summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/api-usage.rst8
-rw-r--r--docs/cli-usage.rst6
-rw-r--r--docs/graphql-api-usage.rst55
-rw-r--r--docs/index.rst1
-rw-r--r--gitlab/__init__.py2
-rw-r--r--gitlab/client.py301
-rw-r--r--gitlab/graphql/__init__.py0
-rw-r--r--gitlab/graphql/transport.py25
-rw-r--r--requirements.txt1
-rw-r--r--setup.py1
-rw-r--r--tests/functional/graphql/conftest.py9
-rw-r--r--tests/functional/graphql/test_graphql.py14
-rw-r--r--tox.ini5
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
diff --git a/setup.py b/setup.py
index f02b05f..f29d9e1 100644
--- a/setup.py
+++ b/setup.py
@@ -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"
diff --git a/tox.ini b/tox.ini
index a76beab..bcc9aad 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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}