Skip to content

Commit

Permalink
feat: add a minimal GraphQL client
Browse files Browse the repository at this point in the history
  • Loading branch information
nejch authored and max-wittig committed Sep 4, 2024
1 parent d44ddd2 commit d6b1b0a
Show file tree
Hide file tree
Showing 14 changed files with 210 additions and 24 deletions.
52 changes: 52 additions & 0 deletions docs/api-usage-graphql.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
############################
Using the GraphQL API (beta)
############################

python-gitlab provides basic support for executing GraphQL queries and mutations.

.. danger::

The GraphQL client is experimental and only provides basic support.
It does not currently support pagination, obey rate limits,
or attempt complex retries. You can use it to build simple queries and mutations.

It is currently unstable and its implementation may change. You can expect a more
mature client in one of the upcoming versions.

The ``gitlab.GraphQL`` class
==================================

As with the REST client, you connect to a GitLab instance by creating a ``gitlab.GraphQL`` object:

.. code-block:: python
import gitlab
# anonymous read-only access for public resources (GitLab.com)
gq = gitlab.GraphQL()
# anonymous read-only access for public resources (self-hosted GitLab instance)
gq = gitlab.GraphQL('https://gitlab.example.com')
# personal access token or OAuth2 token authentication (GitLab.com)
gq = gitlab.GraphQL(token='glpat-JVNSESs8EwWRx5yDxM5q')
# personal access token or OAuth2 token authentication (self-hosted GitLab instance)
gq = gitlab.GraphQL('https://gitlab.example.com', token='glpat-JVNSESs8EwWRx5yDxM5q')
Sending queries
===============

Get the result of a query:

.. code-block:: python
query = """{
query {
currentUser {
name
}
}
"""
result = gq.execute(query)
8 changes: 4 additions & 4 deletions docs/api-usage.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
############################
Getting started with the API
############################
##################
Using the REST API
##################

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

``gitlab.Gitlab`` class
=======================
Expand Down
6 changes: 3 additions & 3 deletions docs/cli-usage.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
############################
Getting started with the CLI
############################
#############
Using the CLI
#############

``python-gitlab`` provides a :command:`gitlab` command-line tool to interact
with GitLab servers.
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
cli-usage
api-usage
api-usage-advanced
api-usage-graphql
cli-examples
api-objects
api/gitlab
Expand Down
3 changes: 2 additions & 1 deletion gitlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
__title__,
__version__,
)
from gitlab.client import Gitlab, GitlabList # noqa: F401
from gitlab.client import Gitlab, GitlabList, GraphQL # noqa: F401
from gitlab.exceptions import * # noqa: F401,F403

warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab")
Expand All @@ -42,5 +42,6 @@
"__version__",
"Gitlab",
"GitlabList",
"GraphQL",
]
__all__.extend(gitlab.exceptions.__all__)
24 changes: 24 additions & 0 deletions gitlab/_backends/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Any

import httpx
from gql.transport.httpx import HTTPXTransport


class GitlabTransport(HTTPXTransport):
"""A gql httpx transport that reuses an existing httpx.Client.
By default, gql's transports do not have a keep-alive session
and do not enable providing your own session that's kept open.
This transport lets us provide and close our session on our own
and provide additional auth.
For details, see https://github.com/graphql-python/gql/issues/91.
"""

def __init__(self, *args: Any, client: httpx.Client, **kwargs: Any):
super().__init__(*args, **kwargs)
self.client = client

def connect(self) -> None:
pass

def close(self) -> None:
pass
85 changes: 72 additions & 13 deletions gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@
import gitlab.exceptions
from gitlab import _backends, utils

try:
import gql
import graphql
import httpx

from ._backends.graphql import GitlabTransport

_GQL_INSTALLED = True
except ImportError: # pragma: no cover
_GQL_INSTALLED = False


