talisman-api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- talisman_api/__init__.py +11 -0
- talisman_api/abstract.py +91 -0
- talisman_api/api_client/__init__.py +9 -0
- talisman_api/api_client/client.py +112 -0
- talisman_api/api_client/composite.py +29 -0
- talisman_api/api_client/gql_clients/__init__.py +11 -0
- talisman_api/api_client/gql_clients/_abstract.py +90 -0
- talisman_api/api_client/gql_clients/_config.py +19 -0
- talisman_api/api_client/gql_clients/_keycloak.py +90 -0
- talisman_api/api_client/gql_clients/_noauth.py +12 -0
- talisman_api/decorator.py +9 -0
- talisman_api/wrapper.py +54 -0
- talisman_api-1.0.0.data/data/VERSION +1 -0
- talisman_api-1.0.0.dist-info/METADATA +24 -0
- talisman_api-1.0.0.dist-info/RECORD +17 -0
- talisman_api-1.0.0.dist-info/WHEEL +5 -0
- talisman_api-1.0.0.dist-info/top_level.txt +1 -0
talisman_api/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'AbstractTalismanAPI',
|
|
3
|
+
'APISchema', 'CompositeTalismanAPIClient',
|
|
4
|
+
'version',
|
|
5
|
+
'TalismanAPIClient'
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
from .abstract import AbstractTalismanAPI
|
|
9
|
+
from .api_client import APISchema, CompositeTalismanAPIClient
|
|
10
|
+
from .decorator import version
|
|
11
|
+
from .wrapper import TalismanAPIClient
|
talisman_api/abstract.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABCMeta, abstractmethod
|
|
4
|
+
from importlib import import_module, resources
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from gql import gql
|
|
8
|
+
from graphql import DocumentNode, GraphQLError
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from .api_client import CompositeTalismanAPIClient
|
|
12
|
+
from .api_client.composite import APISchema
|
|
13
|
+
from .wrapper import TalismanAPIClient
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AbstractTalismanAPI(metaclass=ABCMeta):
|
|
19
|
+
_VERSION: str
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: CompositeTalismanAPIClient, documents: dict[APISchema, DocumentNode]):
|
|
22
|
+
if set(client) != set(documents):
|
|
23
|
+
raise ValueError
|
|
24
|
+
self._composite_client: CompositeTalismanAPIClient = client
|
|
25
|
+
self._documents: dict[APISchema, DocumentNode] = documents
|
|
26
|
+
|
|
27
|
+
def _client(self, schema: APISchema) -> TalismanAPIClient:
|
|
28
|
+
return TalismanAPIClient(self._composite_client[schema], self._documents[schema])
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def _required_apis(cls) -> Iterable[APISchema]:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
async def create(cls, client: CompositeTalismanAPIClient) -> Self:
|
|
37
|
+
documents: dict[APISchema, DocumentNode] = {}
|
|
38
|
+
for api_schema in cls._required_apis():
|
|
39
|
+
if api_schema not in client:
|
|
40
|
+
raise ValueError(f'Client is not configured to use {api_schema} API')
|
|
41
|
+
|
|
42
|
+
package, _ = cls.__module__.rsplit('.', maxsplit=1)
|
|
43
|
+
resource_name = f'graphql/{cls._VERSION}/{api_schema.value}.graphql'
|
|
44
|
+
try:
|
|
45
|
+
with (resources.files(package) / resource_name).open('r', encoding='utf-8') as f:
|
|
46
|
+
document: str = f.read()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
raise ValueError(f'Package {package} contains no required {resource_name} file') from e
|
|
49
|
+
try:
|
|
50
|
+
document: DocumentNode = gql(document)
|
|
51
|
+
except GraphQLError as e:
|
|
52
|
+
raise ValueError(f'GraphQL syntax error if {resource_name} (package {package})') from e
|
|
53
|
+
|
|
54
|
+
api_client = client[api_schema]
|
|
55
|
+
async with api_client:
|
|
56
|
+
if not api_client.validate(document):
|
|
57
|
+
raise ValueError(f'{resource_name} (package {package}) do not match runtime server {api_schema} API')
|
|
58
|
+
documents[api_schema] = document
|
|
59
|
+
return cls(client, documents)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
async def get_compatible_api(
|
|
63
|
+
cls,
|
|
64
|
+
client: CompositeTalismanAPIClient,
|
|
65
|
+
*,
|
|
66
|
+
logger: logging.Logger = _LOGGER,
|
|
67
|
+
impl_package: str = '_impl'
|
|
68
|
+
) -> Self:
|
|
69
|
+
package, _ = cls.__module__.rsplit('.', 1)
|
|
70
|
+
package_name = f'{package}.{impl_package}'
|
|
71
|
+
package = import_module(package_name)
|
|
72
|
+
if not hasattr(package, '__all__'):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f'{package_name}.__init__ should define __all__ attribute with ordered API implementation classes'
|
|
75
|
+
)
|
|
76
|
+
for impl_name in package.__all__:
|
|
77
|
+
impl_cls = getattr(package, impl_name, None)
|
|
78
|
+
if not inspect.isclass(impl_cls):
|
|
79
|
+
logger.error(f'{package_name}.__all__ should contains only API implementation classes')
|
|
80
|
+
continue
|
|
81
|
+
if not issubclass(impl_cls, cls):
|
|
82
|
+
logger.error(f'{package_name}.{impl_name} should inherit {cls}')
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
return await impl_cls.create(client)
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
logger.info(
|
|
88
|
+
f'{package_name}.{impl_name} (version: "{impl_cls._VERSION}") is not compatible with actual API: {e}',
|
|
89
|
+
exc_info=e
|
|
90
|
+
)
|
|
91
|
+
raise NotImplementedError('API version is not supported')
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import AsyncIterator, Callable
|
|
5
|
+
|
|
6
|
+
from graphql import DocumentNode
|
|
7
|
+
from requests import Timeout
|
|
8
|
+
|
|
9
|
+
from .gql_clients import AsyncAbstractGQLClient, GQLClientConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TalismanAPIClient(AbstractAsyncContextManager):
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: GQLClientConfig):
|
|
17
|
+
self._config = config
|
|
18
|
+
self._gql_client: AsyncAbstractGQLClient | None = None
|
|
19
|
+
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
self._opened: int = 0
|
|
22
|
+
self._close_task = None
|
|
23
|
+
|
|
24
|
+
async def __aenter__(self):
|
|
25
|
+
async with self._lock:
|
|
26
|
+
self._opened += 1
|
|
27
|
+
if self._gql_client is None:
|
|
28
|
+
self._gql_client = self._config.configure()
|
|
29
|
+
await self._gql_client.__aenter__()
|
|
30
|
+
if self._close_task is not None:
|
|
31
|
+
self._close_task.cancel()
|
|
32
|
+
self._close_task = None
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
async def __aexit__(self, exc_type=None, exc_val=None, exc_tb=None):
|
|
36
|
+
async with self._lock:
|
|
37
|
+
self._opened -= 1
|
|
38
|
+
if self._opened == 0 and self._close_task is None:
|
|
39
|
+
self._close_task = asyncio.create_task(self._delayed_close())
|
|
40
|
+
|
|
41
|
+
async def _delayed_close(self, delay: int = 10):
|
|
42
|
+
try:
|
|
43
|
+
await asyncio.sleep(delay)
|
|
44
|
+
async with self._lock:
|
|
45
|
+
if self._opened == 0 and self._gql_client is not None:
|
|
46
|
+
await self._gql_client.__aexit__(None, None, None)
|
|
47
|
+
self._gql_client = None
|
|
48
|
+
except asyncio.CancelledError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def validate(self, document: DocumentNode) -> bool:
|
|
52
|
+
return self._gql_client.validate(document)
|
|
53
|
+
|
|
54
|
+
async def execute(self, document: DocumentNode, operation_name: str, variables: dict | None = None, raise_on_timeout: bool = True):
|
|
55
|
+
try:
|
|
56
|
+
async with self:
|
|
57
|
+
return await self._gql_client.execute(document, operation_name=operation_name, variables=variables)
|
|
58
|
+
|
|
59
|
+
except Timeout as e:
|
|
60
|
+
logger.error('Timeout while query processing', exc_info=e,
|
|
61
|
+
extra={'variables': str(variables), 'operation_name': operation_name})
|
|
62
|
+
if raise_on_timeout:
|
|
63
|
+
raise e
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error('Some exception was occured during query processing.', exc_info=e,
|
|
66
|
+
extra={'variables': str(variables), 'operation_name': operation_name})
|
|
67
|
+
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
async def paginate_items(
|
|
71
|
+
self,
|
|
72
|
+
document: DocumentNode,
|
|
73
|
+
operation_name: str,
|
|
74
|
+
variables: dict = None,
|
|
75
|
+
*,
|
|
76
|
+
page_size: int = 50,
|
|
77
|
+
extract_page: Callable[[dict], dict] = lambda d: d
|
|
78
|
+
) -> AsyncIterator[dict]:
|
|
79
|
+
if variables is None:
|
|
80
|
+
variables = {}
|
|
81
|
+
total = 1 # some value greater than 0
|
|
82
|
+
offset = 0
|
|
83
|
+
while offset < total:
|
|
84
|
+
query_variables = {**variables, "offset": offset, "limit": page_size}
|
|
85
|
+
page = extract_page(await self.execute(document, operation_name, query_variables))
|
|
86
|
+
total = page['pagination']['total']
|
|
87
|
+
items = page['pagination'].get('list', tuple())
|
|
88
|
+
for item in items:
|
|
89
|
+
yield item
|
|
90
|
+
offset += len(items)
|
|
91
|
+
|
|
92
|
+
async def get_all_items(
|
|
93
|
+
self,
|
|
94
|
+
document: DocumentNode,
|
|
95
|
+
operation_name: str,
|
|
96
|
+
variables: dict = None,
|
|
97
|
+
*,
|
|
98
|
+
extract_page: Callable[[dict], dict] = lambda d: d
|
|
99
|
+
):
|
|
100
|
+
if variables is None:
|
|
101
|
+
variables = {}
|
|
102
|
+
# get total only
|
|
103
|
+
query_variables = {**variables, "offset": 0, "limit": 0}
|
|
104
|
+
page = extract_page(await self.execute(document, operation_name, query_variables))
|
|
105
|
+
total = page['pagination']['total']
|
|
106
|
+
|
|
107
|
+
# get all items
|
|
108
|
+
query_variables = {**variables, "offset": 0, "limit": total}
|
|
109
|
+
page = extract_page(await self.execute(document, operation_name, query_variables))
|
|
110
|
+
items = page['pagination'].get('list', tuple())
|
|
111
|
+
for item in items:
|
|
112
|
+
yield item
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Iterator, Mapping
|
|
3
|
+
|
|
4
|
+
from .client import TalismanAPIClient
|
|
5
|
+
from .gql_clients import GQLClientConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APISchema(str, Enum):
|
|
9
|
+
PUBLIC = "public"
|
|
10
|
+
KB_UTILS = "kbutils"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CompositeTalismanAPIClient(Mapping[APISchema, TalismanAPIClient]):
|
|
14
|
+
def __init__(self, config: dict[str | APISchema, GQLClientConfig]):
|
|
15
|
+
self._api_clients: dict[APISchema, TalismanAPIClient] = {
|
|
16
|
+
APISchema(key): TalismanAPIClient(value) for key, value in config.items()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def __contains__(self, item: object) -> bool:
|
|
20
|
+
return item in self._api_clients
|
|
21
|
+
|
|
22
|
+
def __getitem__(self, item: APISchema) -> TalismanAPIClient:
|
|
23
|
+
return self._api_clients.get(item)
|
|
24
|
+
|
|
25
|
+
def __iter__(self) -> Iterator[APISchema]:
|
|
26
|
+
return iter(self._api_clients)
|
|
27
|
+
|
|
28
|
+
def __len__(self) -> int:
|
|
29
|
+
return len(self._api_clients)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'AsyncAbstractGQLClient',
|
|
3
|
+
'GQLClientConfig',
|
|
4
|
+
'AsyncKeycloakAwareGQLClient',
|
|
5
|
+
'AsyncNoAuthGQLClient'
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
from ._abstract import AsyncAbstractGQLClient
|
|
9
|
+
from ._config import GQLClientConfig
|
|
10
|
+
from ._keycloak import AsyncKeycloakAwareGQLClient
|
|
11
|
+
from ._noauth import AsyncNoAuthGQLClient
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABCMeta
|
|
3
|
+
from asyncio import Semaphore
|
|
4
|
+
from contextlib import AbstractAsyncContextManager
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
import backoff
|
|
8
|
+
from aiorwlock import RWLock
|
|
9
|
+
from gql import Client
|
|
10
|
+
from gql.client import AsyncClientSession
|
|
11
|
+
from gql.transport.aiohttp import AIOHTTPTransport, log as aiohttp_logger
|
|
12
|
+
from gql.transport.exceptions import TransportQueryError
|
|
13
|
+
from gql.transport.requests import log as requests_logger
|
|
14
|
+
from graphql import DocumentNode
|
|
15
|
+
|
|
16
|
+
from tp_interfaces.logging.time import AsyncTimeMeasurer
|
|
17
|
+
|
|
18
|
+
requests_logger.setLevel(logging.WARNING)
|
|
19
|
+
aiohttp_logger.setLevel(logging.ERROR)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncAbstractGQLClient(AbstractAsyncContextManager, metaclass=ABCMeta):
|
|
25
|
+
|
|
26
|
+
def __init__(self, gql_uri: str, timeout: int = 60, concurrency_limit: int = 10, retry_execute: bool | Callable = True):
|
|
27
|
+
self._gql_uri = gql_uri
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._retry_execute = retry_execute
|
|
30
|
+
|
|
31
|
+
self._client: Optional[Client] = None
|
|
32
|
+
self._session: Optional[AsyncClientSession] = None
|
|
33
|
+
|
|
34
|
+
self._sema = Semaphore(concurrency_limit)
|
|
35
|
+
self._rw_lock = RWLock()
|
|
36
|
+
|
|
37
|
+
def validate(self, document: DocumentNode) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
self._client.validate(document)
|
|
40
|
+
return True
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
async def execute(self, document: DocumentNode, variables=None, operation_name=None, extra_headers=None, timeout=None):
|
|
45
|
+
async with self._sema, self._rw_lock.reader_lock:
|
|
46
|
+
async with AsyncTimeMeasurer(
|
|
47
|
+
f"query {operation_name}", inline_time=True, logger=logger, warning_threshold=5000,
|
|
48
|
+
extra={"operation_name": operation_name, "variables": variables}
|
|
49
|
+
):
|
|
50
|
+
return await self._session.execute(document, variables, operation_name)
|
|
51
|
+
|
|
52
|
+
async def _configure_session(self, headers: dict = None):
|
|
53
|
+
async with self._rw_lock.writer_lock:
|
|
54
|
+
await self._close_session()
|
|
55
|
+
|
|
56
|
+
transport = AIOHTTPTransport(url=self._gql_uri, headers=headers)
|
|
57
|
+
self._client = Client(transport=transport, fetch_schema_from_transport=True, execute_timeout=self._timeout)
|
|
58
|
+
|
|
59
|
+
# here we could change default behaviour of query retrying: just change retry_execute to backoff decorator
|
|
60
|
+
self._session = await self._client.connect_async(reconnecting=True, retry_execute=self._retry_execute)
|
|
61
|
+
|
|
62
|
+
async def _close_session(self):
|
|
63
|
+
if self._session is not None:
|
|
64
|
+
await self._client.close_async()
|
|
65
|
+
self._session = None
|
|
66
|
+
self._client = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_retry_execute(config: bool | dict = True) -> bool | Callable:
|
|
70
|
+
if isinstance(config, bool):
|
|
71
|
+
return config
|
|
72
|
+
if isinstance(config, dict):
|
|
73
|
+
if not config:
|
|
74
|
+
config = {
|
|
75
|
+
'wait_gen': 'expo',
|
|
76
|
+
'wait_gen_args': {
|
|
77
|
+
'base': 2.71828,
|
|
78
|
+
'factor': 2
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
wait_gen = getattr(backoff, config.get('wait_gen', 'expo'))
|
|
82
|
+
max_tries = config.get('max_tries', 5)
|
|
83
|
+
return backoff.on_exception(
|
|
84
|
+
wait_gen,
|
|
85
|
+
Exception,
|
|
86
|
+
max_tries=max_tries,
|
|
87
|
+
giveup=lambda e: isinstance(e, TransportQueryError),
|
|
88
|
+
**config.get('wait_gen_args', {})
|
|
89
|
+
)
|
|
90
|
+
raise ValueError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
from ._abstract import AsyncAbstractGQLClient, build_retry_execute
|
|
4
|
+
from ._keycloak import AsyncKeycloakAwareGQLClient
|
|
5
|
+
from ._noauth import AsyncNoAuthGQLClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GQLClientConfig(NamedTuple):
|
|
9
|
+
uri: str
|
|
10
|
+
auth: bool = False
|
|
11
|
+
timeout: int = 60
|
|
12
|
+
concurrency_limit: int = 30
|
|
13
|
+
retry_execute: bool | dict = True
|
|
14
|
+
|
|
15
|
+
def configure(self) -> AsyncAbstractGQLClient:
|
|
16
|
+
retry_execute = build_retry_execute(self.retry_execute)
|
|
17
|
+
if self.auth:
|
|
18
|
+
return AsyncKeycloakAwareGQLClient(self.uri, self.timeout, self.concurrency_limit, retry_execute)
|
|
19
|
+
return AsyncNoAuthGQLClient(self.uri, self.timeout, self.concurrency_limit, retry_execute)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from asyncio import Lock
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from keycloak import KeycloakOpenID
|
|
9
|
+
from urllib3.util.retry import log
|
|
10
|
+
|
|
11
|
+
from tp_interfaces.logging.time import TimeMeasurer
|
|
12
|
+
from ._abstract import AsyncAbstractGQLClient
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
log.setLevel(logging.INFO)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthEnvs(enum.Enum):
|
|
19
|
+
AUTH_URL = "KEYCLOAK_AUTH_URL"
|
|
20
|
+
REALM = "KEYCLOAK_REALM"
|
|
21
|
+
CLIENT_ID = "KEYCLOAK_CLIENT_ID"
|
|
22
|
+
CLIENT_KEY = "KEYCLOAK_CLIENT_KEY"
|
|
23
|
+
USER = "KEYCLOAK_USER"
|
|
24
|
+
PWD = "KEYCLOAK_PWD"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def env(self) -> str:
|
|
28
|
+
return os.getenv(self.value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncKeycloakAwareGQLClient(AsyncAbstractGQLClient):
|
|
32
|
+
_TIME_OFFSET = 10 # in seconds
|
|
33
|
+
|
|
34
|
+
def __init__(self, gql_uri: str, timeout: int = 60, concurrency_limit: int = 10, retry_execute: bool | Callable = True):
|
|
35
|
+
super().__init__(gql_uri, timeout, concurrency_limit, retry_execute)
|
|
36
|
+
|
|
37
|
+
self._auth: dict[AuthEnvs, str] = {env: env.env for env in AuthEnvs}
|
|
38
|
+
|
|
39
|
+
not_set_envs = {env for env, val in self._auth.items() if val is None}
|
|
40
|
+
if not_set_envs:
|
|
41
|
+
raise ValueError(f"Authorization environment values are not set: {not_set_envs}")
|
|
42
|
+
|
|
43
|
+
self._keycloak_openid: Optional[KeycloakOpenID] = None
|
|
44
|
+
self._access_token: Optional[str] = None
|
|
45
|
+
self._refresh_token: Optional[str] = None
|
|
46
|
+
self._access_expiration_timestamp: float = 0
|
|
47
|
+
self._refresh_expiration_timestamp: float = 0
|
|
48
|
+
|
|
49
|
+
self._lock = Lock()
|
|
50
|
+
|
|
51
|
+
async def _ensure_session_liveness(self):
|
|
52
|
+
offsetted_time = time.time() + self._TIME_OFFSET
|
|
53
|
+
if offsetted_time < self._access_expiration_timestamp:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
time_before_req = time.time()
|
|
57
|
+
if offsetted_time < self._refresh_expiration_timestamp and self._refresh_token is not None:
|
|
58
|
+
with TimeMeasurer("refreshing access token with refresh token", logger=logger):
|
|
59
|
+
token_info = self._keycloak_openid.refresh_token(self._refresh_token)
|
|
60
|
+
else:
|
|
61
|
+
with TimeMeasurer("refreshing access token with credentials", logger=logger):
|
|
62
|
+
token_info = self._keycloak_openid.token(self._auth[AuthEnvs.USER], self._auth[AuthEnvs.PWD])
|
|
63
|
+
|
|
64
|
+
self._access_token = token_info['access_token']
|
|
65
|
+
self._access_expiration_timestamp = time_before_req + token_info['expires_in']
|
|
66
|
+
self._refresh_token = token_info['refresh_token']
|
|
67
|
+
self._refresh_expiration_timestamp = time_before_req + token_info['refresh_expires_in']
|
|
68
|
+
|
|
69
|
+
headers = {"X-Auth-Token": self._access_token, "Authorization": f"Bearer {self._access_token}"}
|
|
70
|
+
await self._configure_session(headers)
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self):
|
|
73
|
+
self._keycloak_openid = KeycloakOpenID(
|
|
74
|
+
self._auth[AuthEnvs.AUTH_URL], self._auth[AuthEnvs.REALM], self._auth[AuthEnvs.CLIENT_ID], self._auth[AuthEnvs.CLIENT_KEY]
|
|
75
|
+
)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, exc_type=None, exc_val=None, exc_tb=None):
|
|
79
|
+
async with self._lock:
|
|
80
|
+
async with self._rw_lock.writer_lock:
|
|
81
|
+
await self._close_session()
|
|
82
|
+
|
|
83
|
+
self._access_token, self._refresh_token = None, None
|
|
84
|
+
self._access_expiration_timestamp, self._refresh_expiration_timestamp = 0, 0
|
|
85
|
+
self._keycloak_openid = None
|
|
86
|
+
|
|
87
|
+
async def execute(self, document, variables=None, operation_name=None, extra_headers=None, timeout=None):
|
|
88
|
+
async with self._lock: # lock session configuration to avoid concurrent keycloack requests
|
|
89
|
+
await self._ensure_session_liveness()
|
|
90
|
+
return await super().execute(document, variables, operation_name, extra_headers, timeout)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from ._abstract import AsyncAbstractGQLClient
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AsyncNoAuthGQLClient(AsyncAbstractGQLClient):
|
|
5
|
+
|
|
6
|
+
async def __aenter__(self):
|
|
7
|
+
await self._configure_session()
|
|
8
|
+
return self
|
|
9
|
+
|
|
10
|
+
async def __aexit__(self, exc_type=None, exc_val=None, exc_tb=None):
|
|
11
|
+
async with self._rw_lock.writer_lock:
|
|
12
|
+
await self._close_session()
|
talisman_api/wrapper.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from contextlib import AbstractAsyncContextManager
|
|
2
|
+
from typing import AsyncIterator, Callable
|
|
3
|
+
|
|
4
|
+
from graphql import DocumentNode
|
|
5
|
+
|
|
6
|
+
from .api_client import TalismanAPIClient as BaseClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TalismanAPIClient(AbstractAsyncContextManager):
|
|
10
|
+
def __init__(self, client: BaseClient, document: DocumentNode):
|
|
11
|
+
self._client = client
|
|
12
|
+
self._document = document
|
|
13
|
+
|
|
14
|
+
async def __aenter__(self):
|
|
15
|
+
await self._client.__aenter__()
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
19
|
+
await self._client.__aexit__(exc_type, exc_val, exc_tb)
|
|
20
|
+
|
|
21
|
+
async def execute(self, operation_name: str, variables: dict | None = None, raise_on_timeout: bool = True):
|
|
22
|
+
return await self._client.execute(self._document, operation_name, variables, raise_on_timeout)
|
|
23
|
+
|
|
24
|
+
async def paginate_items(
|
|
25
|
+
self,
|
|
26
|
+
operation_name: str,
|
|
27
|
+
variables: dict = None,
|
|
28
|
+
*,
|
|
29
|
+
page_size: int = 20,
|
|
30
|
+
extract_page: Callable[[dict], dict] = lambda d: d
|
|
31
|
+
) -> AsyncIterator[dict]:
|
|
32
|
+
async for i in self._client.paginate_items(
|
|
33
|
+
self._document,
|
|
34
|
+
operation_name,
|
|
35
|
+
variables,
|
|
36
|
+
page_size=page_size,
|
|
37
|
+
extract_page=extract_page
|
|
38
|
+
):
|
|
39
|
+
yield i
|
|
40
|
+
|
|
41
|
+
async def get_all_items(
|
|
42
|
+
self,
|
|
43
|
+
operation_name: str,
|
|
44
|
+
variables: dict = None,
|
|
45
|
+
*,
|
|
46
|
+
extract_page: Callable[[dict], dict] = lambda d: d
|
|
47
|
+
):
|
|
48
|
+
async for i in self._client.get_all_items(
|
|
49
|
+
self._document,
|
|
50
|
+
operation_name,
|
|
51
|
+
variables,
|
|
52
|
+
extract_page=extract_page
|
|
53
|
+
):
|
|
54
|
+
yield i
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: talisman-api
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python Talisman API client for Talisman-based app
|
|
5
|
+
Author: ISPRAS Talisman NLP team
|
|
6
|
+
Author-email: modis@ispras.ru
|
|
7
|
+
Maintainer: Vladimir Mayorov
|
|
8
|
+
Maintainer-email: vmayorov@ispras.ru
|
|
9
|
+
License: Apache Software License
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: talisman-interfaces (~=0.7)
|
|
19
|
+
Requires-Dist: aiorwlock (~=1.3)
|
|
20
|
+
Requires-Dist: gql[aiohttp] (~=3.4)
|
|
21
|
+
Requires-Dist: requests (~=2.31)
|
|
22
|
+
Requires-Dist: python-keycloak (~=2.16)
|
|
23
|
+
Requires-Dist: typing-extensions (>=4.0.0)
|
|
24
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
talisman_api/__init__.py,sha256=Xyz4wn-nSCuWn1ekRY61ym8-PhXglvqX4o9eveUOefc,302
|
|
2
|
+
talisman_api/abstract.py,sha256=IUUbYOIGgRYafHJqct3JPwhknsBTynrC9LbKCgACKjo,3753
|
|
3
|
+
talisman_api/decorator.py,sha256=otwEnX1AhPUvTblyr47Up1Xf1aHA_jQmGpzhsMXZygc,180
|
|
4
|
+
talisman_api/wrapper.py,sha256=q9PMYJ6R9xOZOXGB2aOs4O0IxHCJH7SzYmI9yX1LAHg,1734
|
|
5
|
+
talisman_api/api_client/__init__.py,sha256=3kLisxkvPOKBVhw9qwTZQmVjeJDmlO9IqgoaNrwRyxc,249
|
|
6
|
+
talisman_api/api_client/client.py,sha256=4F83CU63T0lbN4td_yiaXok2UgDihGMxdf5EzapsC6g,4172
|
|
7
|
+
talisman_api/api_client/composite.py,sha256=iKCGZP7gg2zP1UcNJYtm7FHblYloq9uqyFJzoTks3Y8,882
|
|
8
|
+
talisman_api/api_client/gql_clients/__init__.py,sha256=3wGvo38C862_iOHQwu80Bl6c-OTIXDOBmhrVx9DAP08,306
|
|
9
|
+
talisman_api/api_client/gql_clients/_abstract.py,sha256=OWctaZ3S6wcDIpc_gCeMOCFmVykFBCYAVGl43aUhZPw,3389
|
|
10
|
+
talisman_api/api_client/gql_clients/_config.py,sha256=qnrB_yTwdPZqGCg2Bzfe5SkbOnQW72h8ddItjxmPvNs,703
|
|
11
|
+
talisman_api/api_client/gql_clients/_keycloak.py,sha256=SruxIm_ripB8GVc_VWpPIprNyW7efxZMjvhwA-mksfA,3690
|
|
12
|
+
talisman_api/api_client/gql_clients/_noauth.py,sha256=V9GDkMJg3NH5dZG3InWlpxv8sa1Eha220RYBHad_qao,353
|
|
13
|
+
talisman_api-1.0.0.data/data/VERSION,sha256=klIfw8vZZL3J9YSpkbif3apXVO0cyW1tQkRTOGacEwU,5
|
|
14
|
+
talisman_api-1.0.0.dist-info/METADATA,sha256=t1D--9EojEPZeqTxh2fabjCS1hBud2EeJjI7i1OEuUo,867
|
|
15
|
+
talisman_api-1.0.0.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
|
|
16
|
+
talisman_api-1.0.0.dist-info/top_level.txt,sha256=9i4PbX8BUXG5HjLUv5hrPQg1vgyYBxhD4Uuf8jTVdw8,13
|
|
17
|
+
talisman_api-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
talisman_api
|