This commit is contained in:
2024-12-09 18:22:38 +09:00
parent ab0cbebefc
commit c4c4547706
959 changed files with 174888 additions and 6 deletions

View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2013-2019 Gauvain Pocentek, 2019-2023 python-gitlab team
#
# 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 warnings
import gitlab.config # noqa: F401
from gitlab._version import ( # noqa: F401
__author__,
__copyright__,
__email__,
__license__,
__title__,
__version__,
)
from gitlab.client import Gitlab, GitlabList, GraphQL # noqa: F401
from gitlab.exceptions import * # noqa: F401,F403
warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab")
__all__ = [
"__author__",
"__copyright__",
"__email__",
"__license__",
"__title__",
"__version__",
"Gitlab",
"GitlabList",
"GraphQL",
]
__all__.extend(gitlab.exceptions.__all__)

View File

@ -0,0 +1,4 @@
import gitlab.cli
if __name__ == "__main__":
gitlab.cli.main()

View File

@ -0,0 +1,22 @@
"""
Defines http backends for processing http requests
"""
from .requests_backend import (
JobTokenAuth,
OAuthTokenAuth,
PrivateTokenAuth,
RequestsBackend,
RequestsResponse,
)
DefaultBackend = RequestsBackend
DefaultResponse = RequestsResponse
__all__ = [
"DefaultBackend",
"DefaultResponse",
"JobTokenAuth",
"OAuthTokenAuth",
"PrivateTokenAuth",
]

View File

@ -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

View File

@ -0,0 +1,32 @@
import abc
import sys
from typing import Any, Dict, Optional, Union
import requests
from requests_toolbelt.multipart.encoder import MultipartEncoder # type: ignore
if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol
class BackendResponse(Protocol):
@abc.abstractmethod
def __init__(self, response: requests.Response) -> None: ...
class Backend(Protocol):
@abc.abstractmethod
def http_request(
self,
method: str,
url: str,
json: Optional[Union[Dict[str, Any], bytes]],
data: Optional[Union[Dict[str, Any], MultipartEncoder]],
params: Optional[Any],
timeout: Optional[float],
verify: Optional[Union[bool, str]],
stream: Optional[bool],
**kwargs: Any,
) -> BackendResponse: ...

View File

@ -0,0 +1,168 @@
from __future__ import annotations
import dataclasses
from typing import Any, BinaryIO, Dict, Optional, TYPE_CHECKING, Union
import requests
from requests import PreparedRequest
from requests.auth import AuthBase
from requests.structures import CaseInsensitiveDict
from requests_toolbelt.multipart.encoder import MultipartEncoder # type: ignore
from . import protocol
class TokenAuth:
def __init__(self, token: str):
self.token = token
class OAuthTokenAuth(TokenAuth, AuthBase):
def __call__(self, r: PreparedRequest) -> PreparedRequest:
r.headers["Authorization"] = f"Bearer {self.token}"
r.headers.pop("PRIVATE-TOKEN", None)
r.headers.pop("JOB-TOKEN", None)
return r
class PrivateTokenAuth(TokenAuth, AuthBase):
def __call__(self, r: PreparedRequest) -> PreparedRequest:
r.headers["PRIVATE-TOKEN"] = self.token
r.headers.pop("JOB-TOKEN", None)
r.headers.pop("Authorization", None)
return r
class JobTokenAuth(TokenAuth, AuthBase):
def __call__(self, r: PreparedRequest) -> PreparedRequest:
r.headers["JOB-TOKEN"] = self.token
r.headers.pop("PRIVATE-TOKEN", None)
r.headers.pop("Authorization", None)
return r
@dataclasses.dataclass
class SendData:
content_type: str
data: Optional[Union[Dict[str, Any], MultipartEncoder]] = None
json: Optional[Union[Dict[str, Any], bytes]] = None
def __post_init__(self) -> None:
if self.json is not None and self.data is not None:
raise ValueError(
f"`json` and `data` are mutually exclusive. Only one can be set. "
f"json={self.json!r} data={self.data!r}"
)
class RequestsResponse(protocol.BackendResponse):
def __init__(self, response: requests.Response) -> None:
self._response: requests.Response = response
@property
def response(self) -> requests.Response:
return self._response
@property
def status_code(self) -> int:
return self._response.status_code
@property
def headers(self) -> CaseInsensitiveDict[str]:
return self._response.headers
@property
def content(self) -> bytes:
return self._response.content
@property
def reason(self) -> str:
return self._response.reason
def json(self) -> Any:
return self._response.json()
class RequestsBackend(protocol.Backend):
def __init__(self, session: Optional[requests.Session] = None) -> None:
self._client: requests.Session = session or requests.Session()
@property
def client(self) -> requests.Session:
return self._client
@staticmethod
def prepare_send_data(
files: Optional[Dict[str, Any]] = None,
post_data: Optional[Union[Dict[str, Any], bytes, BinaryIO]] = None,
raw: bool = False,
) -> SendData:
if files:
if post_data is None:
post_data = {}
else:
# When creating a `MultipartEncoder` instance with data-types
# which don't have an `encode` method it will cause an error:
# object has no attribute 'encode'
# So convert common non-string types into strings.
if TYPE_CHECKING:
assert isinstance(post_data, dict)
for k, v in post_data.items():
if isinstance(v, bool):
v = int(v)
if isinstance(v, (complex, float, int)):
post_data[k] = str(v)
post_data["file"] = files.get("file")
post_data["avatar"] = files.get("avatar")
data = MultipartEncoder(fields=post_data)
return SendData(data=data, content_type=data.content_type)
if raw and post_data:
return SendData(data=post_data, content_type="application/octet-stream")
if TYPE_CHECKING:
assert not isinstance(post_data, BinaryIO)
return SendData(json=post_data, content_type="application/json")
def http_request(
self,
method: str,
url: str,
json: Optional[Union[Dict[str, Any], bytes]] = None,
data: Optional[Union[Dict[str, Any], MultipartEncoder]] = None,
params: Optional[Any] = None,
timeout: Optional[float] = None,
verify: Optional[Union[bool, str]] = True,
stream: Optional[bool] = False,
**kwargs: Any,
) -> RequestsResponse:
"""Make HTTP request
Args:
method: The HTTP method to call ('get', 'post', 'put', 'delete', etc.)
url: The full URL
data: The data to send to the server in the body of the request
json: Data to send in the body in json by default
timeout: The timeout, in seconds, for the request
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.
stream: Whether the data should be streamed
Returns:
A requests Response object.
"""
response: requests.Response = self._client.request(
method=method,
url=url,
params=params,
data=data,
timeout=timeout,
stream=stream,
verify=verify,
json=json,
**kwargs,
)
return RequestsResponse(response=response)

View File

@ -0,0 +1,6 @@
__author__ = "Gauvain Pocentek, python-gitlab team"
__copyright__ = "Copyright 2013-2019 Gauvain Pocentek, 2019-2023 python-gitlab team"
__email__ = "gauvainpocentek@gmail.com"
__license__ = "LGPL3"
__title__ = "python-gitlab"
__version__ = "5.1.0"

View File

