Skip to content

Commit c60ae1f

Browse files
committed
feat(cli): allow options from args and environment variables
1 parent 170a4d9 commit c60ae1f

File tree

5 files changed

+288
-7
lines changed

5 files changed

+288
-7
lines changed

gitlab/cli.py

+92-4
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+
"--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,7 +336,7 @@ 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)
339+
gl = gitlab.Gitlab.merge_config(options, gitlab_id, config_files)
252340
if gl.private_token or gl.oauth_token or gl.job_token:
253341
gl.auth()
254342
except Exception as 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.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)

tests/unit/test_config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def test_default_config(mock_clean_env, monkeypatch):
148148
assert cp.retry_transient_errors is False
149149
assert cp.ssl_verify is True
150150
assert cp.timeout == 60
151-
assert cp.url == const.DEFAULT_URL
151+
assert cp.url is None
152152
assert cp.user_agent == const.USER_AGENT
153153

154154

tests/unit/test_gitlab_auth.py

+110
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from argparse import Namespace
2+
13
import pytest
24
import requests
35

46
from gitlab import Gitlab
7+
from gitlab.config import GitlabConfigParser
58

69

710
def test_invalid_auth_args():
@@ -83,3 +86,110 @@ def test_http_auth():
8386
assert isinstance(gl._http_auth, requests.auth.HTTPBasicAuth)
8487
assert gl.headers["PRIVATE-TOKEN"] == "private_token"
8588
assert "Authorization" not in gl.headers
89+
90+
91+
@pytest.mark.parametrize(
92+
"options,expected_private_token,expected_oauth_token,expected_job_token",
93+
[
94+
(
95+
Namespace(
96+
private_token="options-private-token",
97+
oauth_token="options-oauth-token",
98+
job_token="options-job-token",
99+
),
100+
"options-private-token",
101+
None,
102+
None,
103+
),
104+
(
105+
Namespace(
106+
private_token=None,
107+
oauth_token="options-oauth-token",
108+
job_token="option-job-token",
109+
),
110+
None,
111+
"options-oauth-token",
112+
None,
113+
),
114+
(
115+
Namespace(
116+
private_token=None, oauth_token=None, job_token="options-job-token"
117+
),
118+
None,
119+
None,
120+
"options-job-token",
121+
),
122+
],
123+
)
124+
def test_get_auth_from_env_with_options(
125+
options,
126+
expected_private_token,
127+
expected_oauth_token,
128+
expected_job_token,
129+
):
130+
cp = GitlabConfigParser()
131+
cp.private_token = None
132+
cp.oauth_token = None
133+
cp.job_token = None
134+
135+
private_token, oauth_token, job_token = Gitlab._get_auth_from_env(options, cp)
136+
assert private_token == expected_private_token
137+
assert oauth_token == expected_oauth_token
138+
assert job_token == expected_job_token
139+
140+
141+
@pytest.mark.parametrize(
142+
"config,expected_private_token,expected_oauth_token,expected_job_token",
143+
[
144+
(
145+
{
146+
"private_token": "config-private-token",
147+
"oauth_token": "config-oauth-token",
148+
"job_token": "config-job-token",
149+
},
150+
"config-private-token",
151+
None,
152+
None,
153+
),
154+
(
155+
{
156+
"private_token": None,
157+
"oauth_token": "config-oauth-token",
158+
"job_token": "config-job-token",
159+
},
160+
None,
161+
"config-oauth-token",
162+
None,
163+
),
164+
(
165+
{
166+
"private_token": None,
167+
"oauth_token": None,
168+
"job_token": "config-job-token",
169+
},
170+
None,
171+
None,
172+
"config-job-token",
173+
),
174+
],
175+
)
176+
def test_get_auth_from_env_with_config(
177+
config,
178+
expected_private_token,
179+
expected_oauth_token,
180+
expected_job_token,
181+
):
182+
options = Namespace(
183+
private_token=None,
184+
oauth_token=None,
185+
job_token=None,
186+
)
187+
cp = GitlabConfigParser()
188+
cp.private_token = config["private_token"]
189+
cp.oauth_token = config["oauth_token"]
190+
cp.job_token = config["job_token"]
191+
192+
private_token, oauth_token, job_token = Gitlab._get_auth_from_env(options, cp)
193+
assert private_token == expected_private_token
194+
assert oauth_token == expected_oauth_token
195+
assert job_token == expected_job_token

0 commit comments

Comments
 (0)