Skip to content

Commit 288f39c

Browse files
nejchmax-wittig
authored andcommitted
feat(graphql): add async client
1 parent 8046387 commit 288f39c

File tree

9 files changed

+259
-36
lines changed

9 files changed

+259
-36
lines changed

README.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ python-gitlab
2525
.. image:: https://img.shields.io/github/license/python-gitlab/python-gitlab
2626
:target: https://github.com/python-gitlab/python-gitlab/blob/main/COPYING
2727

28-
``python-gitlab`` is a Python package providing access to the GitLab server API.
28+
``python-gitlab`` is a Python package providing access to the GitLab APIs.
2929

30-
It supports the v4 API of GitLab, and provides a CLI tool (``gitlab``).
30+
It includes a client for GitLab's v4 REST API, synchronous and asynchronous GraphQL API
31+
clients, as well as a CLI tool (``gitlab``) wrapping REST API endpoints.
3132

3233
.. _features:
3334

@@ -39,6 +40,7 @@ Features
3940
* write Pythonic code to manage your GitLab resources.
4041
* pass arbitrary parameters to the GitLab API. Simply follow GitLab's docs
4142
on what parameters are available.
43+
* use a synchronous or asynchronous client when using the GraphQL API.
4244
* access arbitrary endpoints as soon as they are available on GitLab, by using
4345
lower-level API methods.
4446
* use persistent requests sessions for authentication, proxy and certificate handling.

docs/api-usage-graphql.rst

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Using the GraphQL API (beta)
33
############################
44

5-
python-gitlab provides basic support for executing GraphQL queries and mutations.
5+
python-gitlab provides basic support for executing GraphQL queries and mutations,
6+
providing both a synchronous and asynchronous client.
67

78
.. danger::
89

@@ -13,10 +14,11 @@ python-gitlab provides basic support for executing GraphQL queries and mutations
1314
It is currently unstable and its implementation may change. You can expect a more
1415
mature client in one of the upcoming versions.
1516

16-
The ``gitlab.GraphQL`` class
17-
==================================
17+
The ``gitlab.GraphQL`` and ``gitlab.AsyncGraphQL`` classes
18+
==========================================================
1819

19-
As with the REST client, you connect to a GitLab instance by creating a ``gitlab.GraphQL`` object:
20+
As with the REST client, you connect to a GitLab instance by creating a ``gitlab.GraphQL``
21+
(for synchronous code) or ``gitlab.AsyncGraphQL`` instance (for asynchronous code):
2022

2123
.. code-block:: python
2224
@@ -34,6 +36,12 @@ As with the REST client, you connect to a GitLab instance by creating a ``gitlab
3436
# personal access token or OAuth2 token authentication (self-hosted GitLab instance)
3537
gq = gitlab.GraphQL('https://gitlab.example.com', token='glpat-JVNSESs8EwWRx5yDxM5q')
3638
39+
# or the async equivalents
40+
async_gq = gitlab.AsyncGraphQL()
41+
async_gq = gitlab.AsyncGraphQL('https://gitlab.example.com')
42+
async_gq = gitlab.AsyncGraphQL(token='glpat-JVNSESs8EwWRx5yDxM5q')
43+
async_gq = gitlab.AsyncGraphQL('https://gitlab.example.com', token='glpat-JVNSESs8EwWRx5yDxM5q')
44+
3745
Sending queries
3846
===============
3947

