diff --git a/docs/api-usage-graphql.rst b/docs/api-usage-graphql.rst new file mode 100644 index 000000000..c8550c640 --- /dev/null +++ b/docs/api-usage-graphql.rst @@ -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) diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 59b53ccee..a0f44f696 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -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 ======================= diff --git a/docs/cli-usage.rst b/docs/cli-usage.rst index 4b525fc5d..0be22f5e2 100644 --- a/docs/cli-usage.rst +++ b/docs/cli-usage.rst @@ -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. diff --git a/docs/index.rst b/docs/index.rst index ca0c83fb6..1d0a0ed53 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ cli-usage api-usage api-usage-advanced + api-usage-graphql cli-examples api-objects api/gitlab diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 74ffa8521..6c6810aef 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -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") @@ -42,5 +42,6 @@ "__version__", "Gitlab", "GitlabList", + "GraphQL", ] __all__.extend(gitlab.exceptions.__all__) diff --git a/gitlab/_backends/graphql.py b/gitlab/_backends/graphql.py new file mode 100644 index 000000000..89187c9e3 --- /dev/null +++ b/gitlab/_backends/graphql.py @@ -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 diff --git a/gitlab/client.py b/gitlab/client.py index 3b069b031..4bd6c97d0 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -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: " @@ -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 @@ -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. @@ -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) diff --git a/gitlab/utils.py b/gitlab/utils.py index 160ab7ab9..67aa98c6a 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -9,7 +9,7 @@ import requests -from gitlab import types +from gitlab import const, types class _StdoutStream: @@ -17,6 +17,18 @@ 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: diff --git a/pyproject.toml b/pyproject.toml index ff43ca62d..9efbdb0f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt index fbeb48ebc..a72f40953 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ +gql==3.5.0 +httpx==0.27.0 requests==2.32.3 requests-toolbelt==1.0.0 diff --git a/tests/functional/api/test_graphql.py b/tests/functional/api/test_graphql.py new file mode 100644 index 000000000..06c20010f --- /dev/null +++ b/tests/functional/api/test_graphql.py @@ -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) + assert response["currentUser"]["active"] is True diff --git a/tests/functional/fixtures/set_token.rb b/tests/functional/fixtures/set_token.rb index 26d4c0a2a..eec4e03ec 100644 --- a/tests/functional/fixtures/set_token.rb +++ b/tests/functional/fixtures/set_token.rb @@ -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 diff --git a/tests/install/test_install.py b/tests/install/test_install.py index 1538180d3..e262bb444 100644 --- a/tests/install/test_install.py +++ b/tests/install/test_install.py @@ -3,4 +3,4 @@ def test_install() -> None: with pytest.raises(ImportError): - import httpx # type: ignore # noqa + import aiohttp # type: ignore # noqa diff --git a/tests/unit/test_graphql.py b/tests/unit/test_graphql.py new file mode 100644 index 000000000..9bfc1e0f5 --- /dev/null +++ b/tests/unit/test_graphql.py @@ -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)