Skip to content

Commit 14487b3

Browse files
committed
feat(cli): allow options from args and environment variables
BREAKING-CHANGE: The gitlab CLI will now accept CLI arguments and environment variables for its global options in addition to configuration file options. This may change behavior for some workflows such as running inside GitLab CI and with certain environment variables configured.
1 parent a3eafab commit 14487b3

File tree

7 files changed

+405
-25
lines changed

7 files changed

+405
-25
lines changed

docs/cli-usage.rst

+50-7
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,60 @@
33
####################
44

55
``python-gitlab`` provides a :command:`gitlab` command-line tool to interact
6-
with GitLab servers. It uses a configuration file to define how to connect to
7-
the servers. Without a configuration file, ``gitlab`` will default to
8-
https://gitlab.com and unauthenticated requests.
6+
with GitLab servers.
7+
8+
This is especially convenient for running quick ad-hoc commands locally, easily
9+
interacting with the API inside GitLab CI, or with more advanced shell scripting
10+
when integrating with other tooling.
911

1012
.. _cli_configuration:
1113

1214
Configuration
1315
=============
1416

15-
Files
16-
-----
17+
``gitlab`` allows setting configuration options via command-line arguments,
18+
environment variables, and configuration files.
19+
20+
For a complete list of global CLI options and their environment variable
21+
equivalents, see :doc:`/cli-objects`.
22+
23+
With no configuration provided, ``gitlab`` will default to unauthenticated
24+
requests against `GitLab.com <https://gitlab.com>`__.
25+
26+
With no configuration but running inside a GitLab CI job, it will default to
27+
authenticated requests using the current job token against the current instance
28+
(via ``CI_SERVER_URL`` and ``CI_JOB_TOKEN`` environment variables).
29+
30+
.. warning::
31+
Please note the job token has very limited permissions and can only be used
32+
with certain endpoints. You may need to provide a personal access token instead.
33+
34+
When you provide configuration, values are evaluated with the following precedence:
35+
36+
1. Explicitly provided CLI arguments,
37+
2. Environment variables,
38+
3. Configuration files:
39+
40+
a. explicitly defined config files:
41+
42+
i. via the ``--config-file`` CLI argument,
43+
ii. via the ``PYTHON_GITLAB_CFG`` environment variable,
44+
45+
b. user-specific config file,
46+
c. system-level config file,
47+
48+
4. Environment variables always present in CI (CI_SERVER_URL, CI_JOB_TOKEN).
49+
50+
Additionally, authentication will take the following precedence
51+
when multiple options or environment variables are present:
52+
53+
1. Private token,
54+
2. OAuth token,
55+
3. CI job token.
56+
57+
58+
Configuration files
59+
-------------------
1760

1861
``gitlab`` looks up 3 configuration files by default:
1962

@@ -35,8 +78,8 @@ You can use a different configuration file with the ``--config-file`` option.
3578
If the environment variable is defined and the target file cannot be accessed,
3679
``gitlab`` will fail explicitly.
3780

38-
Content
39-
-------
81+
Configuration file format
82+
-------------------------
4083

4184
The configuration file uses the ``INI`` format. It contains at least a
4285
``[global]`` section, and a specific section for each GitLab server. For

gitlab/cli.py