@@ -50,3 +58,17 @@ Get the result of a query:
5058
"""
5159
5260
result = gq.execute(query)
61+
62+
Get the result of a query using the async client:
63+
64+
.. code-block:: python
65+
66+
query = """{
67+
query {
68+
currentUser {
69+
name
70+
}
71+
}
72+
"""
73+
74+
result = await async_gq.execute(query)

gitlab/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
__title__,
2828
__version__,
2929
)
30-
from gitlab.client import Gitlab, GitlabList, GraphQL # noqa: F401
30+
from gitlab.client import AsyncGraphQL, Gitlab, GitlabList, GraphQL # noqa: F401
3131
from gitlab.exceptions import * # noqa: F401,F403
3232

3333
warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab")
@@ -42,6 +42,7 @@
4242
"__version__",
4343
"Gitlab",
4444
"GitlabList",
45+
"AsyncGraphQL",
4546
"GraphQL",
4647
]
4748
__all__.extend(gitlab.exceptions.__all__)

gitlab/_backends/graphql.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Any
22

33
import httpx
4-
from gql.transport.httpx import HTTPXTransport
4+
from gql.transport.httpx import HTTPXAsyncTransport, HTTPXTransport
55

66

77
class GitlabTransport(HTTPXTransport):
@@ -22,3 +22,23 @@ def connect(self) -> None:
2222

2323
def close(self) -> None:
2424
pass
25+
26+
27+
class GitlabAsyncTransport(HTTPXAsyncTransport):
28+
"""An async gql httpx transport that reuses an existing httpx.AsyncClient.
29+
By default, gql's transports do not have a keep-alive session
30+
and do not enable providing your own session that's kept open.
31+
This transport lets us provide and close our session on our own
32+
and provide additional auth.
33+
For details, see https://github.com/graphql-python/gql/issues/91.
34+
"""
35+
36+
def __init__(self, *args: Any, client: httpx.AsyncClient, **kwargs: Any):
37+
super().__init__(*args, **kwargs)
38+
self.client = client
39+
40+
async def connect(self) -> None:
41+
pass
42+
43+
async def close(self) -> None:
44+
pass

gitlab/client.py

Lines changed: 120 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import graphql
3333
import httpx
3434

35-
from ._backends.graphql import GitlabTransport
35+
from ._backends.graphql import GitlabAsyncTransport, GitlabTransport
3636

3737
_GQL_INSTALLED = True
3838
except ImportError: # pragma: no cover
@@ -1278,14 +1278,13 @@ def next(self) -> Dict[str, Any]:
12781278
raise StopIteration
12791279

12801280

1281-
class GraphQL:
1281+
class _BaseGraphQL:
12821282
def __init__(
12831283
self,
12841284
url: Optional[str] = None,
12851285
*,
12861286
token: Optional[str] = None,
12871287
ssl_verify: Union[bool, str] = True,
1288-
client: Optional[httpx.Client] = None,
12891288
timeout: Optional[float] = None,
12901289
user_agent: str = gitlab.const.USER_AGENT,
12911290
fetch_schema_from_transport: bool = False,
@@ -1308,9 +1307,50 @@ def __init__(
13081307
self._max_retries = max_retries
13091308
self._obey_rate_limit = obey_rate_limit
13101309
self._retry_transient_errors = retry_transient_errors
1310+
self._client_opts = self._get_client_opts()
1311+
self._fetch_schema_from_transport = fetch_schema_from_transport
1312+
1313+
def _get_client_opts(self) -> Dict[str, Any]:
1314+
headers = {"User-Agent": self._user_agent}
1315+
1316+
if self._token:
1317+
headers["Authorization"] = f"Bearer {self._token}"
1318+
1319+
return {
1320+
"headers": headers,
1321+
"timeout": self._timeout,
1322+
"verify": self._ssl_verify,
1323+
}
1324+
13111325

1312-
opts = self._get_client_opts()
1313-
self._http_client = client or httpx.Client(**opts)
1326+
class GraphQL(_BaseGraphQL):
1327+
def __init__(
1328+
self,
1329+
url: Optional[str] = None,
1330+
*,
1331+
token: Optional[str] = None,
1332+
ssl_verify: Union[bool, str] = True,
1333+
client: Optional[httpx.Client] = None,
1334+
timeout: Optional[float] = None,
1335+
user_agent: str = gitlab.const.USER_AGENT,
1336+
fetch_schema_from_transport: bool = False,
1337+
max_retries: int = 10,
1338+
obey_rate_limit: bool = True,
1339+
retry_transient_errors: bool = False,
1340+
) -> None:
1341+
super().__init__(
1342+
url=url,
1343+
token=token,
1344+
ssl_verify=ssl_verify,
1345+
timeout=timeout,
1346+
user_agent=user_agent,
1347+
fetch_schema_from_transport=fetch_schema_from_transport,
1348+
max_retries=max_retries,
1349+
obey_rate_limit=obey_rate_limit,
1350+
retry_transient_errors=retry_transient_errors,
1351+
)
1352+
1353+
self._http_client = client or httpx.Client(**self._client_opts)
13141354
self._transport = GitlabTransport(self._url, client=self._http_client)
13151355
self._client = gql.Client(
13161356
transport=self._transport,
@@ -1324,19 +1364,81 @@ def __enter__(self) -> "GraphQL":
13241364
def __exit__(self, *args: Any) -> None:
13251365
self._http_client.close()
13261366

1327-
def _get_client_opts(self) -> Dict[str, Any]:
1328-
headers = {"User-Agent": self._user_agent}
1367+
def execute(
1368+
self, request: Union[str, graphql.Source], *args: Any, **kwargs: Any
1369+
) -> Any:
1370+
parsed_document = self._gql(request)
1371+
retry = utils.Retry(
1372+
max_retries=self._max_retries,
1373+
obey_rate_limit=self._obey_rate_limit,
1374+
retry_transient_errors=self._retry_transient_errors,
1375+
)
13291376

1330-
if self._token:
1331-
headers["Authorization"] = f"Bearer {self._token}"
1377+
while True:
1378+
try:
1379+
result = self._client.execute(parsed_document, *args, **kwargs)
1380+
except gql.transport.exceptions.TransportServerError as e:
1381+
if retry.handle_retry_on_status(
1382+
status_code=e.code, headers=self._transport.response_headers
1383+
):
1384+
continue
13321385

1333-
return {
1334-
"headers": headers,
1335-
"timeout": self._timeout,
1336-
"verify": self._ssl_verify,
1337-
}
1386+
if e.code == 401:
1387+
raise gitlab.exceptions.GitlabAuthenticationError(
1388+
response_code=e.code,
1389+
error_message=str(e),
1390+
)
13381391

1339-
def execute(
1392+
raise gitlab.exceptions.GitlabHttpError(
1393+
response_code=e.code,
1394+
error_message=str(e),
1395+
)
1396+
1397+
return result
1398+
1399+
1400+
class AsyncGraphQL(_BaseGraphQL):
1401+
def __init__(
1402+
self,
1403+
url: Optional[str] = None,
1404+
*,
1405+
token: Optional[str] = None,
1406+
ssl_verify: Union[bool, str] = True,
1407+
client: Optional[httpx.AsyncClient] = None,
1408+
timeout: Optional[float] = None,
1409+
user_agent: str = gitlab.const.USER_AGENT,
1410+
fetch_schema_from_transport: bool = False,
1411+
max_retries: int = 10,
1412+
obey_rate_limit: bool = True,
1413+
retry_transient_errors: bool = False,
1414+
) -> None:
1415+
super().__init__(
1416+
url=url,
1417+
token=token,
1418+
ssl_verify=ssl_verify,
1419+
timeout=timeout,
1420+
user_agent=user_agent,
1421+
fetch_schema_from_transport=fetch_schema_from_transport,
1422+
max_retries=max_retries,
1423+
obey_rate_limit=obey_rate_limit,
1424+
retry_transient_errors=retry_transient_errors,
1425+
)
1426+
1427+
self._http_client = client or httpx.AsyncClient(**self._client_opts)
1428+
self._transport = GitlabAsyncTransport(self._url, client=self._http_client)
1429+
self._client = gql.Client(
1430+
transport=self._transport,
1431+
fetch_schema_from_transport=fetch_schema_from_transport,
1432+
)
1433+
self._gql = gql.gql
1434+
1435+
async def __aenter__(self) -> "AsyncGraphQL":
1436+
return self
1437+
1438+
async def __aexit__(self, *args: Any) -> None:
1439+
await self._http_client.aclose()
1440+
1441+
async def execute(
13401442
self, request: Union[str, graphql.Source], *args: Any, **kwargs: Any
13411443
) -> Any:
13421444
parsed_document = self._gql(request)
@@ -1348,7 +1450,9 @@ def execute(
13481450

13491451
while True:
13501452
try:
1351-
result = self._client.execute(parsed_document, *args, **kwargs)
1453+
result = await self._client.execute_async(
1454+
parsed_document, *args, **kwargs
1455+
)
13521456
except gql.transport.exceptions.TransportServerError as e:
13531457
if retry.handle_retry_on_status(
13541458
status_code=e.code, headers=self._transport.response_headers

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-gitlab"
7-
description="A python wrapper for the GitLab API"
7+
description="The python wrapper for the GitLab REST and GraphQL APIs."
88
readme = "README.rst"
99
authors = [
1010
{name = "Gauvain Pocentek", email= "gauvain@pocentek.net"}

requirements-test.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
-r requirements.txt
2+
anyio==4.6.2.post1
23
build==1.2.2.post1
34
coverage==7.6.8
45
pytest-console-scripts==1.4.1
@@ -8,4 +9,5 @@ pytest==8.3.4
89
PyYaml==6.0.2
910
responses==0.25.3
1011
respx==0.21.1
12+
trio==0.27.0
1113
wheel==0.45.1

tests/functional/api/test_graphql.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1-
import logging
2-
31
import pytest
42

53
import gitlab
64

75

86
@pytest.fixture
97
def gl_gql(gitlab_url: str, gitlab_token: str) -> gitlab.GraphQL:
10-
logging.info("Instantiating gitlab.GraphQL instance")
11-
instance = gitlab.GraphQL(gitlab_url, token=gitlab_token)
8+
return gitlab.GraphQL(gitlab_url, token=gitlab_token)
9+
1210

13-
return instance
11+
@pytest.fixture
12+
def gl_async_gql(gitlab_url: str, gitlab_token: str) -> gitlab.AsyncGraphQL:
13+
return gitlab.AsyncGraphQL(gitlab_url, token=gitlab_token)
1414

1515

1616
def test_query_returns_valid_response(gl_gql: gitlab.GraphQL):
1717
query = "query {currentUser {active}}"
1818

1919
response = gl_gql.execute(query)
2020
assert response["currentUser"]["active"] is True
21+
22+
23+
@pytest.mark.anyio
24+
async def test_async_query_returns_valid_response(gl_async_gql: gitlab.AsyncGraphQL):
25+
query = "query {currentUser {active}}"
26+
27+
response = await gl_async_gql.execute(query)
28+
assert response["currentUser"]["active"] is True

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy