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

+4-2
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

+26-4
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

+2-1
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

+21-1
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

+120-16
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

+1-1
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

+2
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

+13-5
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)