+93-5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import argparse
2121
import functools
22+
import os
2223
import re
2324
import sys
2425
from types import ModuleType
@@ -112,17 +113,25 @@ def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser:
112113
"-v",
113114
"--verbose",
114115
"--fancy",
115-
help="Verbose mode (legacy format only)",
116+
help="Verbose mode (legacy format only) [env var: GITLAB_VERBOSE]",
116117
action="store_true",
118+
default=os.getenv("GITLAB_VERBOSE"),
117119
)
118120
parser.add_argument(
119-
"-d", "--debug", help="Debug mode (display HTTP requests)", action="store_true"
121+
"-d",
122+
"--debug",
123+
help="Debug mode (display HTTP requests) [env var: GITLAB_DEBUG]",
124+
action="store_true",
125+
default=os.getenv("GITLAB_DEBUG"),
120126
)
121127
parser.add_argument(
122128
"-c",
123129
"--config-file",
124130
action="append",
125-
help="Configuration file to use. Can be used multiple times.",
131+
help=(
132+
"Configuration file to use. Can be used multiple times. "
133+
"[env var: PYTHON_GITLAB_CFG]"
134+
),
126135
)
127136
parser.add_argument(
128137
"-g",
@@ -151,7 +160,86 @@ def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser:
151160
),
152161
required=False,
153162
)
163+
parser.add_argument(
164+
"--server-url",
165+
help=("GitLab server URL [env var: GITLAB_URL]"),
166+
required=False,
167+
default=os.getenv("GITLAB_URL"),
168+
)
169+
parser.add_argument(
170+
"--ssl-verify",
171+
help=(
172+
"Whether SSL certificates should be validated. [env var: GITLAB_SSL_VERIFY]"
173+
),
174+
required=False,
175+
default=os.getenv("GITLAB_SSL_VERIFY"),
176+
)
177+
parser.add_argument(
178+
"--timeout",
179+
help=(
180+
"Timeout to use for requests to the GitLab server. "
181+
"[env var: GITLAB_TIMEOUT]"
182+
),
183+
required=False,
184+
default=os.getenv("GITLAB_TIMEOUT"),
185+
)
186+
parser.add_argument(
187+
"--api-version",
188+
help=("GitLab API version [env var: GITLAB_API_VERSION]"),
189+
required=False,
190+
default=os.getenv("GITLAB_API_VERSION"),
191+
)
192+
parser.add_argument(
193+
"--per-page",
194+
help=(
195+
"Number of entries to return per page in the response. "
196+
"[env var: GITLAB_PER_PAGE]"
197+
),
198+
required=False,
199+
default=os.getenv("GITLAB_PER_PAGE"),
200+
)
201+
parser.add_argument(
202+
"--pagination",
203+
help=(
204+
"Whether to use keyset or offset pagination [env var: GITLAB_PAGINATION]"
205+
),
206+
required=False,
207+
default=os.getenv("GITLAB_PAGINATION"),
208+
)
209+
parser.add_argument(
210+
"--order-by",
211+
help=("Set order_by globally [env var: GITLAB_ORDER_BY]"),
212+
required=False,
213+
default=os.getenv("GITLAB_ORDER_BY"),
214+
)
215+
parser.add_argument(
216+
"--user-agent",
217+
help=(
218+
"The user agent to send to GitLab with the HTTP request. "
219+
"[env var: GITLAB_USER_AGENT]"
220+
),
221+
required=False,
222+
default=os.getenv("GITLAB_USER_AGENT"),
223+
)
154224

225+
tokens = parser.add_mutually_exclusive_group()
226+
tokens.add_argument(
227+
"--private-token",
228+
help=("GitLab private access token [env var: GITLAB_PRIVATE_TOKEN]"),
229+
required=False,
230+
default=os.getenv("GITLAB_PRIVATE_TOKEN"),
231+
)
232+
tokens.add_argument(
233+
"--oauth-token",
234+
help=("GitLab OAuth token [env var: GITLAB_OAUTH_TOKEN]"),
235+
required=False,
236+
default=os.getenv("GITLAB_OAUTH_TOKEN"),
237+
)
238+
tokens.add_argument(
239+
"--job-token",
240+
help=("GitLab CI job token [env var: CI_JOB_TOKEN]"),
241+
required=False,
242+
)
155243
return parser
156244

157245

@@ -248,8 +336,8 @@ def main() -> None:
248336
args_dict = {k: _parse_value(v) for k, v in args_dict.items() if v is not None}
249337

250338
try:
251-
gl = gitlab.Gitlab.from_config(gitlab_id, config_files)
252-
if gl.private_token or gl.oauth_token or gl.job_token:
339+
gl = gitlab.Gitlab.merge_config(options, gitlab_id, config_files)
340+
if gl.private_token or gl.oauth_token:
253341
gl.auth()
254342
except Exception as e:
255343
die(str(e))

gitlab/client.py

+83
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
"""Wrapper for the GitLab API."""
1818

