421 lines
12 KiB
Python
421 lines
12 KiB
Python
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
|
|
)
|