Skip to content

Commit d6b1b0a

Browse files
nejchmax-wittig
authored andcommitted
feat: add a minimal GraphQL client
1 parent d44ddd2 commit d6b1b0a

14 files changed

+210
-24
lines changed

docs/api-usage-graphql.rst

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
############################
2+
Using the GraphQL API (beta)
3+
############################
4+
5+
python-gitlab provides basic support for executing GraphQL queries and mutations.
6+
7+
.. danger::
8+
9+
The GraphQL client is experimental and only provides basic support.
10+
It does not currently support pagination, obey rate limits,
11+
or attempt complex retries. You can use it to build simple queries and mutations.
12+
13+
It is currently unstable and its implementation may change. You can expect a more
14+
mature client in one of the upcoming versions.
15+
16+
The ``gitlab.GraphQL`` class
17+
==================================
18+
19+
As with the REST client, you connect to a GitLab instance by creating a ``gitlab.GraphQL`` object:
20+
21+
.. code-block:: python
22+
23+
import gitlab
24+
25+
# anonymous read-only access for public resources (GitLab.com)
26+
gq = gitlab.GraphQL()
27+
28+
# anonymous read-only access for public resources (self-hosted GitLab instance)
29+
gq = gitlab.GraphQL('https://gitlab.example.com')
30+
31+
# personal access token or OAuth2 token authentication (GitLab.com)
32+
gq = gitlab.GraphQL(token='glpat-JVNSESs8EwWRx5yDxM5q')
33+
34+
# personal access token or OAuth2 token authentication (self-hosted GitLab instance)
35+
gq = gitlab.GraphQL('https://gitlab.example.com', token='glpat-JVNSESs8EwWRx5yDxM5q')
36+
37+
Sending queries
38+
===============
39+
40+
Get the result of a query:
41+
42+
.. code-block:: python
43+
44+
query = """{
45+
query {
46+
currentUser {
47+
name
48+
}
49+
}
50+
"""
51+
52+
result = gq.execute(query)

docs/api-usage.rst

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
############################
2-
Getting started with the API
3-
############################
1+
##################
2+
Using the REST API
3+
##################
44

5-
python-gitlab only supports GitLab API v4.
5+
python-gitlab currently only supports v4 of the GitLab REST API.
66

77
``gitlab.Gitlab`` class
88
=======================

docs/cli-usage.rst

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
############################
2-
Getting started with the CLI
3-
############################
1+
#############
2+
Using the CLI
3+
#############
44

55
``python-gitlab`` provides a :command:`gitlab` command-line tool to interact
66
with GitLab servers.

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
cli-usage
88
api-usage
99
api-usage-advanced
10+
api-usage-graphql
1011
cli-examples
1112
api-objects
1213
api/gitlab

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 # noqa: F401
30+
from gitlab.client import Gitlab, GitlabList, GraphQL # noqa: F401
3131
from gitlab.exceptions import * # noqa: F401,F403
3232

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

gitlab/_backends/graphql.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import Any
2+
3+
import httpx
4+
from gql.transport.httpx import HTTPXTransport
5+
6+
7+
class GitlabTransport(HTTPXTransport):
8+
"""A gql httpx transport that reuses an existing httpx.Client.
9+
By default, gql's transports do not have a keep-alive session
10+
and do not enable providing your own session that's kept open.
11+
This transport lets us provide and close our session on our own
12+
and provide additional auth.
13+
For details, see https://github.com/graphql-python/gql/issues/91.
14+
"""
15+
16+
def __init__(self, *args: Any, client: httpx.Client, **kwargs: Any):
17+
super().__init__(*args, **kwargs)
18+
self.client = client
19+
20+
def connect(self) -> None:
21+
pass
22+
23+
def close(self) -> None:
24+
pass

gitlab/client.py

+72-13
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@
2525
import gitlab.exceptions
2626
from gitlab import _backends, utils
2727

