diff options
author | Nejc Habjan <hab.nejc@gmail.com> | 2021-02-20 13:04:15 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-02-20 13:04:15 +0100 |
commit | 2c4fcf83296cc65c08b76b2d9312004ecf670fb6 (patch) | |
tree | 47800a838daba22be77b429013c635902b459ab7 | |
parent | d4e7a031eb64ecba09f2547bd7803f2cceb7558b (diff) | |
parent | 53a764530cc3c6411034a3798f794545881d341e (diff) | |
download | gitlab-2c4fcf83296cc65c08b76b2d9312004ecf670fb6.tar.gz |
Merge pull request #1301 from JohnVillalovos/refactor_jlvillal
refactor: move Gitlab and GitlabList to gitlab/client.py
-rw-r--r-- | gitlab/__init__.py | 835 | ||||
-rw-r--r-- | gitlab/client.py | 858 | ||||
-rw-r--r-- | gitlab/tests/test_gitlab_http_methods.py | 1 |
3 files changed, 860 insertions, 834 deletions
diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 71a473a..2802615 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -16,13 +16,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. """Wrapper for the GitLab API.""" -import importlib -import time import warnings -import requests -import requests.utils - import gitlab.config from gitlab.__version__ import ( __author__, @@ -32,838 +27,10 @@ from gitlab.__version__ import ( __title__, __version__, ) +from gitlab.client import Gitlab, GitlabList from gitlab.const import * # noqa from gitlab.exceptions import * # noqa from gitlab import utils # noqa -from requests_toolbelt.multipart.encoder import MultipartEncoder warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab") - -REDIRECT_MSG = ( - "python-gitlab detected an http to https redirection. You " - "must update your GitLab URL to use https:// to avoid issues." -) - - -class Gitlab(object): - """Represents a GitLab server connection. - - Args: - url (str): The URL of the GitLab server. - private_token (str): The user private token - oauth_token (str): An oauth token - job_token (str): A CI job token - ssl_verify (bool|str): 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 (float): Timeout to use for requests to the GitLab server. - http_username (str): Username for HTTP authentication - http_password (str): Password for HTTP authentication - api_version (str): Gitlab API version to use (support for 4 only) - pagination (str): Can be set to 'keyset' to use keyset pagination - order_by (str): Set order_by globally - user_agent (str): A custom user agent to use for making HTTP requests. - """ - - def __init__( - self, - url, - private_token=None, - oauth_token=None, - job_token=None, - ssl_verify=True, - http_username=None, - http_password=None, - timeout=None, - api_version="4", - session=None, - per_page=None, - pagination=None, - order_by=None, - user_agent=USER_AGENT, - ): - - self._api_version = str(api_version) - self._server_version = self._server_revision = None - self._base_url = url.rstrip("/") - self._url = "%s/api/v%s" % (self._base_url, api_version) - #: Timeout to use for requests to gitlab server - self.timeout = timeout - #: Headers that will be used in request to GitLab - self.headers = {"User-Agent": user_agent} - - #: Whether SSL certificates should be validated - self.ssl_verify = ssl_verify - - self.private_token = private_token - self.http_username = http_username - 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 - self.session = session or requests.Session() - - self.per_page = per_page - self.pagination = pagination - self.order_by = order_by - - objects = importlib.import_module("gitlab.v%s.objects" % self._api_version) - self._objects = objects - - self.broadcastmessages = objects.BroadcastMessageManager(self) - self.deploykeys = objects.DeployKeyManager(self) - self.deploytokens = objects.DeployTokenManager(self) - self.geonodes = objects.GeoNodeManager(self) - self.gitlabciymls = objects.GitlabciymlManager(self) - self.gitignores = objects.GitignoreManager(self) - self.groups = objects.GroupManager(self) - self.hooks = objects.HookManager(self) - self.issues = objects.IssueManager(self) - self.ldapgroups = objects.LDAPGroupManager(self) - self.licenses = objects.LicenseManager(self) - self.namespaces = objects.NamespaceManager(self) - self.mergerequests = objects.MergeRequestManager(self) - self.notificationsettings = objects.NotificationSettingsManager(self) - self.projects = objects.ProjectManager(self) - self.runners = objects.RunnerManager(self) - self.settings = objects.ApplicationSettingsManager(self) - self.appearance = objects.ApplicationAppearanceManager(self) - self.sidekiq = objects.SidekiqManager(self) - self.snippets = objects.SnippetManager(self) - self.users = objects.UserManager(self) - self.todos = objects.TodoManager(self) - self.dockerfiles = objects.DockerfileManager(self) - self.events = objects.EventManager(self) - self.audit_events = objects.AuditEventManager(self) - self.features = objects.FeatureManager(self) - self.pagesdomains = objects.PagesDomainManager(self) - self.user_activities = objects.UserActivitiesManager(self) - self.applications = objects.ApplicationManager(self) - self.variables = objects.VariableManager(self) - self.personal_access_tokens = objects.PersonalAccessTokenManager(self) - - def __enter__(self): - return self - - def __exit__(self, *args): - self.session.close() - - def __getstate__(self): - state = self.__dict__.copy() - state.pop("_objects") - return state - - def __setstate__(self, state): - self.__dict__.update(state) - objects = importlib.import_module("gitlab.v%s.objects" % self._api_version) - self._objects = objects - - @property - def url(self): - """The user-provided server URL.""" - return self._base_url - - @property - def api_url(self): - """The computed API base URL.""" - return self._url - - @property - def api_version(self): - """The API version used (4 only).""" - return self._api_version - - @classmethod - def from_config(cls, gitlab_id=None, config_files=None): - """Create a Gitlab connection from configuration files. - - Args: - gitlab_id (str): ID of the configuration section. - config_files list[str]: List of paths to configuration files. - - Returns: - (gitlab.Gitlab): A Gitlab connection. - - Raises: - gitlab.config.GitlabDataError: If the configuration is not correct. - """ - config = gitlab.config.GitlabConfigParser( - gitlab_id=gitlab_id, config_files=config_files - ) - return cls( - config.url, - private_token=config.private_token, - oauth_token=config.oauth_token, - job_token=config.job_token, - ssl_verify=config.ssl_verify, - timeout=config.timeout, - http_username=config.http_username, - http_password=config.http_password, - api_version=config.api_version, - per_page=config.per_page, - pagination=config.pagination, - order_by=config.order_by, - user_agent=config.user_agent, - ) - - def auth(self): - """Performs an authentication using private token. - - The `user` attribute will hold a `gitlab.objects.CurrentUser` object on - success. - """ - self.user = self._objects.CurrentUserManager(self).get() - - def version(self): - """Returns the version and revision of the gitlab server. - - Note that self.version and self.revision will be set on the gitlab - object. - - Returns: - tuple (str, str): The server version and server revision. - ('unknown', 'unknwown') if the server doesn't - perform as expected. - """ - if self._server_version is None: - try: - data = self.http_get("/version") - self._server_version = data["version"] - self._server_revision = data["revision"] - except Exception: - self._server_version = self._server_revision = "unknown" - - return self._server_version, self._server_revision - - @on_http_error(GitlabVerifyError) - def lint(self, content, **kwargs): - """Validate a gitlab CI configuration. - - Args: - content (txt): The .gitlab-ci.yml content - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabVerifyError: If the validation could not be done - - Returns: - tuple: (True, []) if the file is valid, (False, errors(list)) - otherwise - """ - post_data = {"content": content} - data = self.http_post("/ci/lint", post_data=post_data, **kwargs) - return (data["status"] == "valid", data["errors"]) - - @on_http_error(GitlabMarkdownError) - def markdown(self, text, gfm=False, project=None, **kwargs): - """Render an arbitrary Markdown document. - - Args: - text (str): The markdown text to render - gfm (bool): Render text using GitLab Flavored Markdown. Default is - False - project (str): Full path of a project used a context when `gfm` is - True - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabMarkdownError: If the server cannot perform the request - - Returns: - str: The HTML rendering of the markdown text. - """ - post_data = {"text": text, "gfm": gfm} - if project is not None: - post_data["project"] = project - data = self.http_post("/markdown", post_data=post_data, **kwargs) - return data["html"] - - @on_http_error(GitlabLicenseError) - def get_license(self, **kwargs): - """Retrieve information about the current license. - - Args: - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabGetError: If the server cannot perform the request - - Returns: - dict: The current license information - """ - return self.http_get("/license", **kwargs) - - @on_http_error(GitlabLicenseError) - def set_license(self, license, **kwargs): - """Add a new license. - - Args: - license (str): The license string - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabPostError: If the server cannot perform the request - - Returns: - dict: The new license information - """ - data = {"license": license} - return self.http_post("/license", post_data=data, **kwargs) - - def _construct_url(self, id_, obj, parameters, action=None): - if "next_url" in parameters: - return parameters["next_url"] - args = utils.sanitize_parameters(parameters) - - url_attr = "_url" - if action is not None: - attr = "_%s_url" % action - if hasattr(obj, attr): - url_attr = attr - obj_url = getattr(obj, url_attr) - url = obj_url % args - - if id_ is not None: - return "%s/%s" % (url, str(id_)) - else: - return url - - def _set_auth_info(self): - 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"] = "Bearer %s" % 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): - 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 _create_headers(self, content_type=None): - request_headers = self.headers.copy() - if content_type is not None: - request_headers["Content-type"] = content_type - return request_headers - - def _get_session_opts(self, content_type): - return { - "headers": self._create_headers(content_type), - "auth": self._http_auth, - "timeout": self.timeout, - "verify": self.ssl_verify, - } - - def _build_url(self, path): - """Returns the full url from path. - - If path is already a url, return it unchanged. If it's a path, append - it to the stored url. - - Returns: - str: The full URL - """ - if path.startswith("http://") or path.startswith("https://"): - return path - else: - return "%s%s" % (self._url, path) - - def _check_redirects(self, result): - # Check the requests history to detect http to https redirections. - # If the initial verb is POST, the next request will use a GET request, - # leading to an unwanted behaviour. - # If the initial verb is PUT, the data will not be send with the next - # request. - # If we detect a redirection to https with a POST or a PUT request, we - # raise an exception with a useful error message. - if result.history and self._base_url.startswith("http:"): - for item in result.history: - if item.status_code not in (301, 302): - continue - # GET methods can be redirected without issue - if item.request.method == "GET": - continue - # Did we end-up with an https:// URL? - location = item.headers.get("Location", None) - if location and location.startswith("https://"): - raise RedirectError(REDIRECT_MSG) - - def http_request( - self, - verb, - path, - query_data=None, - post_data=None, - streamed=False, - files=None, - **kwargs - ): - """Make an HTTP request to the Gitlab server. - - Args: - verb (str): The HTTP method to call ('get', 'post', 'put', - 'delete') - path (str): Path or full URL to query ('/projects' or - 'http://whatever/v4/api/projecs') - query_data (dict): Data to send as query parameters - post_data (dict): Data to send in the body (will be converted to - json) - streamed (bool): Whether the data should be streamed - files (dict): The files to send to the server - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - A requests result object. - - Raises: - GitlabHttpError: When the return code is not 2xx - """ - query_data = query_data or {} - url = self._build_url(path) - - params = {} - utils.copy_dict(params, query_data) - - # Deal with kwargs: by default a user uses kwargs to send data to the - # gitlab server, but this generates problems (python keyword conflicts - # and python-gitlab/gitlab conflicts). - # So we provide a `query_parameters` key: if it's there we use its dict - # value as arguments for the gitlab server, and ignore the other - # arguments, except pagination ones (per_page and page) - if "query_parameters" in kwargs: - utils.copy_dict(params, kwargs["query_parameters"]) - for arg in ("per_page", "page"): - if arg in kwargs: - params[arg] = kwargs[arg] - else: - utils.copy_dict(params, kwargs) - - opts = self._get_session_opts(content_type="application/json") - - verify = opts.pop("verify") - timeout = opts.pop("timeout") - # If timeout was passed into kwargs, allow it to override the default - timeout = kwargs.get("timeout", timeout) - - # We need to deal with json vs. data when uploading files - if files: - json = None - post_data["file"] = files.get("file") - post_data["avatar"] = files.get("avatar") - data = MultipartEncoder(post_data) - opts["headers"]["Content-type"] = data.content_type - else: - json = post_data - data = None - - # Requests assumes that `.` should not be encoded as %2E and will make - # changes to urls using this encoding. Using a prepped request we can - # get the desired behavior. - # The Requests behavior is right but it seems that web servers don't - # always agree with this decision (this is the case with a default - # gitlab installation) - req = requests.Request(verb, url, json=json, data=data, params=params, **opts) - prepped = self.session.prepare_request(req) - prepped.url = utils.sanitized_url(prepped.url) - settings = self.session.merge_environment_settings( - prepped.url, {}, streamed, verify, None - ) - - # obey the rate limit by default - obey_rate_limit = kwargs.get("obey_rate_limit", True) - # do not retry transient errors by default - retry_transient_errors = kwargs.get("retry_transient_errors", False) - - # set max_retries to 10 by default, disable by setting it to -1 - max_retries = kwargs.get("max_retries", 10) - cur_retries = 0 - - while True: - result = self.session.send(prepped, timeout=timeout, **settings) - - self._check_redirects(result) - - if 200 <= result.status_code < 300: - return result - - if (429 == result.status_code and obey_rate_limit) or ( - result.status_code in [500, 502, 503, 504] and retry_transient_errors - ): - if max_retries == -1 or cur_retries < max_retries: - wait_time = 2 ** cur_retries * 0.1 - if "Retry-After" in result.headers: - wait_time = int(result.headers["Retry-After"]) - cur_retries += 1 - time.sleep(wait_time) - continue - - error_message = result.content - try: - error_json = result.json() - for k in ("message", "error"): - if k in error_json: - error_message = error_json[k] - except (KeyError, ValueError, TypeError): - pass - - if result.status_code == 401: - raise GitlabAuthenticationError( - response_code=result.status_code, - error_message=error_message, - response_body=result.content, - ) - - raise GitlabHttpError( - response_code=result.status_code, - error_message=error_message, - response_body=result.content, - ) - - def http_get(self, path, query_data=None, streamed=False, raw=False, **kwargs): - """Make a GET request to the Gitlab server. - - Args: - path (str): Path or full URL to query ('/projects' or - 'http://whatever/v4/api/projecs') - query_data (dict): Data to send as query parameters - streamed (bool): Whether the data should be streamed - raw (bool): If True do not try to parse the output as json - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - A requests result object is streamed is True or the content type is - not json. - The parsed json data otherwise. - - Raises: - GitlabHttpError: When the return code is not 2xx - GitlabParsingError: If the json data could not be parsed - """ - query_data = query_data or {} - result = self.http_request( - "get", path, query_data=query_data, streamed=streamed, **kwargs - ) - - if ( - result.headers["Content-Type"] == "application/json" - and not streamed - and not raw - ): - try: - return result.json() - except Exception as e: - raise GitlabParsingError( - error_message="Failed to parse the server message" - ) from e - else: - return result - - def http_list(self, path, query_data=None, as_list=None, **kwargs): - """Make a GET request to the Gitlab server for list-oriented queries. - - Args: - path (str): Path or full URL to query ('/projects' or - 'http://whatever/v4/api/projects') - query_data (dict): Data to send as query parameters - **kwargs: Extra options to send to the server (e.g. sudo, page, - per_page) - - Returns: - list: A list of the objects returned by the server. If `as_list` is - False and no pagination-related arguments (`page`, `per_page`, - `all`) are defined then a GitlabList object (generator) is returned - instead. This object will make API calls when needed to fetch the - next items from the server. - - Raises: - GitlabHttpError: When the return code is not 2xx - GitlabParsingError: If the json data could not be parsed - """ - query_data = query_data or {} - - # In case we want to change the default behavior at some point - as_list = True if as_list is None else as_list - - get_all = kwargs.pop("all", False) - url = self._build_url(path) - - page = kwargs.get("page") - - if get_all is True and as_list is True: - return list(GitlabList(self, url, query_data, **kwargs)) - - if page or as_list is True: - # pagination requested, we return a list - return list(GitlabList(self, url, query_data, get_next=False, **kwargs)) - - # No pagination, generator requested - return GitlabList(self, url, query_data, **kwargs) - - def http_post(self, path, query_data=None, post_data=None, files=None, **kwargs): - """Make a POST request to the Gitlab server. - - Args: - path (str): Path or full URL to query ('/projects' or - 'http://whatever/v4/api/projecs') - query_data (dict): Data to send as query parameters - post_data (dict): Data to send in the body (will be converted to - json) - files (dict): The files to send to the server - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - The parsed json returned by the server if json is return, else the - raw content - - Raises: - GitlabHttpError: When the return code is not 2xx - GitlabParsingError: If the json data could not be parsed - """ - query_data = query_data or {} - post_data = post_data or {} - - result = self.http_request( - "post", - path, - query_data=query_data, - post_data=post_data, - files=files, - **kwargs - ) - try: - if result.headers.get("Content-Type", None) == "application/json": - return result.json() - except Exception as e: - raise GitlabParsingError( - error_message="Failed to parse the server message" - ) from e - return result - - def http_put(self, path, query_data=None, post_data=None, files=None, **kwargs): - """Make a PUT request to the Gitlab server. - - Args: - path (str): Path or full URL to query ('/projects' or - 'http://whatever/v4/api/projecs') - query_data (dict): Data to send as query parameters - post_data (dict): Data to send in the body (will be converted to - json) - files (dict): The files to send to the server - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - The parsed json returned by the server. - - Raises: - GitlabHttpError: When the return code is not 2xx - GitlabParsingError: If the json data could not be parsed - """ - query_data = query_data or {} - post_data = post_data or {} - - result = self.http_request( - "put", - path, - query_data=query_data, - post_data=post_data, - files=files, - **kwargs - ) - try: - return result.json() - except Exception as e: - raise GitlabParsingError( - error_message="Failed to parse the server message" - ) from e - - def http_delete(self, path, **kwargs): - """Make a PUT request to the Gitlab server. - - Args: - path (str): Path or full URL to query ('/projects' or - 'http://whatever/v4/api/projecs') - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - The requests object. - - Raises: - GitlabHttpError: When the return code is not 2xx - """ - return self.http_request("delete", path, **kwargs) - - @on_http_error(GitlabSearchError) - def search(self, scope, search, **kwargs): - """Search GitLab resources matching the provided string.' - - Args: - scope (str): Scope of the search - search (str): Search string - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabSearchError: If the server failed to perform the request - - Returns: - GitlabList: A list of dicts describing the resources found. - """ - data = {"scope": scope, "search": search} - return self.http_list("/search", query_data=data, **kwargs) - - -class GitlabList(object): - """Generator representing a list of remote objects. - - The object handles the links returned by a query to the API, and will call - the API again when needed. - """ - - def __init__(self, gl, url, query_data, get_next=True, **kwargs): - self._gl = gl - - # Preserve kwargs for subsequent queries - self._kwargs = kwargs.copy() - - self._query(url, query_data, **self._kwargs) - self._get_next = get_next - - def _query(self, url, query_data=None, **kwargs): - query_data = query_data or {} - result = self._gl.http_request("get", url, query_data=query_data, **kwargs) - try: - links = result.links - if links: - next_url = links["next"]["url"] - else: - next_url = requests.utils.parse_header_links(result.headers["links"])[ - 0 - ]["url"] - self._next_url = next_url - except KeyError: - self._next_url = None - self._current_page = result.headers.get("X-Page") - self._prev_page = result.headers.get("X-Prev-Page") - self._next_page = result.headers.get("X-Next-Page") - self._per_page = result.headers.get("X-Per-Page") - self._total_pages = result.headers.get("X-Total-Pages") - self._total = result.headers.get("X-Total") - - try: - self._data = result.json() - except Exception as e: - raise GitlabParsingError( - error_message="Failed to parse the server message" - ) from e - - self._current = 0 - - @property - def current_page(self): - """The current page number.""" - return int(self._current_page) - - @property - def prev_page(self): - """The previous page number. - - If None, the current page is the first. - """ - return int(self._prev_page) if self._prev_page else None - - @property - def next_page(self): - """The next page number. - - If None, the current page is the last. - """ - return int(self._next_page) if self._next_page else None - - @property - def per_page(self): - """The number of items per page.""" - return int(self._per_page) - - @property - def total_pages(self): - """The total number of pages.""" - return int(self._total_pages) - - @property - def total(self): - """The total number of items.""" - return int(self._total) - - def __iter__(self): - return self - - def __len__(self): - return int(self._total) - - def __next__(self): - return self.next() - - def next(self): - try: - item = self._data[self._current] - self._current += 1 - return item - except IndexError: - pass - - if self._next_url and self._get_next is True: - self._query(self._next_url, **self._kwargs) - return self.next() - - raise StopIteration diff --git a/gitlab/client.py b/gitlab/client.py new file mode 100644 index 0000000..dbfc834 --- /dev/null +++ b/gitlab/client.py @@ -0,0 +1,858 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2017 Gauvain Pocentek <gauvain@pocentek.net> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +"""Wrapper for the GitLab API.""" + +import importlib +import time + +import requests +import requests.utils + +import gitlab.config +from gitlab.const import * # noqa +from gitlab.exceptions import * # noqa +from gitlab import utils # noqa +from requests_toolbelt.multipart.encoder import MultipartEncoder + + +REDIRECT_MSG = ( + "python-gitlab detected an http to https redirection. You " + "must update your GitLab URL to use https:// to avoid issues." +) + + +class Gitlab(object): + """Represents a GitLab server connection. + + Args: + url (str): The URL of the GitLab server. + private_token (str): The user private token + oauth_token (str): An oauth token + job_token (str): A CI job token + ssl_verify (bool|str): 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 (float): Timeout to use for requests to the GitLab server. + http_username (str): Username for HTTP authentication + http_password (str): Password for HTTP authentication + api_version (str): Gitlab API version to use (support for 4 only) + pagination (str): Can be set to 'keyset' to use keyset pagination + order_by (str): Set order_by globally + user_agent (str): A custom user agent to use for making HTTP requests. + """ + + def __init__( + self, + url, + private_token=None, + oauth_token=None, + job_token=None, + ssl_verify=True, + http_username=None, + http_password=None, + timeout=None, + api_version="4", + session=None, + per_page=None, + pagination=None, + order_by=None, + user_agent=USER_AGENT, + ): + + self._api_version = str(api_version) + self._server_version = self._server_revision = None + self._base_url = url.rstrip("/") + self._url = "%s/api/v%s" % (self._base_url, api_version) + #: Timeout to use for requests to gitlab server + self.timeout = timeout + #: Headers that will be used in request to GitLab + self.headers = {"User-Agent": user_agent} + + #: Whether SSL certificates should be validated + self.ssl_verify = ssl_verify + + self.private_token = private_token + self.http_username = http_username + 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 + self.session = session or requests.Session() + + self.per_page = per_page + self.pagination = pagination + self.order_by = order_by + + objects = importlib.import_module("gitlab.v%s.objects" % self._api_version) + self._objects = objects + + self.broadcastmessages = objects.BroadcastMessageManager(self) + self.deploykeys = objects.DeployKeyManager(self) + self.deploytokens = objects.DeployTokenManager(self) + self.geonodes = objects.GeoNodeManager(self) + self.gitlabciymls = objects.GitlabciymlManager(self) + self.gitignores = objects.GitignoreManager(self) + self.groups = objects.GroupManager(self) + self.hooks = objects.HookManager(self) + self.issues = objects.IssueManager(self) + self.ldapgroups = objects.LDAPGroupManager(self) + self.licenses = objects.LicenseManager(self) + self.namespaces = objects.NamespaceManager(self) + self.mergerequests = objects.MergeRequestManager(self) + self.notificationsettings = objects.NotificationSettingsManager(self) + self.projects = objects.ProjectManager(self) + self.runners = objects.RunnerManager(self) + self.settings = objects.ApplicationSettingsManager(self) + self.appearance = objects.ApplicationAppearanceManager(self) + self.sidekiq = objects.SidekiqManager(self) + self.snippets = objects.SnippetManager(self) + self.users = objects.UserManager(self) + self.todos = objects.TodoManager(self) + self.dockerfiles = objects.DockerfileManager(self) + self.events = objects.EventManager(self) + self.audit_events = objects.AuditEventManager(self) + self.features = objects.FeatureManager(self) + self.pagesdomains = objects.PagesDomainManager(self) + self.user_activities = objects.UserActivitiesManager(self) + self.applications = objects.ApplicationManager(self) + self.variables = objects.VariableManager(self) + self.personal_access_tokens = objects.PersonalAccessTokenManager(self) + + def __enter__(self): + return self + + def __exit__(self, *args): + self.session.close() + + def __getstate__(self): + state = self.__dict__.copy() + state.pop("_objects") + return state + + def __setstate__(self, state): + self.__dict__.update(state) + objects = importlib.import_module("gitlab.v%s.objects" % self._api_version) + self._objects = objects + + @property + def url(self): + """The user-provided server URL.""" + return self._base_url + + @property + def api_url(self): + """The computed API base URL.""" + return self._url + + @property + def api_version(self): + """The API version used (4 only).""" + return self._api_version + + @classmethod + def from_config(cls, gitlab_id=None, config_files=None): + """Create a Gitlab connection from configuration files. + + Args: + gitlab_id (str): ID of the configuration section. + config_files list[str]: List of paths to configuration files. + + Returns: + (gitlab.Gitlab): A Gitlab connection. + + Raises: + gitlab.config.GitlabDataError: If the configuration is not correct. + """ + config = gitlab.config.GitlabConfigParser( + gitlab_id=gitlab_id, config_files=config_files + ) + return cls( + config.url, + private_token=config.private_token, + oauth_token=config.oauth_token, + job_token=config.job_token, + ssl_verify=config.ssl_verify, + timeout=config.timeout, + http_username=config.http_username, + http_password=config.http_password, + api_version=config.api_version, + per_page=config.per_page, + pagination=config.pagination, + order_by=config.order_by, + user_agent=config.user_agent, + ) + + def auth(self): + """Performs an authentication using private token. + + The `user` attribute will hold a `gitlab.objects.CurrentUser` object on + success. + """ + self.user = self._objects.CurrentUserManager(self).get() + + def version(self): + """Returns the version and revision of the gitlab server. + + Note that self.version and self.revision will be set on the gitlab + object. + + Returns: + tuple (str, str): The server version and server revision. + ('unknown', 'unknwown') if the server doesn't + perform as expected. + """ + if self._server_version is None: + try: + data = self.http_get("/version") + self._server_version = data["version"] + self._server_revision = data["revision"] + except Exception: + self._server_version = self._server_revision = "unknown" + + return self._server_version, self._server_revision + + @on_http_error(GitlabVerifyError) + def lint(self, content, **kwargs): + """Validate a gitlab CI configuration. + + Args: + content (txt): The .gitlab-ci.yml content + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabVerifyError: If the validation could not be done + + Returns: + tuple: (True, []) if the file is valid, (False, errors(list)) + otherwise + """ + post_data = {"content": content} + data = self.http_post("/ci/lint", post_data=post_data, **kwargs) + return (data["status"] == "valid", data["errors"]) + + @on_http_error(GitlabMarkdownError) + def markdown(self, text, gfm=False, project=None, **kwargs): + """Render an arbitrary Markdown document. + + Args: + text (str): The markdown text to render + gfm (bool): Render text using GitLab Flavored Markdown. Default is + False + project (str): Full path of a project used a context when `gfm` is + True + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabMarkdownError: If the server cannot perform the request + + Returns: + str: The HTML rendering of the markdown text. + """ + post_data = {"text": text, "gfm": gfm} + if project is not None: + post_data["project"] = project + data = self.http_post("/markdown", post_data=post_data, **kwargs) + return data["html"] + + @on_http_error(GitlabLicenseError) + def get_license(self, **kwargs): + """Retrieve information about the current license. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the server cannot perform the request + + Returns: + dict: The current license information + """ + return self.http_get("/license", **kwargs) + + @on_http_error(GitlabLicenseError) + def set_license(self, license, **kwargs): + """Add a new license. + + Args: + license (str): The license string + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabPostError: If the server cannot perform the request + + Returns: + dict: The new license information + """ + data = {"license": license} + return self.http_post("/license", post_data=data, **kwargs) + + def _construct_url(self, id_, obj, parameters, action=None): + if "next_url" in parameters: + return parameters["next_url"] + args = utils.sanitize_parameters(parameters) + + url_attr = "_url" + if action is not None: + attr = "_%s_url" % action + if hasattr(obj, attr): + url_attr = attr + obj_url = getattr(obj, url_attr) + url = obj_url % args + + if id_ is not None: + return "%s/%s" % (url, str(id_)) + else: + return url + + def _set_auth_info(self): + 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"] = "Bearer %s" % 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): + 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 _create_headers(self, content_type=None): + request_headers = self.headers.copy() + if content_type is not None: + request_headers["Content-type"] = content_type + return request_headers + + def _get_session_opts(self, content_type): + return { + "headers": self._create_headers(content_type), + "auth": self._http_auth, + "timeout": self.timeout, + "verify": self.ssl_verify, + } + + def _build_url(self, path): + """Returns the full url from path. + + If path is already a url, return it unchanged. If it's a path, append + it to the stored url. + + Returns: + str: The full URL + """ + if path.startswith("http://") or path.startswith("https://"): + return path + else: + return "%s%s" % (self._url, path) + + def _check_redirects(self, result): + # Check the requests history to detect http to https redirections. + # If the initial verb is POST, the next request will use a GET request, + # leading to an unwanted behaviour. + # If the initial verb is PUT, the data will not be send with the next + # request. + # If we detect a redirection to https with a POST or a PUT request, we + # raise an exception with a useful error message. + if result.history and self._base_url.startswith("http:"): + for item in result.history: + if item.status_code not in (301, 302): + continue + # GET methods can be redirected without issue + if item.request.method == "GET": + continue + # Did we end-up with an https:// URL? + location = item.headers.get("Location", None) + if location and location.startswith("https://"): + raise RedirectError(REDIRECT_MSG) + + def http_request( + self, + verb, + path, + query_data=None, + post_data=None, + streamed=False, + files=None, + **kwargs + ): + """Make an HTTP request to the Gitlab server. + + Args: + verb (str): The HTTP method to call ('get', 'post', 'put', + 'delete') + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + streamed (bool): Whether the data should be streamed + files (dict): The files to send to the server + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + A requests result object. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + query_data = query_data or {} + url = self._build_url(path) + + params = {} + utils.copy_dict(params, query_data) + + # Deal with kwargs: by default a user uses kwargs to send data to the + # gitlab server, but this generates problems (python keyword conflicts + # and python-gitlab/gitlab conflicts). + # So we provide a `query_parameters` key: if it's there we use its dict + # value as arguments for the gitlab server, and ignore the other + # arguments, except pagination ones (per_page and page) + if "query_parameters" in kwargs: + utils.copy_dict(params, kwargs["query_parameters"]) + for arg in ("per_page", "page"): + if arg in kwargs: + params[arg] = kwargs[arg] + else: + utils.copy_dict(params, kwargs) + + opts = self._get_session_opts(content_type="application/json") + + verify = opts.pop("verify") + timeout = opts.pop("timeout") + # If timeout was passed into kwargs, allow it to override the default + timeout = kwargs.get("timeout", timeout) + + # We need to deal with json vs. data when uploading files + if files: + json = None + post_data["file"] = files.get("file") + post_data["avatar"] = files.get("avatar") + data = MultipartEncoder(post_data) + opts["headers"]["Content-type"] = data.content_type + else: + json = post_data + data = None + + # Requests assumes that `.` should not be encoded as %2E and will make + # changes to urls using this encoding. Using a prepped request we can + # get the desired behavior. + # The Requests behavior is right but it seems that web servers don't + # always agree with this decision (this is the case with a default + # gitlab installation) + req = requests.Request(verb, url, json=json, data=data, params=params, **opts) + prepped = self.session.prepare_request(req) + prepped.url = utils.sanitized_url(prepped.url) + settings = self.session.merge_environment_settings( + prepped.url, {}, streamed, verify, None + ) + + # obey the rate limit by default + obey_rate_limit = kwargs.get("obey_rate_limit", True) + # do not retry transient errors by default + retry_transient_errors = kwargs.get("retry_transient_errors", False) + + # set max_retries to 10 by default, disable by setting it to -1 + max_retries = kwargs.get("max_retries", 10) + cur_retries = 0 + + while True: + result = self.session.send(prepped, timeout=timeout, **settings) + + self._check_redirects(result) + + if 200 <= result.status_code < 300: + return result + + if (429 == result.status_code and obey_rate_limit) or ( + result.status_code in [500, 502, 503, 504] and retry_transient_errors + ): + if max_retries == -1 or cur_retries < max_retries: + wait_time = 2 ** cur_retries * 0.1 + if "Retry-After" in result.headers: + wait_time = int(result.headers["Retry-After"]) + cur_retries += 1 + time.sleep(wait_time) + continue + + error_message = result.content + try: + error_json = result.json() + for k in ("message", "error"): + if k in error_json: + error_message = error_json[k] + except (KeyError, ValueError, TypeError): + pass + + if result.status_code == 401: + raise GitlabAuthenticationError( + response_code=result.status_code, + error_message=error_message, + response_body=result.content, + ) + + raise GitlabHttpError( + response_code=result.status_code, + error_message=error_message, + response_body=result.content, + ) + + def http_get(self, path, query_data=None, streamed=False, raw=False, **kwargs): + """Make a GET request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + streamed (bool): Whether the data should be streamed + raw (bool): If True do not try to parse the output as json + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + A requests result object is streamed is True or the content type is + not json. + The parsed json data otherwise. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: If the json data could not be parsed + """ + query_data = query_data or {} + result = self.http_request( + "get", path, query_data=query_data, streamed=streamed, **kwargs + ) + + if ( + result.headers["Content-Type"] == "application/json" + and not streamed + and not raw + ): + try: + return result.json() + except Exception as e: + raise GitlabParsingError( + error_message="Failed to parse the server message" + ) from e + else: + return result + + def http_list(self, path, query_data=None, as_list=None, **kwargs): + """Make a GET request to the Gitlab server for list-oriented queries. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projects') + query_data (dict): Data to send as query parameters + **kwargs: Extra options to send to the server (e.g. sudo, page, + per_page) + + Returns: + list: A list of the objects returned by the server. If `as_list` is + False and no pagination-related arguments (`page`, `per_page`, + `all`) are defined then a GitlabList object (generator) is returned + instead. This object will make API calls when needed to fetch the + next items from the server. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: If the json data could not be parsed + """ + query_data = query_data or {} + + # In case we want to change the default behavior at some point + as_list = True if as_list is None else as_list + + get_all = kwargs.pop("all", False) + url = self._build_url(path) + + page = kwargs.get("page") + + if get_all is True and as_list is True: + return list(GitlabList(self, url, query_data, **kwargs)) + + if page or as_list is True: + # pagination requested, we return a list + return list(GitlabList(self, url, query_data, get_next=False, **kwargs)) + + # No pagination, generator requested + return GitlabList(self, url, query_data, **kwargs) + + def http_post(self, path, query_data=None, post_data=None, files=None, **kwargs): + """Make a POST request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + files (dict): The files to send to the server + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + The parsed json returned by the server if json is return, else the + raw content + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: If the json data could not be parsed + """ + query_data = query_data or {} + post_data = post_data or {} + + result = self.http_request( + "post", + path, + query_data=query_data, + post_data=post_data, + files=files, + **kwargs + ) + try: + if result.headers.get("Content-Type", None) == "application/json": + return result.json() + except Exception as e: + raise GitlabParsingError( + error_message="Failed to parse the server message" + ) from e + return result + + def http_put(self, path, query_data=None, post_data=None, files=None, **kwargs): + """Make a PUT request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + query_data (dict): Data to send as query parameters + post_data (dict): Data to send in the body (will be converted to + json) + files (dict): The files to send to the server + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + The parsed json returned by the server. + + Raises: + GitlabHttpError: When the return code is not 2xx + GitlabParsingError: If the json data could not be parsed + """ + query_data = query_data or {} + post_data = post_data or {} + + result = self.http_request( + "put", + path, + query_data=query_data, + post_data=post_data, + files=files, + **kwargs + ) + try: + return result.json() + except Exception as e: + raise GitlabParsingError( + error_message="Failed to parse the server message" + ) from e + + def http_delete(self, path, **kwargs): + """Make a PUT request to the Gitlab server. + + Args: + path (str): Path or full URL to query ('/projects' or + 'http://whatever/v4/api/projecs') + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + The requests object. + + Raises: + GitlabHttpError: When the return code is not 2xx + """ + return self.http_request("delete", path, **kwargs) + + @on_http_error(GitlabSearchError) + def search(self, scope, search, **kwargs): + """Search GitLab resources matching the provided string.' + + Args: + scope (str): Scope of the search + search (str): Search string + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabSearchError: If the server failed to perform the request + + Returns: + GitlabList: A list of dicts describing the resources found. + """ + data = {"scope": scope, "search": search} + return self.http_list("/search", query_data=data, **kwargs) + + +class GitlabList(object): + """Generator representing a list of remote objects. + + The object handles the links returned by a query to the API, and will call + the API again when needed. + """ + + def __init__(self, gl, url, query_data, get_next=True, **kwargs): + self._gl = gl + + # Preserve kwargs for subsequent queries + self._kwargs = kwargs.copy() + + self._query(url, query_data, **self._kwargs) + self._get_next = get_next + + def _query(self, url, query_data=None, **kwargs): + query_data = query_data or {} + result = self._gl.http_request("get", url, query_data=query_data, **kwargs) + try: + links = result.links + if links: + next_url = links["next"]["url"] + else: + next_url = requests.utils.parse_header_links(result.headers["links"])[ + 0 + ]["url"] + self._next_url = next_url + except KeyError: + self._next_url = None + self._current_page = result.headers.get("X-Page") + self._prev_page = result.headers.get("X-Prev-Page") + self._next_page = result.headers.get("X-Next-Page") + self._per_page = result.headers.get("X-Per-Page") + self._total_pages = result.headers.get("X-Total-Pages") + self._total = result.headers.get("X-Total") + + try: + self._data = result.json() + except Exception as e: + raise GitlabParsingError( + error_message="Failed to parse the server message" + ) from e + + self._current = 0 + + @property + def current_page(self): + """The current page number.""" + return int(self._current_page) + + @property + def prev_page(self): + """The previous page number. + + If None, the current page is the first. + """ + return int(self._prev_page) if self._prev_page else None + + @property + def next_page(self): + """The next page number. + + If None, the current page is the last. + """ + return int(self._next_page) if self._next_page else None + + @property + def per_page(self): + """The number of items per page.""" + return int(self._per_page) + + @property + def total_pages(self): + """The total number of pages.""" + return int(self._total_pages) + + @property + def total(self): + """The total number of items.""" + return int(self._total) + + def __iter__(self): + return self + + def __len__(self): + return int(self._total) + + def __next__(self): + return self.next() + + def next(self): + try: + item = self._data[self._current] + self._current += 1 + return item + except IndexError: + pass + + if self._next_url and self._get_next is True: + self._query(self._next_url, **self._kwargs) + return self.next() + + raise StopIteration diff --git a/gitlab/tests/test_gitlab_http_methods.py b/gitlab/tests/test_gitlab_http_methods.py index fac89b9..253ad16 100644 --- a/gitlab/tests/test_gitlab_http_methods.py +++ b/gitlab/tests/test_gitlab_http_methods.py @@ -1,4 +1,5 @@ import pytest +import requests from httmock import HTTMock, urlmatch, response |