@ -0,0 +1,394 @@
import copy
import importlib
import json
import pprint
import textwrap
from types import ModuleType
from typing import Any, Dict, Iterable, Optional, Type, TYPE_CHECKING, Union
import gitlab
from gitlab import types as g_types
from gitlab.exceptions import GitlabParsingError
from .client import Gitlab, GitlabList
__all__ = [
"RESTObject",
"RESTObjectList",
"RESTManager",
]
_URL_ATTRIBUTE_ERROR = (
f"https://python-gitlab.readthedocs.io/en/v{gitlab.__version__}/"
f"faq.html#attribute-error-list"
)
class RESTObject:
"""Represents an object built from server data.
It holds the attributes know from the server, and the updated attributes in
another. This allows smart updates, if the object allows it.
You can redefine ``_id_attr`` in child classes to specify which attribute
must be used as the unique ID. ``None`` means that the object can be updated
without ID in the url.
Likewise, you can define a ``_repr_attr`` in subclasses to specify which
attribute should be added as a human-readable identifier when called in the
object's ``__repr__()`` method.
"""
_id_attr: Optional[str] = "id"
_attrs: Dict[str, Any]
_created_from_list: bool # Indicates if object was created from a list() action
_module: ModuleType
_parent_attrs: Dict[str, Any]
_repr_attr: Optional[str] = None
_updated_attrs: Dict[str, Any]
_lazy: bool
manager: "RESTManager"
def __init__(
self,
manager: "RESTManager",
attrs: Dict[str, Any],
*,
created_from_list: bool = False,
lazy: bool = False,
) -> None:
if not isinstance(attrs, dict):
raise GitlabParsingError(
f"Attempted to initialize RESTObject with a non-dictionary value: "
f"{attrs!r}\nThis likely indicates an incorrect or malformed server "
f"response."
)
self.__dict__.update(
{
"manager": manager,
"_attrs": attrs,
"_updated_attrs": {},
"_module": importlib.import_module(self.__module__),
"_created_from_list": created_from_list,
"_lazy": lazy,
}
)
self.__dict__["_parent_attrs"] = self.manager.parent_attrs
self._create_managers()
def __getstate__(self) -> Dict[str, Any]:
state = self.__dict__.copy()
module = state.pop("_module")
state["_module_name"] = module.__name__
return state
def __setstate__(self, state: Dict[str, Any]) -> None:
module_name = state.pop("_module_name")
self.__dict__.update(state)
self.__dict__["_module"] = importlib.import_module(module_name)
def __getattr__(self, name: str) -> Any:
if name in self.__dict__["_updated_attrs"]:
return self.__dict__["_updated_attrs"][name]
if name in self.__dict__["_attrs"]:
value = self.__dict__["_attrs"][name]
# If the value is a list, we copy it in the _updated_attrs dict
# because we are not able to detect changes made on the object
# (append, insert, pop, ...). Without forcing the attr
# creation __setattr__ is never called, the list never ends up
# in the _updated_attrs dict, and the update() and save()
# method never push the new data to the server.
# See https://github.com/python-gitlab/python-gitlab/issues/306
#
# note: _parent_attrs will only store simple values (int) so we
# don't make this check in the next block.
if isinstance(value, list):
self.__dict__["_updated_attrs"][name] = value[:]
return self.__dict__["_updated_attrs"][name]
return value
if name in self.__dict__["_parent_attrs"]:
return self.__dict__["_parent_attrs"][name]
message = f"{type(self).__name__!r} object has no attribute {name!r}"
if self._created_from_list:
message = (
f"{message}\n\n"
+ textwrap.fill(
f"{self.__class__!r} was created via a list() call and "
f"only a subset of the data may be present. To ensure "
f"all data is present get the object using a "
f"get(object.id) call. For more details, see:"
)
+ f"\n\n{_URL_ATTRIBUTE_ERROR}"
)
elif self._lazy:
message = f"{message}\n\n" + textwrap.fill(
f"If you tried to access object attributes returned from the server, "
f"note that {self.__class__!r} was created as a `lazy` object and was "
f"not initialized with any data."
)
raise AttributeError(message)
def __setattr__(self, name: str, value: Any) -> None:
self.__dict__["_updated_attrs"][name] = value
def asdict(self, *, with_parent_attrs: bool = False) -> Dict[str, Any]:
data = {}
if with_parent_attrs:
data.update(copy.deepcopy(self._parent_attrs))
data.update(copy.deepcopy(self._attrs))
data.update(copy.deepcopy(self._updated_attrs))
return data
@property
def attributes(self) -> Dict[str, Any]:
return self.asdict(with_parent_attrs=True)
def to_json(self, *, with_parent_attrs: bool = False, **kwargs: Any) -> str:
return json.dumps(self.asdict(with_parent_attrs=with_parent_attrs), **kwargs)
def __str__(self) -> str:
return f"{type(self)} => {self.asdict()}"
def pformat(self) -> str:
return f"{type(self)} => \n{pprint.pformat(self.asdict())}"
def pprint(self) -> None:
print(self.pformat())
def __repr__(self) -> str:
name = self.__class__.__name__
if (self._id_attr and self._repr_value) and (self._id_attr != self._repr_attr):
return (
f"<{name} {self._id_attr}:{self.get_id()} "
f"{self._repr_attr}:{self._repr_value}>"
)
if self._id_attr:
return f"<{name} {self._id_attr}:{self.get_id()}>"
if self._repr_value:
return f"<{name} {self._repr_attr}:{self._repr_value}>"
return f"<{name}>"
def __eq__(self, other: object) -> bool:
if not isinstance(other, RESTObject):
return NotImplemented
if self.get_id() and other.get_id():
return self.get_id() == other.get_id()
return super() == other
def __ne__(self, other: object) -> bool:
if not isinstance(other, RESTObject):
return NotImplemented
if self.get_id() and other.get_id():
return self.get_id() != other.get_id()
return super() != other
def __dir__(self) -> Iterable[str]:
return set(self.attributes).union(super().__dir__())
def __hash__(self) -> int:
if not self.get_id():
return super().__hash__()
return hash(self.get_id())
def _create_managers(self) -> None:
# NOTE(jlvillal): We are creating our managers by looking at the class
# annotations. If an attribute is annotated as being a *Manager type
# then we create the manager and assign it to the attribute.
for attr, annotation in sorted(self.__class__.__annotations__.items()):
# We ignore creating a manager for the 'manager' attribute as that
# is done in the self.__init__() method
if attr in ("manager",):
continue
if not isinstance(annotation, (type, str)): # pragma: no cover
continue
if isinstance(annotation, type):
cls_name = annotation.__name__
else:
cls_name = annotation
# All *Manager classes are used except for the base "RESTManager" class
if cls_name == "RESTManager" or not cls_name.endswith("Manager"):
continue
cls = getattr(self._module, cls_name)
manager = cls(self.manager.gitlab, parent=self)
# Since we have our own __setattr__ method, we can't use setattr()
self.__dict__[attr] = manager
def _update_attrs(self, new_attrs: Dict[str, Any]) -> None:
self.__dict__["_updated_attrs"] = {}
self.__dict__["_attrs"] = new_attrs
def get_id(self) -> Optional[Union[int, str]]:
"""Returns the id of the resource."""
if self._id_attr is None or not hasattr(self, self._id_attr):
return None
id_val = getattr(self, self._id_attr)
if TYPE_CHECKING:
assert id_val is None or isinstance(id_val, (int, str))
return id_val
@property
def _repr_value(self) -> Optional[str]:
"""Safely returns the human-readable resource name if present."""
if self._repr_attr is None or not hasattr(self, self._repr_attr):
return None
repr_val = getattr(self, self._repr_attr)
if TYPE_CHECKING:
assert isinstance(repr_val, str)
return repr_val
@property
def encoded_id(self) -> Optional[Union[int, str]]:
"""Ensure that the ID is url-encoded so that it can be safely used in a URL
path"""
obj_id = self.get_id()
if isinstance(obj_id, str):
obj_id = gitlab.utils.EncodedId(obj_id)
return obj_id
class RESTObjectList:
"""Generator object representing a list of RESTObject's.
This generator uses the Gitlab pagination system to fetch new data when
required.
Note: you should not instantiate such objects, they are returned by calls
to RESTManager.list()
Args:
manager: Manager to attach to the created objects
obj_cls: Type of objects to create from the json data
_list: A GitlabList object
"""
def __init__(
self, manager: "RESTManager", obj_cls: Type[RESTObject], _list: GitlabList
) -> None:
"""Creates an objects list from a GitlabList.
You should not create objects of this type, but use managers list()
methods instead.
Args:
manager: the RESTManager to attach to the objects
obj_cls: the class of the created objects
_list: the GitlabList holding the data
"""
self.manager = manager
self._obj_cls = obj_cls
self._list = _list
def __iter__(self) -> "RESTObjectList":
return self
def __len__(self) -> int:
return len(self._list)
def __next__(self) -> RESTObject:
return self.next()
def next(self) -> RESTObject:
data = self._list.next()
return self._obj_cls(self.manager, data, created_from_list=True)
@property
def current_page(self) -> int:
"""The current page number."""
return self._list.current_page
@property
def prev_page(self) -> Optional[int]:
"""The previous page number.
If None, the current page is the first.
"""
return self._list.prev_page
@property
def next_page(self) -> Optional[int]:
"""The next page number.
If None, the current page is the last.
"""
return self._list.next_page
@property
def per_page(self) -> Optional[int]:
"""The number of items per page."""
return self._list.per_page
@property
def total_pages(self) -> Optional[int]:
"""The total number of pages."""
return self._list.total_pages
@property
def total(self) -> Optional[int]:
"""The total number of items."""
return self._list.total
class RESTManager:
"""Base class for CRUD operations on objects.
Derived class must define ``_path`` and ``_obj_cls``.
``_path``: Base URL path on which requests will be sent (e.g. '/projects')
``_obj_cls``: The class of objects that will be created
"""
_create_attrs: g_types.RequiredOptional = g_types.RequiredOptional()
_update_attrs: g_types.RequiredOptional = g_types.RequiredOptional()
_path: Optional[str] = None
_obj_cls: Optional[Type[RESTObject]] = None
_from_parent_attrs: Dict[str, Any] = {}
_types: Dict[str, Type[g_types.GitlabAttribute]] = {}
_computed_path: Optional[str]
_parent: Optional[RESTObject]
_parent_attrs: Dict[str, Any]
gitlab: Gitlab
def __init__(self, gl: Gitlab, parent: Optional[RESTObject] = None) -> None:
"""REST manager constructor.
Args:
gl: :class:`~gitlab.Gitlab` connection to use to make requests.
parent: REST object to which the manager is attached.
"""
self.gitlab = gl
self._parent = parent # for nested managers
self._computed_path = self._compute_path()
@property
def parent_attrs(self) -> Optional[Dict[str, Any]]:
return self._parent_attrs
def _compute_path(self, path: Optional[str] = None) -> Optional[str]:
self._parent_attrs = {}
if path is None:
path = self._path
if path is None:
return None
if self._parent is None or not self._from_parent_attrs:
return path
data: Dict[str, Optional[gitlab.utils.EncodedId]] = {}
for self_attr, parent_attr in self._from_parent_attrs.items():
if not hasattr(self._parent, parent_attr):
data[self_attr] = None
continue
data[self_attr] = gitlab.utils.EncodedId(getattr(self._parent, parent_attr))
self._parent_attrs = data
return path.format(**data)
@property
def path(self) -> Optional[str]:
return self._computed_path

View File