19+
import os
1920
import time
21+
from argparse import Namespace
2022
from typing import Any, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union
2123

2224
import requests
@@ -256,6 +258,87 @@ def from_config(
256258
retry_transient_errors=config.retry_transient_errors,
257259
)
258260

261+
@classmethod
262+
def merge_config(
263+
cls,
264+
options: Namespace,
265+
gitlab_id: Optional[str] = None,
266+
config_files: Optional[List[str]] = None,
267+
) -> "Gitlab":
268+
"""Create a Gitlab connection by merging configuration with
269+
the following precedence:
270+
271+
1. Explicitly provided CLI arguments,
272+
2. Environment variables,
273+
3. Configuration files:
274+
a. explicitly defined config files:
275+
i. via the `--config-file` CLI argument,
276+
ii. via the `PYTHON_GITLAB_CFG` environment variable,
277+
b. user-specific config file,
278+
c. system-level config file,
279+
4. Environment variables always present in CI (CI_SERVER_URL, CI_JOB_TOKEN).
280+
281+
Args:
282+
options list[str]: List of options provided via the CLI.
283+
gitlab_id (str): ID of the configuration section.
284+
config_files list[str]: List of paths to configuration files.
285+
Returns:
286+
(gitlab.Gitlab): A Gitlab connection.
287+
288+
Raises:
289+
gitlab.config.GitlabDataError: If the configuration is not correct.
290+
"""
291+
config = gitlab.config.GitlabConfigParser(
292+
gitlab_id=gitlab_id, config_files=config_files
293+
)
294+
url = (
295+
options.server_url
296+
or config.url
297+
or os.getenv("CI_SERVER_URL")
298+
or gitlab.const.DEFAULT_URL
299+
)
300+
private_token, oauth_token, job_token = cls._get_auth_from_env(options, config)
301+
302+
return cls(
303+
url=url,
304+
private_token=private_token,
305+
oauth_token=oauth_token,
306+
job_token=job_token,
307+
ssl_verify=options.ssl_verify or config.ssl_verify,
308+
timeout=options.timeout or config.timeout,
309+
api_version=options.api_version or config.api_version,
310+
per_page=options.per_page or config.per_page,
311+
pagination=options.pagination or config.pagination,
312+
order_by=options.order_by or config.order_by,
313+
user_agent=options.user_agent or config.user_agent,
314+
)
315+
316+
@staticmethod
317+
def _get_auth_from_env(
318+
options: Namespace, config: gitlab.config.GitlabConfigParser
319+
) -> Tuple:
320+
"""
321+
Return a tuple where at most one of 3 token types ever has a value.
322+
Since multiple types of tokens may be present in the environment,
323+
options, or config files, this precedence ensures we don't
324+
inadvertently cause errors when initializing the client.
325+
326+
This is especially relevant when executed in CI where user and
327+
CI-provided values are both available.
328+
"""
329+
private_token = options.private_token or config.private_token
330+
oauth_token = options.oauth_token or config.oauth_token
331+
job_token = options.job_token or config.job_token or os.getenv("CI_JOB_TOKEN")
332+
333+
if private_token:
334+
return (private_token, None, None)
335+
if oauth_token:
336+
return (None, oauth_token, None)
337+
if job_token:
338+
return (None, None, job_token)
339+
340+
return (None, None, None)
341+
259342
def auth(self) -> None:
260343
"""Performs an authentication using private token.
261344

gitlab/config.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from pathlib import Path
2424
from typing import List, Optional, Union
2525

26-
from gitlab.const import DEFAULT_URL, USER_AGENT
26+
from gitlab.const import USER_AGENT
2727

2828
_DEFAULT_FILES: List[str] = [
2929
"/etc/python-gitlab.cfg",
@@ -119,7 +119,7 @@ def __init__(
119119
self.retry_transient_errors: bool = False
120120
self.ssl_verify: Union[bool, str] = True
121121
self.timeout: int = 60
122-
self.url: str = DEFAULT_URL
122+
self.url: Optional[str] = None
123123
self.user_agent: str = USER_AGENT
124124

125125
self._files = _get_config_files(config_files)

0 commit comments

Comments
 (0)