REDIRECT_MSG = (
"python-gitlab detected a {status_code} ({reason!r}) redirection. You must update "
"your GitLab URL to the correct URL to avoid issues. The redirection was from: "
Expand Down Expand Up @@ -89,7 +101,7 @@ def __init__(
self._api_version = str(api_version)
self._server_version: Optional[str] = None
self._server_revision: Optional[str] = None
self._base_url = self._get_base_url(url)
self._base_url = utils.get_base_url(url)
self._url = f"{self._base_url}/api/v{api_version}"
#: Timeout to use for requests to gitlab server
self.timeout = timeout
Expand Down Expand Up @@ -557,18 +569,6 @@ def _get_session_opts(self) -> Dict[str, Any]:
"verify": self.ssl_verify,
}

@staticmethod
def _get_base_url(url: Optional[str] = None) -> str:
"""Return the base URL with the trailing slash stripped.
If the URL is a Falsy value, return the default URL.
Returns:
The base URL
"""
if not url:
return gitlab.const.DEFAULT_URL

return url.rstrip("/")

def _build_url(self, path: str) -> str:
"""Returns the full url from path.
Expand Down Expand Up @@ -1296,3 +1296,62 @@ def next(self) -> Dict[str, Any]:
return self.next()

raise StopIteration


class GraphQL:
def __init__(
self,
url: Optional[str] = None,
*,
token: Optional[str] = None,
ssl_verify: Union[bool, str] = True,
client: Optional[httpx.Client] = None,
timeout: Optional[float] = None,
user_agent: str = gitlab.const.USER_AGENT,
fetch_schema_from_transport: bool = False,
) -> None:
if not _GQL_INSTALLED:
raise ImportError(
"The GraphQL client could not be initialized because "
"the gql dependencies are not installed. "
"Install them with 'pip install python-gitlab[graphql]'"
)
self._base_url = utils.get_base_url(url)
self._timeout = timeout
self._token = token
self._url = f"{self._base_url}/api/graphql"
self._user_agent = user_agent
self._ssl_verify = ssl_verify

opts = self._get_client_opts()
self._http_client = client or httpx.Client(**opts)
self._transport = GitlabTransport(self._url, client=self._http_client)
self._client = gql.Client(
transport=self._transport,
fetch_schema_from_transport=fetch_schema_from_transport,
)
self._gql = gql.gql

def __enter__(self) -> "GraphQL":
return self

def __exit__(self, *args: Any) -> None:
self._http_client.close()

def _get_client_opts(self) -> Dict[str, Any]:
headers = {"User-Agent": self._user_agent}

if self._token:
headers["Authorization"] = f"Bearer {self._token}"

return {
"headers": headers,
"timeout": self._timeout,
"verify": self._ssl_verify,
}

def execute(
self, request: Union[str, graphql.Source], *args: Any, **kwargs: Any
) -> Any:
parsed_document = self._gql(request)
return self._client.execute(parsed_document, *args, **kwargs)
14 changes: 13 additions & 1 deletion gitlab/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,26 @@

import requests

from gitlab import types
from gitlab import const, types


class _StdoutStream:
def __call__(self, chunk: Any) -> None:
print(chunk)


def get_base_url(url: Optional[str] = None) -> str:
"""Return the base URL with the trailing slash stripped.
If the URL is a Falsy value, return the default URL.
Returns:
The base URL
"""
if not url:
return const.DEFAULT_URL

return url.rstrip("/")


def get_content_type(content_type: Optional[str]) -> str:
message = email.message.Message()
if content_type is not None:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dynamic = ["version"]
[project.optional-dependencies]
autocompletion = ["argcomplete>=1.10.0,<3"]
yaml = ["PyYaml>=6.0.1"]
graphql = ["gql[httpx]>=3.5.0,<4"]

[project.scripts]
gitlab = "gitlab.cli:main"
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
gql==3.5.0
httpx==0.27.0
requests==2.32.3
requests-toolbelt==1.0.0
20 changes: 20 additions & 0 deletions tests/functional/api/test_graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import logging

import pytest

import gitlab


@pytest.fixture
def gl_gql(gitlab_url: str, gitlab_token: str) -> gitlab.GraphQL:
logging.info("Instantiating gitlab.GraphQL instance")
instance = gitlab.GraphQL(gitlab_url, token=gitlab_token)

return instance


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

response = gl_gql.execute(query)

Check failure on line 19 in tests/functional/api/test_graphql.py

View workflow job for this annotation

GitHub Actions / functional (api_func_v4)

test_query_returns_valid_response gql.transport.exceptions.TransportServerError: Client error '429 Too Many Requests' for url 'http://127.0.0.1:8080/api/graphql' For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429
assert response["currentUser"]["active"] is True
2 changes: 1 addition & 1 deletion tests/functional/fixtures/set_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
user = User.find_by_username('root')

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

puts token.token
2 changes: 1 addition & 1 deletion tests/install/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

def test_install() -> None:
with pytest.raises(ImportError):
import httpx # type: ignore # noqa
import aiohttp # type: ignore # noqa
14 changes: 14 additions & 0 deletions tests/unit/test_graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import pytest

import gitlab


def test_import_error_includes_message(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(gitlab.client, "_GQL_INSTALLED", False)
with pytest.raises(ImportError, match="GraphQL client could not be initialized"):
gitlab.GraphQL()


def test_graphql_as_context_manager_exits():
with gitlab.GraphQL() as gl:
assert isinstance(gl, gitlab.GraphQL)

0 comments on commit d6b1b0a

Please sign in to comment.