@ -0,0 +1,420 @@
import argparse
import dataclasses
import functools
import os
import pathlib
import re
import sys
from types import ModuleType
from typing import (
Any,
Callable,
cast,
Dict,
NoReturn,
Optional,
Tuple,
Type,
TYPE_CHECKING,
TypeVar,
Union,
)
from requests.structures import CaseInsensitiveDict
import gitlab.config
from gitlab.base import RESTObject
# This regex is based on:
# https://github.com/jpvanhal/inflection/blob/master/inflection/__init__.py
camel_upperlower_regex = re.compile(r"([A-Z]+)([A-Z][a-z])")
camel_lowerupper_regex = re.compile(r"([a-z\d])([A-Z])")
@dataclasses.dataclass
class CustomAction:
required: Tuple[str, ...]
optional: Tuple[str, ...]
in_object: bool
requires_id: bool # if the `_id_attr` value should be a required argument
help: Optional[str] # help text for the custom action
# custom_actions = {
# cls: {
# action: CustomAction,
# },
# }
custom_actions: Dict[str, Dict[str, CustomAction]] = {}
# For an explanation of how these type-hints work see:
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
#
# The goal here is that functions which get decorated will retain their types.
__F = TypeVar("__F", bound=Callable[..., Any])
def register_custom_action(
*,
cls_names: Union[str, Tuple[str, ...]],
required: Tuple[str, ...] = (),
optional: Tuple[str, ...] = (),
custom_action: Optional[str] = None,
requires_id: bool = True, # if the `_id_attr` value should be a required argument
help: Optional[str] = None, # help text for the action
) -> Callable[[__F], __F]:
def wrap(f: __F) -> __F:
@functools.wraps(f)
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
return f(*args, **kwargs)
# in_obj defines whether the method belongs to the obj or the manager
in_obj = True
if isinstance(cls_names, tuple):
classes = cls_names
else:
classes = (cls_names,)
for cls_name in classes:
final_name = cls_name
if cls_name.endswith("Manager"):
final_name = cls_name.replace("Manager", "")
in_obj = False
if final_name not in custom_actions:
custom_actions[final_name] = {}
action = custom_action or f.__name__.replace("_", "-")
custom_actions[final_name][action] = CustomAction(
required=required,
optional=optional,
in_object=in_obj,
requires_id=requires_id,
help=help,
)
return cast(__F, wrapped_f)
return wrap
def die(msg: str, e: Optional[Exception] = None) -> NoReturn:
if e:
msg = f"{msg} ({e})"
sys.stderr.write(f"{msg}\n")
sys.exit(1)
def gitlab_resource_to_cls(
gitlab_resource: str, namespace: ModuleType
) -> Type[RESTObject]:
classes = CaseInsensitiveDict(namespace.__dict__)
lowercase_class = gitlab_resource.replace("-", "")
class_type = classes[lowercase_class]
if TYPE_CHECKING:
assert isinstance(class_type, type)
assert issubclass(class_type, RESTObject)
return class_type
def cls_to_gitlab_resource(cls: RESTObject) -> str:
dasherized_uppercase = camel_upperlower_regex.sub(r"\1-\2", cls.__name__)
dasherized_lowercase = camel_lowerupper_regex.sub(r"\1-\2", dasherized_uppercase)
return dasherized_lowercase.lower()
def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
add_help=add_help,
description="GitLab API Command Line Interface",
allow_abbrev=False,
)
parser.add_argument("--version", help="Display the version.", action="store_true")
parser.add_argument(
"-v",
"--verbose",
"--fancy",
help="Verbose mode (legacy format only) [env var: GITLAB_VERBOSE]",
action="store_true",
default=os.getenv("GITLAB_VERBOSE"),
)
parser.add_argument(
"-d",
"--debug",
help="Debug mode (display HTTP requests) [env var: GITLAB_DEBUG]",
action="store_true",
default=os.getenv("GITLAB_DEBUG"),
)
parser.add_argument(
"-c",
"--config-file",
action="append",
help=(
"Configuration file to use. Can be used multiple times. "
"[env var: PYTHON_GITLAB_CFG]"
),
)
parser.add_argument(
"-g",
"--gitlab",
help=(
"Which configuration section should "
"be used. If not defined, the default selection "
"will be used."
),
required=False,
)
parser.add_argument(
"-o",
"--output",
help="Output format (v4 only): json|legacy|yaml",
required=False,
choices=["json", "legacy", "yaml"],
default="legacy",
)
parser.add_argument(
"-f",
"--fields",
help=(
"Fields to display in the output (comma "
"separated). Not used with legacy output"
),
required=False,
)
parser.add_argument(
"--server-url",
help=("GitLab server URL [env var: GITLAB_URL]"),
required=False,
default=os.getenv("GITLAB_URL"),
)
ssl_verify_group = parser.add_mutually_exclusive_group()
ssl_verify_group.add_argument(
"--ssl-verify",
help=(
"Path to a CA_BUNDLE file or directory with certificates of trusted CAs. "
"[env var: GITLAB_SSL_VERIFY]"
),
required=False,
default=os.getenv("GITLAB_SSL_VERIFY"),
)
ssl_verify_group.add_argument(
"--no-ssl-verify",
help="Disable SSL verification",
required=False,
dest="ssl_verify",
action="store_false",
)
parser.add_argument(
"--timeout",
help=(
"Timeout to use for requests to the GitLab server. "
"[env var: GITLAB_TIMEOUT]"
),
required=False,
type=int,
default=os.getenv("GITLAB_TIMEOUT"),
)
parser.add_argument(
"--api-version",
help=("GitLab API version [env var: GITLAB_API_VERSION]"),
required=False,
default=os.getenv("GITLAB_API_VERSION"),
)
parser.add_argument(
"--per-page",
help=(
"Number of entries to return per page in the response. "
"[env var: GITLAB_PER_PAGE]"
),
required=False,
type=int,
default=os.getenv("GITLAB_PER_PAGE"),
)
parser.add_argument(
"--pagination",
help=(
"Whether to use keyset or offset pagination [env var: GITLAB_PAGINATION]"
),
required=False,
default=os.getenv("GITLAB_PAGINATION"),
)
parser.add_argument(
"--order-by",
help=("Set order_by globally [env var: GITLAB_ORDER_BY]"),
required=False,
default=os.getenv("GITLAB_ORDER_BY"),
)
parser.add_argument(
"--user-agent",
help=(
"The user agent to send to GitLab with the HTTP request. "
"[env var: GITLAB_USER_AGENT]"
),
required=False,
default=os.getenv("GITLAB_USER_AGENT"),
)
tokens = parser.add_mutually_exclusive_group()
tokens.add_argument(
"--private-token",
help=("GitLab private access token [env var: GITLAB_PRIVATE_TOKEN]"),
required=False,
default=os.getenv("GITLAB_PRIVATE_TOKEN"),
)
tokens.add_argument(
"--oauth-token",
help=("GitLab OAuth token [env var: GITLAB_OAUTH_TOKEN]"),
required=False,
default=os.getenv("GITLAB_OAUTH_TOKEN"),
)
tokens.add_argument(
"--job-token",
help=("GitLab CI job token [env var: CI_JOB_TOKEN]"),
required=False,
)
parser.add_argument(
"--skip-login",
help=(
"Skip initial authenticated API call to the current user endpoint. "
"This may be useful when invoking the CLI in scripts. "
"[env var: GITLAB_SKIP_LOGIN]"
),
action="store_true",
default=os.getenv("GITLAB_SKIP_LOGIN"),
)
parser.add_argument(
"--no-mask-credentials",
help="Don't mask credentials in debug mode",
dest="mask_credentials",
action="store_false",
)
return parser
def _get_parser() -> argparse.ArgumentParser:
# NOTE: We must delay import of gitlab.v4.cli until now or
# otherwise it will cause circular import errors
from gitlab.v4 import cli as v4_cli
parser = _get_base_parser()
return v4_cli.extend_parser(parser)
def _parse_value(v: Any) -> Any:
if isinstance(v, str) and v.startswith("@@"):
return v[1:]
if isinstance(v, str) and v.startswith("@"):
# If the user-provided value starts with @, we try to read the file
# path provided after @ as the real value.
filepath = pathlib.Path(v[1:]).expanduser().resolve()
try:
with open(filepath, encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
with open(filepath, "rb") as f:
return f.read()
except OSError as exc:
exc_name = type(exc).__name__
sys.stderr.write(f"{exc_name}: {exc}\n")
sys.exit(1)
return v
def docs() -> argparse.ArgumentParser: # pragma: no cover
"""
Provide a statically generated parser for sphinx only, so we don't need
to provide dummy gitlab config for readthedocs.
"""
if "sphinx" not in sys.modules:
sys.exit("Docs parser is only intended for build_sphinx")
return _get_parser()
def main() -> None:
if "--version" in sys.argv:
print(gitlab.__version__)
sys.exit(0)
parser = _get_base_parser(add_help=False)
# This first parsing step is used to find the gitlab config to use, and
# load the propermodule (v3 or v4) accordingly. At that point we don't have
# any subparser setup
(options, _) = parser.parse_known_args(sys.argv)
try:
config = gitlab.config.GitlabConfigParser(options.gitlab, options.config_file)
except gitlab.config.ConfigError as e:
if "--help" in sys.argv or "-h" in sys.argv:
parser.print_help()
sys.exit(0)
sys.exit(str(e))
# We only support v4 API at this time
if config.api_version not in ("4",): # dead code # pragma: no cover
raise ModuleNotFoundError(f"gitlab.v{config.api_version}.cli")
# Now we build the entire set of subcommands and do the complete parsing
parser = _get_parser()
try:
import argcomplete # type: ignore
argcomplete.autocomplete(parser) # pragma: no cover
except Exception:
pass
args = parser.parse_args()
config_files = args.config_file
gitlab_id = args.gitlab
verbose = args.verbose
output = args.output
fields = []
if args.fields:
fields = [x.strip() for x in args.fields.split(",")]
debug = args.debug
gitlab_resource = args.gitlab_resource
resource_action = args.resource_action
skip_login = args.skip_login
mask_credentials = args.mask_credentials
args_dict = vars(args)
# Remove CLI behavior-related args
for item in (
"api_version",
"config_file",
"debug",
"fields",
"gitlab",
"gitlab_resource",
"job_token",
"mask_credentials",
"oauth_token",
"output",
"pagination",
"private_token",
"resource_action",
"server_url",
"skip_login",
"ssl_verify",
"timeout",
"user_agent",
"verbose",
"version",
):
args_dict.pop(item)
args_dict = {k: _parse_value(v) for k, v in args_dict.items() if v is not None}
try:
gl = gitlab.Gitlab.merge_config(vars(options), gitlab_id, config_files)
if debug:
gl.enable_debug(mask_credentials=mask_credentials)
if not skip_login and (gl.private_token or gl.oauth_token):
gl.auth()
except Exception as e:
die(str(e))
gitlab.v4.cli.run(
gl, gitlab_resource, resource_action, args_dict, verbose, output, fields
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,287 @@
import configparser
import os
import shlex
import subprocess
from os.path import expanduser, expandvars
from pathlib import Path
from typing import List, Optional, Union
from gitlab.const import USER_AGENT
_DEFAULT_FILES: List[str] = [
"/etc/python-gitlab.cfg",
str(Path.home() / ".python-gitlab.cfg"),
]
HELPER_PREFIX = "helper:"
HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"]
_CONFIG_PARSER_ERRORS = (configparser.NoOptionError, configparser.NoSectionError)
def _resolve_file(filepath: Union[Path, str]) -> str:
resolved = Path(filepath).resolve(strict=True)
return str(resolved)
def _get_config_files(
config_files: Optional[List[str]] = None,
) -> Union[str, List[str]]:
"""
Return resolved path(s) to config files if they exist, with precedence:
1. Files passed in config_files
2. File defined in PYTHON_GITLAB_CFG
3. User- and system-wide config files
"""
resolved_files = []
if config_files:
for config_file in config_files:
try:
resolved = _resolve_file(config_file)
except OSError as e:
raise GitlabConfigMissingError(
f"Cannot read config from file: {e}"
) from e
resolved_files.append(resolved)
return resolved_files
try:
env_config = os.environ["PYTHON_GITLAB_CFG"]
return _resolve_file(env_config)
except KeyError:
pass
except OSError as e:
raise GitlabConfigMissingError(
f"Cannot read config from PYTHON_GITLAB_CFG: {e}"
) from e
for config_file in _DEFAULT_FILES:
try:
resolved = _resolve_file(config_file)
except OSError:
continue
resolved_files.append(resolved)
return resolved_files
class ConfigError(Exception):
pass
class GitlabIDError(ConfigError):
pass
class GitlabDataError(ConfigError):
pass
class GitlabConfigMissingError(ConfigError):
pass
class GitlabConfigHelperError(ConfigError):
pass
class GitlabConfigParser:
def __init__(
self, gitlab_id: Optional[str] = None, config_files: Optional[List[str]] = None
) -> None:
self.gitlab_id = gitlab_id
self.http_username: Optional[str] = None
self.http_password: Optional[str] = None
self.job_token: Optional[str] = None
self.oauth_token: Optional[str] = None
self.private_token: Optional[str] = None
self.api_version: str = "4"
self.order_by: Optional[str] = None
self.pagination: Optional[str] = None
self.per_page: Optional[int] = None
self.retry_transient_errors: bool = False
self.ssl_verify: Union[bool, str] = True
self.timeout: int = 60
self.url: Optional[str] = None
self.user_agent: str = USER_AGENT
self.keep_base_url: bool = False
self._files = _get_config_files(config_files)
if self._files:
self._parse_config()
if self.gitlab_id and not self._files:
raise GitlabConfigMissingError(
f"A gitlab id was provided ({self.gitlab_id}) but no config file found"
)
def _parse_config(self) -> None:
_config = configparser.ConfigParser()
_config.read(self._files, encoding="utf-8")
if self.gitlab_id and not _config.has_section(self.gitlab_id):
raise GitlabDataError(
f"A gitlab id was provided ({self.gitlab_id}) "
"but no config section found"
)
if self.gitlab_id is None:
try:
self.gitlab_id = _config.get("global", "default")
except Exception as e:
raise GitlabIDError(
"Impossible to get the gitlab id (not specified in config file)"
) from e
try:
self.url = _config.get(self.gitlab_id, "url")
except Exception as e:
raise GitlabDataError(
"Impossible to get gitlab details from "
f"configuration ({self.gitlab_id})"
) from e
try:
self.ssl_verify = _config.getboolean("global", "ssl_verify")
except ValueError:
# Value Error means the option exists but isn't a boolean.
# Get as a string instead as it should then be a local path to a
# CA bundle.
self.ssl_verify = _config.get("global", "ssl_verify")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.ssl_verify = _config.getboolean(self.gitlab_id, "ssl_verify")
except ValueError:
# Value Error means the option exists but isn't a boolean.
# Get as a string instead as it should then be a local path to a
# CA bundle.
self.ssl_verify = _config.get(self.gitlab_id, "ssl_verify")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.timeout = _config.getint("global", "timeout")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.timeout = _config.getint(self.gitlab_id, "timeout")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.private_token = _config.get(self.gitlab_id, "private_token")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.oauth_token = _config.get(self.gitlab_id, "oauth_token")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.job_token = _config.get(self.gitlab_id, "job_token")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.http_username = _config.get(self.gitlab_id, "http_username")
self.http_password = _config.get(
self.gitlab_id, "http_password"
) # pragma: no cover
except _CONFIG_PARSER_ERRORS:
pass
self._get_values_from_helper()
try:
self.api_version = _config.get("global", "api_version")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.api_version = _config.get(self.gitlab_id, "api_version")
except _CONFIG_PARSER_ERRORS:
pass
if self.api_version not in ("4",):
raise GitlabDataError(f"Unsupported API version: {self.api_version}")
for section in ["global", self.gitlab_id]:
try:
self.per_page = _config.getint(section, "per_page")
except _CONFIG_PARSER_ERRORS:
pass
if self.per_page is not None and not 0 <= self.per_page <= 100:
raise GitlabDataError(f"Unsupported per_page number: {self.per_page}")
try:
self.pagination = _config.get(self.gitlab_id, "pagination")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.order_by = _config.get(self.gitlab_id, "order_by")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.user_agent = _config.get("global", "user_agent")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.user_agent = _config.get(self.gitlab_id, "user_agent")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.keep_base_url = _config.getboolean("global", "keep_base_url")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.keep_base_url = _config.getboolean(self.gitlab_id, "keep_base_url")
except _CONFIG_PARSER_ERRORS:
pass
try:
self.retry_transient_errors = _config.getboolean(
"global", "retry_transient_errors"
)
except _CONFIG_PARSER_ERRORS:
pass
try:
self.retry_transient_errors = _config.getboolean(
self.gitlab_id, "retry_transient_errors"
)
except _CONFIG_PARSER_ERRORS:
pass
def _get_values_from_helper(self) -> None:
"""Update attributes that may get values from an external helper program"""
for attr in HELPER_ATTRIBUTES:
value = getattr(self, attr)
if not isinstance(value, str):
continue
if not value.lower().strip().startswith(HELPER_PREFIX):
continue
helper = value[len(HELPER_PREFIX) :].strip()
commmand = [expanduser(expandvars(token)) for token in shlex.split(helper)]
try:
value = (
subprocess.check_output(commmand, stderr=subprocess.PIPE)
.decode("utf-8")
.strip()
)
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode().strip()
raise GitlabConfigHelperError(
f"Failed to read {attr} value from helper "
f"for {self.gitlab_id}:\n{stderr}"
) from e
setattr(self, attr, value)

View File

@ -0,0 +1,169 @@
from enum import Enum, IntEnum
from gitlab._version import __title__, __version__
class GitlabEnum(str, Enum):
"""An enum mixed in with str to make it JSON-serializable."""
# https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/lib/gitlab/access.rb#L12-18
class AccessLevel(IntEnum):
NO_ACCESS: int = 0
MINIMAL_ACCESS: int = 5
GUEST: int = 10
PLANNER: int = 15
REPORTER: int = 20
DEVELOPER: int = 30
MAINTAINER: int = 40
OWNER: int = 50
ADMIN: int = 60
# https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/lib/gitlab/visibility_level.rb#L23-25
class Visibility(GitlabEnum):
PRIVATE: str = "private"
INTERNAL: str = "internal"
PUBLIC: str = "public"
class NotificationLevel(GitlabEnum):
DISABLED: str = "disabled"
PARTICIPATING: str = "participating"
WATCH: str = "watch"
GLOBAL: str = "global"
MENTION: str = "mention"
CUSTOM: str = "custom"
# https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/app/views/search/_category.html.haml#L10-37
class SearchScope(GitlabEnum):
# all scopes (global, group and project)
PROJECTS: str = "projects"
ISSUES: str = "issues"
MERGE_REQUESTS: str = "merge_requests"
MILESTONES: str = "milestones"
WIKI_BLOBS: str = "wiki_blobs"
COMMITS: str = "commits"
BLOBS: str = "blobs"
USERS: str = "users"
# specific global scope
GLOBAL_SNIPPET_TITLES: str = "snippet_titles"
# specific project scope
PROJECT_NOTES: str = "notes"
# https://docs.gitlab.com/ee/api/merge_requests.html#merge-status
class DetailedMergeStatus(GitlabEnum):
# possible values for the detailed_merge_status field of Merge Requests
BLOCKED_STATUS: str = "blocked_status"
BROKEN_STATUS: str = "broken_status"
CHECKING: str = "checking"
UNCHECKED: str = "unchecked"
CI_MUST_PASS: str = "ci_must_pass"
CI_STILL_RUNNING: str = "ci_still_running"
DISCUSSIONS_NOT_RESOLVED: str = "discussions_not_resolved"
DRAFT_STATUS: str = "draft_status"
EXTERNAL_STATUS_CHECKS: str = "external_status_checks"
MERGEABLE: str = "mergeable"
NOT_APPROVED: str = "not_approved"
NOT_OPEN: str = "not_open"
POLICIES_DENIED: str = "policies_denied"
# https://docs.gitlab.com/ee/api/pipelines.html
class PipelineStatus(GitlabEnum):
CREATED: str = "created"
WAITING_FOR_RESOURCE: str = "waiting_for_resource"
PREPARING: str = "preparing"
PENDING: str = "pending"
RUNNING: str = "running"
SUCCESS: str = "success"
FAILED: str = "failed"
CANCELED: str = "canceled"
SKIPPED: str = "skipped"
MANUAL: str = "manual"
SCHEDULED: str = "scheduled"
DEFAULT_URL: str = "https://gitlab.com"
NO_ACCESS = AccessLevel.NO_ACCESS.value
MINIMAL_ACCESS = AccessLevel.MINIMAL_ACCESS.value
GUEST_ACCESS = AccessLevel.GUEST.value
REPORTER_ACCESS = AccessLevel.REPORTER.value
DEVELOPER_ACCESS = AccessLevel.DEVELOPER.value
MAINTAINER_ACCESS = AccessLevel.MAINTAINER.value
OWNER_ACCESS = AccessLevel.OWNER.value
ADMIN_ACCESS = AccessLevel.ADMIN.value
VISIBILITY_PRIVATE = Visibility.PRIVATE.value
VISIBILITY_INTERNAL = Visibility.INTERNAL.value
VISIBILITY_PUBLIC = Visibility.PUBLIC.value
NOTIFICATION_LEVEL_DISABLED = NotificationLevel.DISABLED.value
NOTIFICATION_LEVEL_PARTICIPATING = NotificationLevel.PARTICIPATING.value
NOTIFICATION_LEVEL_WATCH = NotificationLevel.WATCH.value
NOTIFICATION_LEVEL_GLOBAL = NotificationLevel.GLOBAL.value
NOTIFICATION_LEVEL_MENTION = NotificationLevel.MENTION.value
NOTIFICATION_LEVEL_CUSTOM = NotificationLevel.CUSTOM.value
# Search scopes
# all scopes (global, group and project)
SEARCH_SCOPE_PROJECTS = SearchScope.PROJECTS.value
SEARCH_SCOPE_ISSUES = SearchScope.ISSUES.value
SEARCH_SCOPE_MERGE_REQUESTS = SearchScope.MERGE_REQUESTS.value
SEARCH_SCOPE_MILESTONES = SearchScope.MILESTONES.value
SEARCH_SCOPE_WIKI_BLOBS = SearchScope.WIKI_BLOBS.value
SEARCH_SCOPE_COMMITS = SearchScope.COMMITS.value
SEARCH_SCOPE_BLOBS = SearchScope.BLOBS.value
SEARCH_SCOPE_USERS = SearchScope.USERS.value
# specific global scope
SEARCH_SCOPE_GLOBAL_SNIPPET_TITLES = SearchScope.GLOBAL_SNIPPET_TITLES.value
# specific project scope
SEARCH_SCOPE_PROJECT_NOTES = SearchScope.PROJECT_NOTES.value
USER_AGENT: str = f"{__title__}/{__version__}"
NO_JSON_RESPONSE_CODES = [204]
RETRYABLE_TRANSIENT_ERROR_CODES = [500, 502, 503, 504] + list(range(520, 531))
__all__ = [
"AccessLevel",
"Visibility",
"NotificationLevel",
"SearchScope",
"ADMIN_ACCESS",
"DEFAULT_URL",
"DEVELOPER_ACCESS",
"GUEST_ACCESS",
"MAINTAINER_ACCESS",
"MINIMAL_ACCESS",
"NO_ACCESS",
"NOTIFICATION_LEVEL_CUSTOM",
"NOTIFICATION_LEVEL_DISABLED",
"NOTIFICATION_LEVEL_GLOBAL",
"NOTIFICATION_LEVEL_MENTION",
"NOTIFICATION_LEVEL_PARTICIPATING",
"NOTIFICATION_LEVEL_WATCH",
"OWNER_ACCESS",
"REPORTER_ACCESS",
"SEARCH_SCOPE_BLOBS",
"SEARCH_SCOPE_COMMITS",
"SEARCH_SCOPE_GLOBAL_SNIPPET_TITLES",
"SEARCH_SCOPE_ISSUES",
"SEARCH_SCOPE_MERGE_REQUESTS",
"SEARCH_SCOPE_MILESTONES",
"SEARCH_SCOPE_PROJECT_NOTES",
"SEARCH_SCOPE_PROJECTS",
"SEARCH_SCOPE_USERS",
"SEARCH_SCOPE_WIKI_BLOBS",
"USER_AGENT",
"VISIBILITY_INTERNAL",
"VISIBILITY_PRIVATE",
"VISIBILITY_PUBLIC",
]

View File

@ -0,0 +1,428 @@
import functools
from typing import Any, Callable, cast, Optional, Type, TYPE_CHECKING, TypeVar, Union
class GitlabError(Exception):
def __init__(
self,
error_message: Union[str, bytes] = "",
response_code: Optional[int] = None,
response_body: Optional[bytes] = None,
) -> None:
Exception.__init__(self, error_message)
# Http status code
self.response_code = response_code
# Full http response
self.response_body = response_body
# Parsed error message from gitlab
try:
# if we receive str/bytes we try to convert to unicode/str to have
# consistent message types (see #616)
if TYPE_CHECKING:
assert isinstance(error_message, bytes)
self.error_message = error_message.decode()
except Exception:
if TYPE_CHECKING:
assert isinstance(error_message, str)
self.error_message = error_message
def __str__(self) -> str:
if self.response_code is not None:
return f"{self.response_code}: {self.error_message}"
return f"{self.error_message}"
class GitlabAuthenticationError(GitlabError):
pass
class RedirectError(GitlabError):
pass
class GitlabParsingError(GitlabError):
pass
class GitlabCiLintError(GitlabError):
pass
class GitlabConnectionError(GitlabError):
pass
class GitlabOperationError(GitlabError):
pass
class GitlabHttpError(GitlabError):
pass
class GitlabListError(GitlabOperationError):
pass
class GitlabGetError(GitlabOperationError):
pass
class GitlabHeadError(GitlabOperationError):
pass
class GitlabCreateError(GitlabOperationError):
pass
class GitlabUpdateError(GitlabOperationError):
pass
class GitlabDeleteError(GitlabOperationError):
pass
class GitlabSetError(GitlabOperationError):
pass
class GitlabProtectError(GitlabOperationError):
pass
class GitlabTransferProjectError(GitlabOperationError):
pass
class GitlabGroupTransferError(GitlabOperationError):
pass
class GitlabProjectDeployKeyError(GitlabOperationError):
pass
class GitlabPromoteError(GitlabOperationError):
pass
class GitlabCancelError(GitlabOperationError):
pass
class GitlabPipelineCancelError(GitlabCancelError):
pass
class GitlabRetryError(GitlabOperationError):
pass
class GitlabBuildCancelError(GitlabCancelError):
pass
class GitlabBuildRetryError(GitlabRetryError):
pass
class GitlabBuildPlayError(GitlabRetryError):
pass
class GitlabBuildEraseError(GitlabRetryError):
pass
class GitlabJobCancelError(GitlabCancelError):
pass
class GitlabJobRetryError(GitlabRetryError):
pass
class GitlabJobPlayError(GitlabRetryError):
pass
class GitlabJobEraseError(GitlabRetryError):
pass
class GitlabPipelinePlayError(GitlabRetryError):
pass
class GitlabPipelineRetryError(GitlabRetryError):
pass
class GitlabBlockError(GitlabOperationError):
pass
class GitlabUnblockError(GitlabOperationError):
pass
class GitlabDeactivateError(GitlabOperationError):
pass
class GitlabActivateError(GitlabOperationError):
pass
class GitlabBanError(GitlabOperationError):
pass
class GitlabUnbanError(GitlabOperationError):
pass
class GitlabSubscribeError(GitlabOperationError):
pass
class GitlabUnsubscribeError(GitlabOperationError):
pass
class GitlabMRForbiddenError(GitlabOperationError):
pass
class GitlabMRApprovalError(GitlabOperationError):
pass
class GitlabMRRebaseError(GitlabOperationError):
pass
class GitlabMRResetApprovalError(GitlabOperationError):
pass
class GitlabMRClosedError(GitlabOperationError):
pass
class GitlabMROnBuildSuccessError(GitlabOperationError):
pass
class GitlabTodoError(GitlabOperationError):
pass
class GitlabTopicMergeError(GitlabOperationError):
pass
class GitlabTimeTrackingError(GitlabOperationError):
pass
class GitlabUploadError(GitlabOperationError):
pass
class GitlabAttachFileError(GitlabOperationError):
pass
class GitlabImportError(GitlabOperationError):
pass
class GitlabInvitationError(GitlabOperationError):
pass
class GitlabCherryPickError(GitlabOperationError):
pass
class GitlabHousekeepingError(GitlabOperationError):
pass
class GitlabOwnershipError(GitlabOperationError):
pass
class GitlabSearchError(GitlabOperationError):
pass
class GitlabStopError(GitlabOperationError):
pass
class GitlabMarkdownError(GitlabOperationError):
pass
class GitlabVerifyError(GitlabOperationError):
pass
class GitlabRenderError(GitlabOperationError):
pass
class GitlabRepairError(GitlabOperationError):
pass
class GitlabRestoreError(GitlabOperationError):
pass
class GitlabRevertError(GitlabOperationError):
pass
class GitlabRotateError(GitlabOperationError):
pass
class GitlabLicenseError(GitlabOperationError):
pass
class GitlabFollowError(GitlabOperationError):
pass
class GitlabUnfollowError(GitlabOperationError):
pass
class GitlabUserApproveError(GitlabOperationError):
pass
class GitlabUserRejectError(GitlabOperationError):
pass
class GitlabDeploymentApprovalError(GitlabOperationError):
pass
class GitlabHookTestError(GitlabOperationError):
pass
# For an explanation of how these type-hints work see:
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
#
# The goal here is that functions which get decorated will retain their types.
__F = TypeVar("__F", bound=Callable[..., Any])
def on_http_error(error: Type[Exception]) -> Callable[[__F], __F]:
"""Manage GitlabHttpError exceptions.
This decorator function can be used to catch GitlabHttpError exceptions
raise specialized exceptions instead.
Args:
The exception type to raise -- must inherit from GitlabError
"""
def wrap(f: __F) -> __F:
@functools.wraps(f)
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
try:
return f(*args, **kwargs)
except GitlabHttpError as e:
raise error(e.error_message, e.response_code, e.response_body) from e
return cast(__F, wrapped_f)
return wrap
# Export manually to keep mypy happy
__all__ = [
"GitlabActivateError",
"GitlabAttachFileError",
"GitlabAuthenticationError",
"GitlabBanError",
"GitlabBlockError",
"GitlabBuildCancelError",
"GitlabBuildEraseError",
"GitlabBuildPlayError",
"GitlabBuildRetryError",
"GitlabCancelError",
"GitlabCherryPickError",
"GitlabCiLintError",
"GitlabConnectionError",
"GitlabCreateError",
"GitlabDeactivateError",
"GitlabDeleteError",
"GitlabDeploymentApprovalError",
"GitlabError",
"GitlabFollowError",
"GitlabGetError",
"GitlabGroupTransferError",
"GitlabHeadError",
"GitlabHookTestError",
"GitlabHousekeepingError",
"GitlabHttpError",
"GitlabImportError",
"GitlabInvitationError",
"GitlabJobCancelError",
"GitlabJobEraseError",
"GitlabJobPlayError",
"GitlabJobRetryError",
"GitlabLicenseError",
"GitlabListError",
"GitlabMRApprovalError",
"GitlabMRClosedError",
"GitlabMRForbiddenError",
"GitlabMROnBuildSuccessError",
"GitlabMRRebaseError",
"GitlabMRResetApprovalError",
"GitlabMarkdownError",
"GitlabOperationError",
"GitlabOwnershipError",
"GitlabParsingError",
"GitlabPipelineCancelError",
"GitlabPipelinePlayError",
"GitlabPipelineRetryError",
"GitlabProjectDeployKeyError",
"GitlabPromoteError",
"GitlabProtectError",
"GitlabRenderError",
"GitlabRepairError",
"GitlabRestoreError",
"GitlabRetryError",
"GitlabRevertError",
"GitlabRotateError",
"GitlabSearchError",
"GitlabSetError",
"GitlabStopError",
"GitlabSubscribeError",
"GitlabTimeTrackingError",
"GitlabTodoError",
"GitlabTopicMergeError",
"GitlabTransferProjectError",
"GitlabUnbanError",
"GitlabUnblockError",
"GitlabUnfollowError",
"GitlabUnsubscribeError",
"GitlabUpdateError",
"GitlabUploadError",
"GitlabUserApproveError",
"GitlabUserRejectError",
"GitlabVerifyError",
"RedirectError",
]

File diff suppressed because it is too large Load Diff

View File

View File

@ -0,0 +1,105 @@
import dataclasses
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
@dataclasses.dataclass(frozen=True)
class RequiredOptional:
required: Tuple[str, ...] = ()
optional: Tuple[str, ...] = ()
exclusive: Tuple[str, ...] = ()
def validate_attrs(
self,
*,
data: Dict[str, Any],
excludes: Optional[List[str]] = None,
) -> None:
if excludes is None:
excludes = []
if self.required:
required = [k for k in self.required if k not in excludes]
missing = [attr for attr in required if attr not in data]
if missing:
raise AttributeError(f"Missing attributes: {', '.join(missing)}")
if self.exclusive:
exclusives = [attr for attr in data if attr in self.exclusive]
if len(exclusives) > 1:
raise AttributeError(
f"Provide only one of these attributes: {', '.join(exclusives)}"
)
if not exclusives:
raise AttributeError(
f"Must provide one of these attributes: "
f"{', '.join(self.exclusive)}"
)
class GitlabAttribute:
def __init__(self, value: Any = None) -> None:
self._value = value
def get(self) -> Any:
return self._value
def set_from_cli(self, cli_value: Any) -> None:
self._value = cli_value
def get_for_api(self, *, key: str) -> Tuple[str, Any]:
return (key, self._value)
class _ListArrayAttribute(GitlabAttribute):
"""Helper class to support `list` / `array` types."""
def set_from_cli(self, cli_value: str) -> None:
if not cli_value.strip():
self._value = []
else:
self._value = [item.strip() for item in cli_value.split(",")]
def get_for_api(self, *, key: str) -> Tuple[str, str]:
# Do not comma-split single value passed as string
if isinstance(self._value, str):
return (key, self._value)
if TYPE_CHECKING:
assert isinstance(self._value, list)
return (key, ",".join([str(x) for x in self._value]))
class ArrayAttribute(_ListArrayAttribute):
"""To support `array` types as documented in
https://docs.gitlab.com/ee/api/#array"""
def get_for_api(self, *, key: str) -> Tuple[str, Any]:
if isinstance(self._value, str):
return (f"{key}[]", self._value)
if TYPE_CHECKING:
assert isinstance(self._value, list)
return (f"{key}[]", self._value)
class CommaSeparatedListAttribute(_ListArrayAttribute):
"""For values which are sent to the server as a Comma Separated Values
(CSV) string. We allow them to be specified as a list and we convert it
into a CSV"""
class LowercaseStringAttribute(GitlabAttribute):
def get_for_api(self, *, key: str) -> Tuple[str, str]:
return (key, str(self._value).lower())
class FileAttribute(GitlabAttribute):
@staticmethod
def get_file_name(attr_name: Optional[str] = None) -> Optional[str]:
return attr_name
class ImageAttribute(FileAttribute):
@staticmethod
def get_file_name(attr_name: Optional[str] = None) -> str:
return f"{attr_name}.png" if attr_name else "image.png"

View File

@ -0,0 +1,303 @@
import dataclasses
import email.message
import logging
import pathlib
import time
import traceback
import urllib.parse
import warnings
from typing import (
Any,
Callable,
Dict,
Iterator,
Literal,
MutableMapping,
Optional,
Tuple,
Type,
Union,
)
import requests
from gitlab import const, types
class _StdoutStream:
def __call__(self, chunk: Any) -> None:
print(chunk)
def get_base_url(url: Optional[str] = None) -> str:
"""Return the base URL with the trailing slash stripped.
If the URL is a Falsy value, return the default URL.
Returns:
The base URL
"""
if not url:
return const.DEFAULT_URL
return url.rstrip("/")
def get_content_type(content_type: Optional[str]) -> str:
message = email.message.Message()
if content_type is not None:
message["content-type"] = content_type
return message.get_content_type()
class MaskingFormatter(logging.Formatter):
"""A logging formatter that can mask credentials"""
def __init__(
self,
fmt: Optional[str] = logging.BASIC_FORMAT,
datefmt: Optional[str] = None,
style: Literal["%", "{", "$"] = "%",
validate: bool = True,
masked: Optional[str] = None,
) -> None:
super().__init__(fmt, datefmt, style, validate)
self.masked = masked
def _filter(self, entry: str) -> str:
if not self.masked:
return entry
return entry.replace(self.masked, "[MASKED]")
def format(self, record: logging.LogRecord) -> str:
original = logging.Formatter.format(self, record)
return self._filter(original)
def response_content(
response: requests.Response,
streamed: bool,
action: Optional[Callable[[bytes], None]],
chunk_size: int,
*,
iterator: bool,
) -> Optional[Union[bytes, Iterator[Any]]]:
if iterator:
return response.iter_content(chunk_size=chunk_size)
if streamed is False:
return response.content
if action is None:
action = _StdoutStream()
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
action(chunk)
return None
class Retry:
def __init__(
self,
max_retries: int,
obey_rate_limit: Optional[bool] = True,
retry_transient_errors: Optional[bool] = False,
) -> None:
self.cur_retries = 0
self.max_retries = max_retries
self.obey_rate_limit = obey_rate_limit
self.retry_transient_errors = retry_transient_errors
def _retryable_status_code(
self, status_code: Optional[int], reason: str = ""
) -> bool:
if status_code == 429 and self.obey_rate_limit:
return True
if not self.retry_transient_errors:
return False
if status_code in const.RETRYABLE_TRANSIENT_ERROR_CODES:
return True
if status_code == 409 and "Resource lock" in reason:
return True
return False
def handle_retry_on_status(
self,
status_code: Optional[int],
headers: Optional[MutableMapping[str, str]] = None,
reason: str = "",
) -> bool:
if not self._retryable_status_code(status_code, reason):
return False
if headers is None:
headers = {}
# Response headers documentation:
# https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#response-headers
if self.max_retries == -1 or self.cur_retries < self.max_retries:
wait_time = 2**self.cur_retries * 0.1
if "Retry-After" in headers:
wait_time = int(headers["Retry-After"])
elif "RateLimit-Reset" in headers:
wait_time = int(headers["RateLimit-Reset"]) - time.time()
self.cur_retries += 1
time.sleep(wait_time)
return True
return False
def handle_retry(self) -> bool:
if self.retry_transient_errors and (
self.max_retries == -1 or self.cur_retries < self.max_retries
):
wait_time = 2**self.cur_retries * 0.1
self.cur_retries += 1
time.sleep(wait_time)
return True
return False
def _transform_types(
data: Dict[str, Any],
custom_types: Dict[str, Any],
*,
transform_data: bool,
transform_files: Optional[bool] = True,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Copy the data dict with attributes that have custom types and transform them
before being sent to the server.
``transform_files``: If ``True`` (default), also populates the ``files`` dict for
FileAttribute types with tuples to prepare fields for requests' MultipartEncoder:
https://toolbelt.readthedocs.io/en/latest/user.html#multipart-form-data-encoder
``transform_data``: If ``True`` transforms the ``data`` dict with fields
suitable for encoding as query parameters for GitLab's API:
https://docs.gitlab.com/ee/api/#encoding-api-parameters-of-array-and-hash-types
Returns:
A tuple of the transformed data dict and files dict"""
# Duplicate data to avoid messing with what the user sent us
data = data.copy()
if not transform_files and not transform_data:
return data, {}
files = {}
for attr_name, attr_class in custom_types.items():
if attr_name not in data:
continue
gitlab_attribute = attr_class(data[attr_name])
# if the type is FileAttribute we need to pass the data as file
if isinstance(gitlab_attribute, types.FileAttribute) and transform_files:
key = gitlab_attribute.get_file_name(attr_name)
files[attr_name] = (key, data.pop(attr_name))
continue
if not transform_data:
continue
if isinstance(gitlab_attribute, types.GitlabAttribute):
key, value = gitlab_attribute.get_for_api(key=attr_name)
if key != attr_name:
del data[attr_name]
data[key] = value
return data, files
def copy_dict(
*,
src: Dict[str, Any],
dest: Dict[str, Any],
) -> None:
for k, v in src.items():
if isinstance(v, dict):
# NOTE(jlvillal): This provides some support for the `hash` type
# https://docs.gitlab.com/ee/api/#hash
# Transform dict values to new attributes. For example:
# custom_attributes: {'foo', 'bar'} =>
# "custom_attributes['foo']": "bar"
for dict_k, dict_v in v.items():
dest[f"{k}[{dict_k}]"] = dict_v
else:
dest[k] = v
class EncodedId(str):
"""A custom `str` class that will return the URL-encoded value of the string.
* Using it recursively will only url-encode the value once.
* Can accept either `str` or `int` as input value.
* Can be used in an f-string and output the URL-encoded string.
Reference to documentation on why this is necessary.
See::
https://docs.gitlab.com/ee/api/index.html#namespaced-path-encoding
https://docs.gitlab.com/ee/api/index.html#path-parameters
"""
def __new__(cls, value: Union[str, int, "EncodedId"]) -> "EncodedId":
if isinstance(value, EncodedId):
return value
if not isinstance(value, (int, str)):
raise TypeError(f"Unsupported type received: {type(value)}")
if isinstance(value, str):
value = urllib.parse.quote(value, safe="")
return super().__new__(cls, value)
def remove_none_from_dict(data: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in data.items() if v is not None}
def warn(
message: str,
*,
category: Optional[Type[Warning]] = None,
source: Optional[Any] = None,
show_caller: bool = True,
) -> None:
"""This `warnings.warn` wrapper function attempts to show the location causing the
warning in the user code that called the library.
It does this by walking up the stack trace to find the first frame located outside
the `gitlab/` directory. This is helpful to users as it shows them their code that
is causing the warning.
"""
# Get `stacklevel` for user code so we indicate where issue is in
# their code.
pg_dir = pathlib.Path(__file__).parent.resolve()
stack = traceback.extract_stack()
stacklevel = 1
warning_from = ""
for stacklevel, frame in enumerate(reversed(stack), start=1):
warning_from = f" (python-gitlab: {frame.filename}:{frame.lineno})"
frame_dir = str(pathlib.Path(frame.filename).parent.resolve())
if not frame_dir.startswith(str(pg_dir)):
break
if show_caller:
message += warning_from
warnings.warn(
message=message,
category=category,
stacklevel=stacklevel,
source=source,
)
@dataclasses.dataclass
class WarnMessageData:
message: str
show_caller: bool

View File

View File

@ -0,0 +1,605 @@
import argparse
import json
import operator
import sys
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING, Union
import gitlab
import gitlab.base
import gitlab.v4.objects
from gitlab import cli
from gitlab.exceptions import GitlabCiLintError
class GitlabCLI:
def __init__(
self,
gl: gitlab.Gitlab,
gitlab_resource: str,
resource_action: str,
args: Dict[str, str],
) -> None:
self.cls: Type[gitlab.base.RESTObject] = cli.gitlab_resource_to_cls(
gitlab_resource, namespace=gitlab.v4.objects
)
self.cls_name = self.cls.__name__
self.gitlab_resource = gitlab_resource.replace("-", "_")
self.resource_action = resource_action.lower()
self.gl = gl
self.args = args
self.parent_args: Dict[str, Any] = {}
self.mgr_cls: Union[
Type[gitlab.mixins.CreateMixin],
Type[gitlab.mixins.DeleteMixin],
Type[gitlab.mixins.GetMixin],
Type[gitlab.mixins.GetWithoutIdMixin],
Type[gitlab.mixins.ListMixin],
Type[gitlab.mixins.UpdateMixin],
] = getattr(gitlab.v4.objects, f"{self.cls.__name__}Manager")
# We could do something smart, like splitting the manager name to find
# parents, build the chain of managers to get to the final object.
# Instead we do something ugly and efficient: interpolate variables in
# the class _path attribute, and replace the value with the result.
if TYPE_CHECKING:
assert self.mgr_cls._path is not None
self._process_from_parent_attrs()
self.mgr_cls._path = self.mgr_cls._path.format(**self.parent_args)
self.mgr = self.mgr_cls(gl)
self.mgr._from_parent_attrs = self.parent_args
if self.mgr_cls._types:
for attr_name, type_cls in self.mgr_cls._types.items():
if attr_name in self.args.keys():
obj = type_cls()
obj.set_from_cli(self.args[attr_name])
self.args[attr_name] = obj.get()
def _process_from_parent_attrs(self) -> None:
"""Items in the path need to be url-encoded. There is a 1:1 mapping from
mgr_cls._from_parent_attrs <--> mgr_cls._path. Those values must be url-encoded
as they may contain a slash '/'."""
for key in self.mgr_cls._from_parent_attrs:
if key not in self.args:
continue
self.parent_args[key] = gitlab.utils.EncodedId(self.args[key])
# If we don't delete it then it will be added to the URL as a query-string
del self.args[key]
def run(self) -> Any:
# Check for a method that matches gitlab_resource + action
method = f"do_{self.gitlab_resource}_{self.resource_action}"
if hasattr(self, method):
return getattr(self, method)()
# Fallback to standard actions (get, list, create, ...)
method = f"do_{self.resource_action}"
if hasattr(self, method):
return getattr(self, method)()
# Finally try to find custom methods
return self.do_custom()
def do_custom(self) -> Any:
class_instance: Union[gitlab.base.RESTManager, gitlab.base.RESTObject]
in_obj = cli.custom_actions[self.cls_name][self.resource_action].in_object
# Get the object (lazy), then act
if in_obj:
data = {}
if self.mgr._from_parent_attrs:
for k in self.mgr._from_parent_attrs:
data[k] = self.parent_args[k]
if not issubclass(self.cls, gitlab.mixins.GetWithoutIdMixin):
if TYPE_CHECKING:
assert isinstance(self.cls._id_attr, str)
data[self.cls._id_attr] = self.args.pop(self.cls._id_attr)
class_instance = self.cls(self.mgr, data)
else:
class_instance = self.mgr
method_name = self.resource_action.replace("-", "_")
return getattr(class_instance, method_name)(**self.args)
def do_project_export_download(self) -> None:
try:
project = self.gl.projects.get(self.parent_args["project_id"], lazy=True)
export_status = project.exports.get()
if TYPE_CHECKING:
assert export_status is not None
data = export_status.download()
if TYPE_CHECKING:
assert data is not None
assert isinstance(data, bytes)
sys.stdout.buffer.write(data)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to download the export", e)
def do_validate(self) -> None:
if TYPE_CHECKING:
assert isinstance(self.mgr, gitlab.v4.objects.CiLintManager)
try:
self.mgr.validate(self.args)
except GitlabCiLintError as e: # pragma: no cover, cli.die is unit-tested
cli.die("CI YAML Lint failed", e)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Cannot validate CI YAML", e)
def do_create(self) -> gitlab.base.RESTObject:
if TYPE_CHECKING:
assert isinstance(self.mgr, gitlab.mixins.CreateMixin)
try:
result = self.mgr.create(self.args)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to create object", e)
return result
def do_list(
self,
) -> Union[gitlab.base.RESTObjectList, List[gitlab.base.RESTObject]]:
if TYPE_CHECKING:
assert isinstance(self.mgr, gitlab.mixins.ListMixin)
message_details = gitlab.utils.WarnMessageData(
message=(
"Your query returned {len_items} of {total_items} items. To return all "
"items use `--get-all`. To silence this warning use `--no-get-all`."
),
show_caller=False,
)
try:
result = self.mgr.list(**self.args, message_details=message_details)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to list objects", e)
return result
def do_get(self) -> Optional[gitlab.base.RESTObject]:
if isinstance(self.mgr, gitlab.mixins.GetWithoutIdMixin):
try:
result = self.mgr.get(id=None, **self.args)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to get object", e)
return result
if TYPE_CHECKING:
assert isinstance(self.mgr, gitlab.mixins.GetMixin)
assert isinstance(self.cls._id_attr, str)
id = self.args.pop(self.cls._id_attr)
try:
result = self.mgr.get(id, lazy=False, **self.args)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to get object", e)
return result
def do_delete(self) -> None:
if TYPE_CHECKING:
assert isinstance(self.mgr, gitlab.mixins.DeleteMixin)
assert isinstance(self.cls._id_attr, str)
id = self.args.pop(self.cls._id_attr)
try:
self.mgr.delete(id, **self.args)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to destroy object", e)
def do_update(self) -> Dict[str, Any]:
if TYPE_CHECKING:
assert isinstance(self.mgr, gitlab.mixins.UpdateMixin)
if issubclass(self.mgr_cls, gitlab.mixins.GetWithoutIdMixin):
id = None
else:
if TYPE_CHECKING:
assert isinstance(self.cls._id_attr, str)
id = self.args.pop(self.cls._id_attr)
try:
result = self.mgr.update(id, self.args)
except Exception as e: # pragma: no cover, cli.die is unit-tested
cli.die("Impossible to update object", e)
return result
# https://github.com/python/typeshed/issues/7539#issuecomment-1076581049
if TYPE_CHECKING:
_SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
else:
_SubparserType = Any
def _populate_sub_parser_by_class(
cls: Type[gitlab.base.RESTObject],
sub_parser: _SubparserType,
) -> None:
mgr_cls_name = f"{cls.__name__}Manager"
mgr_cls = getattr(gitlab.v4.objects, mgr_cls_name)
action_parsers: Dict[str, argparse.ArgumentParser] = {}
for action_name, help_text in [
("list", "List the GitLab resources"),
("get", "Get a GitLab resource"),
("create", "Create a GitLab resource"),
("update", "Update a GitLab resource"),
("delete", "Delete a GitLab resource"),
]:
if not hasattr(mgr_cls, action_name):
continue
sub_parser_action = sub_parser.add_parser(
action_name,
conflict_handler="resolve",
help=help_text,
)
action_parsers[action_name] = sub_parser_action
sub_parser_action.add_argument("--sudo", required=False)
if mgr_cls._from_parent_attrs:
for x in mgr_cls._from_parent_attrs:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
if action_name == "list":
for x in mgr_cls._list_filters:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=False
)
sub_parser_action.add_argument("--page", required=False, type=int)
sub_parser_action.add_argument("--per-page", required=False, type=int)
get_all_group = sub_parser_action.add_mutually_exclusive_group()
get_all_group.add_argument(
"--get-all",
required=False,
action="store_const",
const=True,
default=None,
dest="get_all",
help="Return all items from the server, without pagination.",
)
get_all_group.add_argument(
"--no-get-all",
required=False,
action="store_const",
const=False,
default=None,
dest="get_all",
help="Don't return all items from the server.",
)
if action_name == "delete":
if cls._id_attr is not None:
id_attr = cls._id_attr.replace("_", "-")
sub_parser_action.add_argument(f"--{id_attr}", required=True)
if action_name == "get":
if not issubclass(cls, gitlab.mixins.GetWithoutIdMixin):
if cls._id_attr is not None:
id_attr = cls._id_attr.replace("_", "-")
sub_parser_action.add_argument(f"--{id_attr}", required=True)
for x in mgr_cls._optional_get_attrs:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=False
)
if action_name == "create":
for x in mgr_cls._create_attrs.required:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
for x in mgr_cls._create_attrs.optional:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=False
)
if mgr_cls._create_attrs.exclusive:
group = sub_parser_action.add_mutually_exclusive_group()
for x in mgr_cls._create_attrs.exclusive:
group.add_argument(f"--{x.replace('_', '-')}")
if action_name == "update":
if cls._id_attr is not None:
id_attr = cls._id_attr.replace("_", "-")
sub_parser_action.add_argument(f"--{id_attr}", required=True)
for x in mgr_cls._update_attrs.required:
if x != cls._id_attr:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
for x in mgr_cls._update_attrs.optional:
if x != cls._id_attr:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=False
)
if mgr_cls._update_attrs.exclusive:
group = sub_parser_action.add_mutually_exclusive_group()
for x in mgr_cls._update_attrs.exclusive:
group.add_argument(f"--{x.replace('_', '-')}")
if cls.__name__ in cli.custom_actions:
name = cls.__name__
for action_name in cli.custom_actions[name]:
custom_action = cli.custom_actions[name][action_name]
# NOTE(jlvillal): If we put a function for the `default` value of
# the `get` it will always get called, which will break things.
action_parser = action_parsers.get(action_name)
if action_parser is None:
sub_parser_action = sub_parser.add_parser(
action_name, help=custom_action.help
)
else:
sub_parser_action = action_parser
# Get the attributes for URL/path construction
if mgr_cls._from_parent_attrs:
for x in mgr_cls._from_parent_attrs:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
sub_parser_action.add_argument("--sudo", required=False)
# We need to get the object somehow
if not issubclass(cls, gitlab.mixins.GetWithoutIdMixin):
if cls._id_attr is not None and custom_action.requires_id:
id_attr = cls._id_attr.replace("_", "-")
sub_parser_action.add_argument(f"--{id_attr}", required=True)
for x in custom_action.required:
if x != cls._id_attr:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
for x in custom_action.optional:
if x != cls._id_attr:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=False
)
if mgr_cls.__name__ in cli.custom_actions:
name = mgr_cls.__name__
for action_name in cli.custom_actions[name]:
# NOTE(jlvillal): If we put a function for the `default` value of
# the `get` it will always get called, which will break things.
action_parser = action_parsers.get(action_name)
if action_parser is None:
sub_parser_action = sub_parser.add_parser(action_name)
else:
sub_parser_action = action_parser
if mgr_cls._from_parent_attrs:
for x in mgr_cls._from_parent_attrs:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
sub_parser_action.add_argument("--sudo", required=False)
custom_action = cli.custom_actions[name][action_name]
for x in custom_action.required:
if x != cls._id_attr:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=True
)
for x in custom_action.optional:
if x != cls._id_attr:
sub_parser_action.add_argument(
f"--{x.replace('_', '-')}", required=False
)
def extend_parser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(
title="resource",
dest="gitlab_resource",
help="The GitLab resource to manipulate.",
)
subparsers.required = True
# populate argparse for all Gitlab Object
classes = set()
for cls in gitlab.v4.objects.__dict__.values():
if not isinstance(cls, type):
continue
if issubclass(cls, gitlab.base.RESTManager):
if cls._obj_cls is not None:
classes.add(cls._obj_cls)
for cls in sorted(classes, key=operator.attrgetter("__name__")):
arg_name = cli.cls_to_gitlab_resource(cls)
mgr_cls_name = f"{cls.__name__}Manager"
mgr_cls = getattr(gitlab.v4.objects, mgr_cls_name)
object_group = subparsers.add_parser(
arg_name,
help=f"API endpoint: {mgr_cls._path}",
)
object_subparsers = object_group.add_subparsers(
title="action",
dest="resource_action",
help="Action to execute on the GitLab resource.",
)
_populate_sub_parser_by_class(cls, object_subparsers)
object_subparsers.required = True
return parser
def get_dict(
obj: Union[str, Dict[str, Any], gitlab.base.RESTObject], fields: List[str]
) -> Union[str, Dict[str, Any]]:
if not isinstance(obj, gitlab.base.RESTObject):
return obj
if fields:
return {k: v for k, v in obj.attributes.items() if k in fields}
return obj.attributes
class JSONPrinter:
@staticmethod
def display(d: Union[str, Dict[str, Any]], **_kwargs: Any) -> None:
print(json.dumps(d))
@staticmethod
def display_list(
data: List[Union[str, Dict[str, Any], gitlab.base.RESTObject]],
fields: List[str],
**_kwargs: Any,
) -> None:
print(json.dumps([get_dict(obj, fields) for obj in data]))
class YAMLPrinter:
@staticmethod
def display(d: Union[str, Dict[str, Any]], **_kwargs: Any) -> None:
try:
import yaml # noqa
print(yaml.safe_dump(d, default_flow_style=False))
except ImportError:
sys.exit(
"PyYaml is not installed.\n"
"Install it with `pip install PyYaml` "
"to use the yaml output feature"
)
@staticmethod
def display_list(
data: List[Union[str, Dict[str, Any], gitlab.base.RESTObject]],
fields: List[str],
**_kwargs: Any,
) -> None:
try:
import yaml # noqa
print(
yaml.safe_dump(
[get_dict(obj, fields) for obj in data], default_flow_style=False
)
)
except ImportError:
sys.exit(
"PyYaml is not installed.\n"
"Install it with `pip install PyYaml` "
"to use the yaml output feature"
)
class LegacyPrinter:
def display(self, _d: Union[str, Dict[str, Any]], **kwargs: Any) -> None:
verbose = kwargs.get("verbose", False)
padding = kwargs.get("padding", 0)
obj: Optional[Union[Dict[str, Any], gitlab.base.RESTObject]] = kwargs.get("obj")
if TYPE_CHECKING:
assert obj is not None
def display_dict(d: Dict[str, Any], padding: int) -> None:
for k in sorted(d.keys()):
v = d[k]
if isinstance(v, dict):
print(f"{' ' * padding}{k.replace('_', '-')}:")
new_padding = padding + 2
self.display(v, verbose=True, padding=new_padding, obj=v)
continue
print(f"{' ' * padding}{k.replace('_', '-')}: {v}")
if verbose:
if isinstance(obj, dict):
display_dict(obj, padding)
return
# not a dict, we assume it's a RESTObject
if obj._id_attr:
id = getattr(obj, obj._id_attr, None)
print(f"{obj._id_attr}: {id}")
attrs = obj.attributes
if obj._id_attr:
attrs.pop(obj._id_attr)
display_dict(attrs, padding)
return
lines = []
if TYPE_CHECKING:
assert isinstance(obj, gitlab.base.RESTObject)
if obj._id_attr:
id = getattr(obj, obj._id_attr)
lines.append(f"{obj._id_attr.replace('_', '-')}: {id}")
if obj._repr_attr:
value = getattr(obj, obj._repr_attr, "None") or "None"
value = value.replace("\r", "").replace("\n", " ")
# If the attribute is a note (ProjectCommitComment) then we do
# some modifications to fit everything on one line
line = f"{obj._repr_attr}: {value}"
# ellipsize long lines (comments)
if len(line) > 79:
line = f"{line[:76]}..."
lines.append(line)
if lines:
print("\n".join(lines))
return
print(
f"No default fields to show for {obj!r}. "
f"Please use '--verbose' or the JSON/YAML formatters."
)
def display_list(
self,
data: List[Union[str, gitlab.base.RESTObject]],
fields: List[str],
**kwargs: Any,
) -> None:
verbose = kwargs.get("verbose", False)
for obj in data:
if isinstance(obj, gitlab.base.RESTObject):
self.display(get_dict(obj, fields), verbose=verbose, obj=obj)
else:
print(obj)
print("")
PRINTERS: Dict[
str, Union[Type[JSONPrinter], Type[LegacyPrinter], Type[YAMLPrinter]]
] = {
"json": JSONPrinter,
"legacy": LegacyPrinter,
"yaml": YAMLPrinter,
}
def run(
gl: gitlab.Gitlab,
gitlab_resource: str,
resource_action: str,
args: Dict[str, Any],
verbose: bool,
output: str,
fields: List[str],
) -> None:
g_cli = GitlabCLI(
gl=gl,
gitlab_resource=gitlab_resource,
resource_action=resource_action,
args=args,
)
data = g_cli.run()
printer: Union[JSONPrinter, LegacyPrinter, YAMLPrinter] = PRINTERS[output]()
if isinstance(data, dict):
printer.display(data, verbose=True, obj=data)
elif isinstance(data, list):
printer.display_list(data, fields, verbose=verbose)
elif isinstance(data, gitlab.base.RESTObjectList):
printer.display_list(list(data), fields, verbose=verbose)
elif isinstance(data, gitlab.base.RESTObject):
printer.display(get_dict(data, fields), verbose=verbose, obj=data)
elif isinstance(data, str):
print(data)
elif isinstance(data, bytes):
sys.stdout.buffer.write(data)
elif hasattr(data, "decode"):
print(data.decode())

View File

@ -0,0 +1,79 @@
from .access_requests import *
from .appearance import *
from .applications import *
from .artifacts import *
from .audit_events import *
from .award_emojis import *
from .badges import *
from .boards import *
from .branches import *
from .broadcast_messages import *
from .bulk_imports import *
from .ci_lint import *
from .cluster_agents import *
from .clusters import *
from .commits import *
from .container_registry import *
from .custom_attributes import *
from .deploy_keys import *
from .deploy_tokens import *
from .deployments import *
from .discussions import *
from .draft_notes import *
from .environments import *
from .epics import *
from .events import *
from .export_import import *
from .features import *
from .files import *
from .geo_nodes import *
from .group_access_tokens import *
from .groups import *
from .hooks import *
from .integrations import *
from .invitations import *
from .issues import *
from .iterations import *
from .job_token_scope import *
from .jobs import *
from .keys import *
from .labels import *
from .ldap import *
from .members import *
from .merge_request_approvals import *
from .merge_requests import *
from .merge_trains import *
from .milestones import *
from .namespaces import *
from .notes import *
from .notification_settings import *
from .package_protection_rules import *
from .packages import *
from .pages import *
from .personal_access_tokens import *
from .pipelines import *
from .project_access_tokens import *
from .projects import *
from .push_rules import *
from .registry_protection_rules import *
from .releases import *
from .repositories import *
from .resource_groups import *
from .reviewers import *
from .runners import *
from .secure_files import *
from .service_accounts import *
from .settings import *
from .sidekiq import *
from .snippets import *
from .statistics import *
from .tags import *
from .templates import *
from .todos import *
from .topics import *
from .triggers import *
from .users import *
from .variables import *
from .wikis import *
__all__ = [name for name in dir() if not name.startswith("_")]

Some files were not shown because too many files have changed in this diff Show More