28+
try:
29+
import gql
30+
import graphql
31+
import httpx
32+
33+
from ._backends.graphql import GitlabTransport
34+
35+
_GQL_INSTALLED = True
36+
except ImportError: # pragma: no cover
37+
_GQL_INSTALLED = False
38+
39+
2840
REDIRECT_MSG = (
2941
"python-gitlab detected a {status_code} ({reason!r}) redirection. You must update "
3042
"your GitLab URL to the correct URL to avoid issues. The redirection was from: "
@@ -89,7 +101,7 @@ def __init__(
89101
self._api_version = str(api_version)
90102
self._server_version: Optional[str] = None
91103
self._server_revision: Optional[str] = None
92-
self._base_url = self._get_base_url(url)
104+
self._base_url = utils.get_base_url(url)
93105
self._url = f"{self._base_url}/api/v{api_version}"
94106
#: Timeout to use for requests to gitlab server
95107
self.timeout = timeout
@@ -557,18 +569,6 @@ def _get_session_opts(self) -> Dict[str, Any]:
557569
"verify": self.ssl_verify,
558570
}
559571

560-
@staticmethod
561-
def _get_base_url(url: Optional[str] = None) -> str:
562-
"""Return the base URL with the trailing slash stripped.
563-
If the URL is a Falsy value, return the default URL.
564-
Returns:
565-
The base URL
566-
"""
567-
if not url:
568-
return gitlab.const.DEFAULT_URL
569-
570-
return url.rstrip("/")
571-
572572
def _build_url(self, path: str) -> str:
573573
"""Returns the full url from path.
574574
@@ -1296,3 +1296,62 @@ def next(self) -> Dict[str, Any]:
12961296
return self.next()
12971297

12981298
raise StopIteration
1299+
1300+
1301+
class GraphQL:
1302+
def __init__(
1303+
self,
1304+
url: Optional[str] = None,
1305+
*,
1306+
token: Optional[str] = None,
1307+
ssl_verify: Union[bool, str] = True,
1308+
client: Optional[httpx.Client] = None,
1309+
timeout: Optional[float] = None,
1310+
user_agent: str = gitlab.const.USER_AGENT,
1311+
fetch_schema_from_transport: bool = False,
1312+
) -> None:
1313+
if not _GQL_INSTALLED:
1314+
raise ImportError(
1315+
"The GraphQL client could not be initialized because "
1316+
"the gql dependencies are not installed. "
1317+
"Install them with 'pip install python-gitlab[graphql]'"
1318+
)
1319+
self._base_url = utils.get_base_url(url)
1320+
self._timeout = timeout
1321+
self._token = token
1322+
self._url = f"{self._base_url}/api/graphql"
1323+
self._user_agent = user_agent
1324+
self._ssl_verify = ssl_verify
1325+
1326+
opts = self._get_client_opts()
1327+
self._http_client = client or httpx.Client(**opts)
1328+
self._transport = GitlabTransport(self._url, client=self._http_client)
1329+
self._client = gql.Client(
1330+
transport=self._transport,
1331+
fetch_schema_from_transport=fetch_schema_from_transport,
1332+
)
1333+
self._gql = gql.gql
1334+
1335+
def __enter__(self) -> "GraphQL":
1336+
return self
1337+
1338+
def __exit__(self, *args: Any) -> None:
1339+
self._http_client.close()
1340+
1341+
def _get_client_opts(self) -> Dict[str, Any]:
1342+
headers = {"User-Agent": self._user_agent}
1343+
1344+
if self._token:
1345+
headers["Authorization"] = f"Bearer {self._token}"
1346+
1347+
return {
1348+
"headers": headers,
1349+
"timeout": self._timeout,
1350+
"verify": self._ssl_verify,
1351+
}
1352+
1353+
def execute(
1354+
self, request: Union[str, graphql.Source], *args: Any, **kwargs: Any
1355+
) -> Any:
1356+
parsed_document = self._gql(request)
1357+
return self._client.execute(parsed_document, *args, **kwargs)

gitlab/utils.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,26 @@
99

1010
import requests
1111

12-
from gitlab import types
12+
from gitlab import const, types
1313

1414

1515
class _StdoutStream:
1616
def __call__(self, chunk: Any) -> None:
1717
print(chunk)
1818

1919

20+
def get_base_url(url: Optional[str] = None) -> str:
21+
"""Return the base URL with the trailing slash stripped.
22+
If the URL is a Falsy value, return the default URL.
23+
Returns:
24+
The base URL
25+
"""
26+
if not url:
27+
return const.DEFAULT_URL
28+
29+
return url.rstrip("/")
30+
31+
2032
def get_content_type(content_type: Optional[str]) -> str:
2133
message = email.message.Message()
2234
if content_type is not None:

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dynamic = ["version"]
4343
[project.optional-dependencies]
4444
autocompletion = ["argcomplete>=1.10.0,<3"]
4545
yaml = ["PyYaml>=6.0.1"]
46+
graphql = ["gql[httpx]>=3.5.0,<4"]
4647

4748
[project.scripts]
4849
gitlab = "gitlab.cli:main"

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
gql==3.5.0
2+
httpx==0.27.0
13
requests==2.32.3
24
requests-toolbelt==1.0.0

tests/functional/api/test_graphql.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import logging
2+
3+
import pytest
4+
5+
import gitlab
6+
7+
8+
@pytest.fixture
9+
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)
12+
13+
return instance
14+
15+
16+
def test_query_returns_valid_response(gl_gql: gitlab.GraphQL):
17+
query = "query {currentUser {active}}"
18+
19+
response = gl_gql.execute(query)
20+
assert response["currentUser"]["active"] is True

tests/functional/fixtures/set_token.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
user = User.find_by_username('root')
44

55
token = user.personal_access_tokens.first_or_create(scopes: ['api', 'sudo'], name: 'default', expires_at: 365.days.from_now);
6-
token.set_token('python-gitlab-token');
6+
token.set_token('glpat-python-gitlab-token_');
77
token.save!
88

99
puts token.token

tests/install/test_install.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33

44
def test_install() -> None:
55
with pytest.raises(ImportError):
6-
import httpx # type: ignore # noqa
6+
import aiohttp # type: ignore # noqa

tests/unit/test_graphql.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pytest
2+
3+
import gitlab
4+
5+
6+
def test_import_error_includes_message(monkeypatch: pytest.MonkeyPatch):
7+
monkeypatch.setattr(gitlab.client, "_GQL_INSTALLED", False)
8+
with pytest.raises(ImportError, match="GraphQL client could not be initialized"):
9+
gitlab.GraphQL()
10+
11+
12+
def test_graphql_as_context_manager_exits():
13+
with gitlab.GraphQL() as gl:
14+
assert isinstance(gl, gitlab.GraphQL)

0 commit comments

Comments
 (0)