diff options
author | Nejc Habjan <nejc.habjan@siemens.com> | 2022-05-04 00:16:41 +0200 |
---|---|---|
committer | Nejc Habjan <nejc.habjan@siemens.com> | 2022-12-11 13:33:02 +0100 |
commit | 097997a6fd4a5e09474b3bffd48d879491018361 (patch) | |
tree | e6015c0323b4f597f7241d5d3c4493a934cc2537 | |
parent | c7cf0d1f172c214a11b30622fbccef57d9c86e93 (diff) | |
download | gitlab-feat/graphql.tar.gz |
feat: add basic GraphQL support (wip)feat/graphql
-rw-r--r-- | docs/api-usage.rst | 8 | ||||
-rw-r--r-- | docs/cli-usage.rst | 6 | ||||
-rw-r--r-- | docs/graphql-api-usage.rst | 55 | ||||
-rw-r--r-- | docs/index.rst | 1 | ||||
-rw-r--r-- | gitlab/__init__.py | 2 | ||||
-rw-r--r-- | gitlab/client.py | 301 | ||||
-rw-r--r-- | gitlab/graphql/__init__.py | 0 | ||||
-rw-r--r-- | gitlab/graphql/transport.py | 25 | ||||
-rw-r--r-- | requirements.txt | 1 | ||||
-rw-r--r-- | setup.py | 1 | ||||
-rw-r--r-- | tests/functional/graphql/conftest.py | 9 | ||||
-rw-r--r-- | tests/functional/graphql/test_graphql.py | 14 | ||||
-rw-r--r-- | tox.ini | 5 |
13 files changed, 328 insertions, 100 deletions
diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 06b9058..6822fe0 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 c728221..0b8abb5 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/graphql-api-usage.rst b/docs/graphql-api-usage.rst new file mode 100644 index 0000000..caa1cfb --- /dev/null +++ b/docs/graphql-api-usage.rst @@ -0,0 +1,55 @@ +##################### +Using the GraphQL API +##################### + +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 + + It is currently unstable and its implementation may change. You can expect a more + mature client in one of the upcoming major versions. + +The ``gitlab.GraphQLGitlab`` class +================================== + +As with the REST client, you connect to a GitLab instance by creating a ``gitlab.GraphQLGitlab`` object: + +.. code-block:: python + + import gitlab + + # anonymous read-only access for public resources (GitLab.com) + gl = gitlab.GraphQLGitlab() + + # anonymous read-only access for public resources (self-hosted GitLab instance) + gl = gitlab.GraphQLGitlab('https://gitlab.example.com') + + # private token or personal token authentication (GitLab.com) + gl = gitlab.GraphQLGitlab(private_token='JVNSESs8EwWRx5yDxM5q') + + # private token or personal token authentication (self-hosted GitLab instance) + gl = gitlab.GraphQLGitlab(url='https://gitlab.example.com', private_token='JVNSESs8EwWRx5yDxM5q') + + # oauth token authentication + gl = gitlab.GraphQLGitlab('https://gitlab.example.com', oauth_token='my_long_token_here') + +Sending queries +=============== + +Get the result of a simple query: + +.. code-block:: python + + query = """{ + query { + currentUser { + name + } + } + """ + + result = gl.execute(query) diff --git a/docs/index.rst b/docs/index.rst index ca0c83f..d2f1a31 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ cli-usage api-usage api-usage-advanced + graphql-api-usage cli-examples api-objects api/gitlab diff --git a/gitlab/__init__.py b/gitlab/__init__.py index e7aafda..a7b7ef7 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -29,7 +29,7 @@ from gitlab._version import ( # noqa: F401 __title__, __version__, ) -from gitlab.client import Gitlab, GitlabList # noqa: F401 +from gitlab.client import Gitlab, GitlabList, GraphQLGitlab # noqa: F401 from gitlab.exceptions import * # noqa: F401,F403 warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab") diff --git a/gitlab/client.py b/gitlab/client.py index 98f6905..9128530 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -1,7 +1,9 @@ """Wrapper for the GitLab API.""" +import abc import os import re +import sys import time from typing import Any, cast, Dict, List, Optional, Tuple, Type, TYPE_CHECKING, Union from urllib import parse @@ -16,6 +18,10 @@ import gitlab.const import gitlab.exceptions from gitlab import http_backends, utils +if TYPE_CHECKING or ("sphinx" in sys.modules): + import gql + from graphql import DocumentNode + 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: " @@ -30,8 +36,7 @@ _PAGINATION_URL = ( ) -class Gitlab: - +class _BaseGitlab: """Represents a GitLab server connection. Args: @@ -53,10 +58,6 @@ class Gitlab: or 52x responses. Defaults to False. keep_base_url: keep user-provided base URL for pagination if it differs from response headers - - Keyward Args: - requests.Session session: Http Requests Session - RequestsBackend http_backend: Backend that will be used to make http requests """ def __init__( @@ -69,20 +70,14 @@ class Gitlab: http_username: Optional[str] = None, http_password: Optional[str] = None, timeout: Optional[float] = None, - api_version: str = "4", - per_page: Optional[int] = None, - pagination: Optional[str] = None, - order_by: Optional[str] = None, user_agent: str = gitlab.const.USER_AGENT, retry_transient_errors: bool = False, keep_base_url: bool = False, **kwargs: Any, ) -> None: - 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._url = f"{self._base_url}/api/v{api_version}" #: Timeout to use for requests to gitlab server self.timeout = timeout self.retry_transient_errors = retry_transient_errors @@ -98,7 +93,6 @@ class Gitlab: self.http_password = http_password self.oauth_token = oauth_token self.job_token = job_token - self._set_auth_info() #: Create a session object for requests http_backend: Type[http_backends.DefaultBackend] = kwargs.pop( @@ -107,6 +101,151 @@ class Gitlab: self.http_backend = http_backend(**kwargs) self.session = self.http_backend.client + @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 _get_session_opts(self) -> Dict[str, Any]: + return { + "headers": self.headers.copy(), + "auth": self._http_auth, + "timeout": self.timeout, + "verify": self.ssl_verify, + } + + @property + def url(self) -> str: + """The user-provided server URL.""" + return self._base_url + + def _set_auth_info(self) -> None: + tokens = [ + token + for token in [self.private_token, self.oauth_token, self.job_token] + if token + ] + if len(tokens) > 1: + raise ValueError( + "Only one of private_token, oauth_token or job_token should " + "be defined" + ) + if (self.http_username and not self.http_password) or ( + not self.http_username and self.http_password + ): + raise ValueError( + "Both http_username and http_password should " "be defined" + ) + if self.oauth_token and self.http_username: + raise ValueError( + "Only one of oauth authentication or http " + "authentication should be defined" + ) + + self._http_auth = None + if self.private_token: + self.headers.pop("Authorization", None) + self.headers["PRIVATE-TOKEN"] = self.private_token + self.headers.pop("JOB-TOKEN", None) + + if self.oauth_token: + self.headers["Authorization"] = f"Bearer {self.oauth_token}" + self.headers.pop("PRIVATE-TOKEN", None) + self.headers.pop("JOB-TOKEN", None) + + if self.job_token: + self.headers.pop("Authorization", None) + self.headers.pop("PRIVATE-TOKEN", None) + self.headers["JOB-TOKEN"] = self.job_token + + if self.http_username: + self._http_auth = requests.auth.HTTPBasicAuth( + self.http_username, self.http_password + ) + + def enable_debug(self) -> None: + import logging + from http.client import HTTPConnection # noqa + + HTTPConnection.debuglevel = 1 + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + requests_log = logging.getLogger("requests.packages.urllib3") + requests_log.setLevel(logging.DEBUG) + requests_log.propagate = True + + +class Gitlab(_BaseGitlab): + """Represents a GitLab server connection. + + Args: + url: The URL of the GitLab server (defaults to https://gitlab.com). + private_token: The user private token + oauth_token: An oauth token + job_token: A CI job token + ssl_verify: Whether SSL certificates should be validated. If + the value is a string, it is the path to a CA file used for + certificate validation. + timeout: Timeout to use for requests to the GitLab server. + http_username: Username for HTTP authentication + http_password: Password for HTTP authentication + api_version: Gitlab API version to use (support for 4 only) + pagination: Can be set to 'keyset' to use keyset pagination + order_by: Set order_by globally + user_agent: A custom user agent to use for making HTTP requests. + retry_transient_errors: Whether to retry after 500, 502, 503, 504 + or 52x responses. Defaults to False. + keep_base_url: keep user-provided base URL for pagination if it + differs from response headers + + Keyward Args: + requests.Session session: Http Requests Session + RequestsBackend http_backend: Backend that will be used to make http requests + """ + + def __init__( + self, + url: Optional[str] = None, + private_token: Optional[str] = None, + oauth_token: Optional[str] = None, + job_token: Optional[str] = None, + ssl_verify: Union[bool, str] = True, + http_username: Optional[str] = None, + http_password: Optional[str] = None, + timeout: Optional[float] = None, + api_version: str = "4", + per_page: Optional[int] = None, + pagination: Optional[str] = None, + order_by: Optional[str] = None, + user_agent: str = gitlab.const.USER_AGENT, + retry_transient_errors: bool = False, + keep_base_url: bool = False, + **kwargs: Any, + ) -> None: + super().__init__( + url, + private_token, + oauth_token, + job_token, + ssl_verify, + http_username, + http_password, + timeout, + user_agent, + retry_transient_errors, + keep_base_url, + **kwargs, + ) + self._api_version = str(api_version) + self._url = f"{self._base_url}/api/v{api_version}" + self._set_auth_info() self.per_page = per_page self.pagination = pagination self.order_by = order_by @@ -223,11 +362,6 @@ class Gitlab: self._objects = objects @property - def url(self) -> str: - """The user-provided server URL.""" - return self._base_url - - @property def api_url(self) -> str: """The computed API base URL.""" return self._url @@ -494,80 +628,6 @@ class Gitlab: assert not isinstance(result, requests.Response) return result - def _set_auth_info(self) -> None: - tokens = [ - token - for token in [self.private_token, self.oauth_token, self.job_token] - if token - ] - if len(tokens) > 1: - raise ValueError( - "Only one of private_token, oauth_token or job_token should " - "be defined" - ) - if (self.http_username and not self.http_password) or ( - not self.http_username and self.http_password - ): - raise ValueError("Both http_username and http_password should be defined") - if self.oauth_token and self.http_username: - raise ValueError( - "Only one of oauth authentication or http " - "authentication should be defined" - ) - - self._http_auth = None - if self.private_token: - self.headers.pop("Authorization", None) - self.headers["PRIVATE-TOKEN"] = self.private_token - self.headers.pop("JOB-TOKEN", None) - - if self.oauth_token: - self.headers["Authorization"] = f"Bearer {self.oauth_token}" - self.headers.pop("PRIVATE-TOKEN", None) - self.headers.pop("JOB-TOKEN", None) - - if self.job_token: - self.headers.pop("Authorization", None) - self.headers.pop("PRIVATE-TOKEN", None) - self.headers["JOB-TOKEN"] = self.job_token - - if self.http_username: - self._http_auth = requests.auth.HTTPBasicAuth( - self.http_username, self.http_password - ) - - @staticmethod - def enable_debug() -> None: - import logging - from http.client import HTTPConnection # noqa - - HTTPConnection.debuglevel = 1 - logging.basicConfig() - logging.getLogger().setLevel(logging.DEBUG) - requests_log = logging.getLogger("requests.packages.urllib3") - requests_log.setLevel(logging.DEBUG) - requests_log.propagate = True - - def _get_session_opts(self) -> Dict[str, Any]: - return { - "headers": self.headers.copy(), - "auth": self._http_auth, - "timeout": self.timeout, - "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. @@ -1259,3 +1319,60 @@ class GitlabList: return self.next() raise StopIteration + + +class GraphQLGitlab(_BaseGitlab): + def __init__( + self, + url: Optional[str] = None, + private_token: Optional[str] = None, + oauth_token: Optional[str] = None, + job_token: Optional[str] = None, + ssl_verify: Union[bool, str] = True, + http_username: Optional[str] = None, + http_password: Optional[str] = None, + session: Optional[requests.Session] = None, + timeout: Optional[float] = None, + user_agent: str = gitlab.const.USER_AGENT, + retry_transient_errors: bool = False, + fetch_schema_from_transport: bool = False, + ) -> None: + super().__init__( + url, + private_token, + oauth_token, + job_token, + ssl_verify, + http_username, + http_password, + session, + timeout, + user_agent, + retry_transient_errors, + ) + self._url = f"{self._base_url}/api/graphql" + self.fetch_schema_from_transport = fetch_schema_from_transport + + try: + import gql + + from .graphql.transport import GitlabSyncTransport + except ImportError: + raise ImportError( + "The GraphQLGitlab client could not be initialized because " + "the gql dependency is not installed. " + "Install it with 'pip install python-gitlab[graphql]'" + ) + + opts = self._get_session_opts() + + self._gql = gql + self._transport = GitlabSyncTransport(self._url, session=self.session, **opts) + self._client = self._gql.Client( + transport=self._transport, + fetch_schema_from_transport=fetch_schema_from_transport, + ) + + def execute(self, request: str, *args, **kwargs) -> Any: + parsed_document = self._gql.gql(request) + return self._client.execute(parsed_document, *args, **kwargs) diff --git a/gitlab/graphql/__init__.py b/gitlab/graphql/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gitlab/graphql/__init__.py diff --git a/gitlab/graphql/transport.py b/gitlab/graphql/transport.py new file mode 100644 index 0000000..26c3700 --- /dev/null +++ b/gitlab/graphql/transport.py @@ -0,0 +1,25 @@ +from typing import Any + +import requests +from gql.transport.requests import RequestsHTTPTransport + + +class GitlabSyncTransport(RequestsHTTPTransport): + """A gql requests transport that reuses an existing requests.Session. + + By default, gql's requests transport does not have a keep-alive session + and does not enable providing your own session. + + This transport lets us provide and close our session on our own. + For details, see https://github.com/graphql-python/gql/issues/91. + """ + + def __init__(self, *args: Any, session: requests.Session, **kwargs: Any): + super().__init__(*args, **kwargs) + self.session = session + + def connect(self) -> None: + pass + + def close(self) -> None: + pass diff --git a/requirements.txt b/requirements.txt index bb79bc4..cc1cd30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +gql==3.4.0 requests==2.28.1 requests-toolbelt==0.10.1 @@ -52,6 +52,7 @@ setup( ], extras_require={ "autocompletion": ["argcomplete>=1.10.0,<3"], + "graphql": ["gql[requests]>=3.2.0,<4"], "yaml": ["PyYaml>=5.2"], }, ) diff --git a/tests/functional/graphql/conftest.py b/tests/functional/graphql/conftest.py new file mode 100644 index 0000000..7193ea4 --- /dev/null +++ b/tests/functional/graphql/conftest.py @@ -0,0 +1,9 @@ +import pytest + +import gitlab + + +@pytest.fixture(scope="session") +def graphql_gl(gitlab_service): + url, private_token = gitlab_service + return gitlab.GraphQLGitlab(url, oauth_token=private_token) diff --git a/tests/functional/graphql/test_graphql.py b/tests/functional/graphql/test_graphql.py new file mode 100644 index 0000000..6bba059 --- /dev/null +++ b/tests/functional/graphql/test_graphql.py @@ -0,0 +1,14 @@ +from gitlab import GraphQLGitlab + + +def test_graphql_query_current_user(graphql_gl: GraphQLGitlab): + query = """ +query { + currentUser { + username + } +} +""" + graphql_gl.enable_debug() + result = graphql_gl.execute(query) + assert result["user"]["username"] == "root" @@ -120,6 +120,11 @@ deps = -r{toxinidir}/requirements-docker.txt commands = pytest --cov --cov-report xml tests/functional/api {posargs} +[testenv:api_func_graphql] +deps = -r{toxinidir}/requirements-docker.txt +commands = + pytest --cov --cov-report xml tests/functional/graphql {posargs} + [testenv:smoke] deps = -r{toxinidir}/requirements-test.txt commands = pytest tests/smoke {posargs} |