Skip to content

Commit 92a893b

Browse files
committed
feat(cli): do not require config file to run CLI
BREAKING CHANGE: A config file is no longer needed to run the CLI. python-gitlab will default to https://gitlab.com with no authentication if there is no config file provided. python-gitlab will now also only look for configuration in the provided PYTHON_GITLAB_CFG path, instead of merging it with user- and system-wide config files. If the environment variable is defined and the file cannot be opened, python-gitlab will now explicitly fail.
1 parent af33aff commit 92a893b

File tree

6 files changed

+234
-116
lines changed

6 files changed

+234
-116
lines changed

.pre-commit-config.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ repos:
2626
- id: pylint
2727
additional_dependencies:
2828
- argcomplete==1.12.3
29+
- pytest==6.2.5
2930
- requests==2.26.0
3031
- requests-toolbelt==0.9.1
3132
files: 'gitlab/'

docs/cli-usage.rst

+11-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
``python-gitlab`` provides a :command:`gitlab` command-line tool to interact
66
with GitLab servers. It uses a configuration file to define how to connect to
7-
the servers.
7+
the servers. Without a configuration file, ``gitlab`` will default to
8+
https://gitlab.com and unauthenticated requests.
89

910
.. _cli_configuration:
1011

@@ -16,8 +17,8 @@ Files
1617

1718
``gitlab`` looks up 3 configuration files by default:
1819

19-
``PYTHON_GITLAB_CFG`` environment variable
20-
An environment variable that contains the path to a configuration file
20+
The ``PYTHON_GITLAB_CFG`` environment variable
21+
An environment variable that contains the path to a configuration file.
2122

2223
``/etc/python-gitlab.cfg``
2324
System-wide configuration file
@@ -27,6 +28,13 @@ Files
2728

2829
You can use a different configuration file with the ``--config-file`` option.
2930

31+
.. warning::
32+
If the ``PYTHON_GITLAB_CFG`` environment variable is defined and the target
33+
file exists, it will be the only configuration file parsed by ``gitlab``.
34+
35+
If the environment variable is defined and the target file cannot be accessed,
36+
``gitlab`` will fail explicitly.
37+
3038
Content
3139
-------
3240

gitlab/config.py

+94-60
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,67 @@
2020
import shlex
2121
import subprocess
2222
from os.path import expanduser, expandvars
23+
from pathlib import Path
2324
from typing import List, Optional, Union
2425

25-
from gitlab.const import USER_AGENT
26+
from gitlab.const import DEFAULT_URL, USER_AGENT
2627

27-
28-
def _env_config() -> List[str]:
29-
if "PYTHON_GITLAB_CFG" in os.environ:
30-
return [os.environ["PYTHON_GITLAB_CFG"]]
31-
return []
32-
33-
34-
_DEFAULT_FILES: List[str] = _env_config() + [
28+
_DEFAULT_FILES: List[str] = [
3529
"/etc/python-gitlab.cfg",
36-
os.path.expanduser("~/.python-gitlab.cfg"),
30+
str(Path.home() / ".python-gitlab.cfg"),
3731
]
3832

3933
HELPER_PREFIX = "helper:"
4034

4135
HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"]
4236

4337

38+
def _resolve_file(filepath: Union[Path, str]) -> str:
39+
resolved = Path(filepath).resolve(strict=True)
40+
return str(resolved)
41+
42+
43+
def _get_config_files(
44+
config_files: Optional[List[str]] = None,
45+
) -> Union[str, List[str]]:
46+
"""
47+
Return resolved path(s) to config files if they exist, with precedence:
48+
1. Files passed in config_files
49+
2. File defined in PYTHON_GITLAB_CFG
50+
3. User- and system-wide config files
51+
"""
52+
resolved_files = []
53+
54+
if config_files:
55+
for config_file in config_files:
56+
try:
57+
resolved = _resolve_file(config_file)
58+
except OSError as e:
59+
raise GitlabConfigMissingError(f"Cannot read config from file: {e}")
60+
resolved_files.append(resolved)
61+
62+
return resolved_files
63+
64+
try:
65+
env_config = os.environ["PYTHON_GITLAB_CFG"]
66+
return _resolve_file(env_config)
67+
except KeyError:
68+
pass
69+
except OSError as e:
70+
raise GitlabConfigMissingError(
71+
f"Cannot read config from PYTHON_GITLAB_CFG: {e}"
72+
)
73+
74+
for config_file in _DEFAULT_FILES:
75+
try:
76+
resolved = _resolve_file(config_file)
77+
except OSError:
78+
continue
79+
resolved_files.append(resolved)
80+
81+
return resolved_files
82+
83+
4484
class ConfigError(Exception):
4585
pass
4686

@@ -66,155 +106,149 @@ def __init__(
66106
self, gitlab_id: Optional[str] = None, config_files: Optional[List[str]] = None
67107
) -> None:
68108
self.gitlab_id = gitlab_id
69-
_files = config_files or _DEFAULT_FILES
70-
file_exist = False
71-
for file in _files:
72-
if os.path.exists(file):
73-
file_exist = True
74-
if not file_exist:
75-
raise GitlabConfigMissingError(
76-
"Config file not found. \nPlease create one in "
77-
"one of the following locations: {} \nor "
78-
"specify a config file using the '-c' parameter.".format(
79-
", ".join(_DEFAULT_FILES)
80-
)
81-
)
109+
self.http_username: Optional[str] = None
110+
self.http_password: Optional[str] = None
111+
self.job_token: Optional[str] = None
112+
self.oauth_token: Optional[str] = None
113+
self.private_token: Optional[str] = None
114+
115+
self.api_version: str = "4"
116+
self.order_by: Optional[str] = None
117+
self.pagination: Optional[str] = None
118+
self.per_page: Optional[int] = None
119+
self.retry_transient_errors: bool = False
120+
self.ssl_verify: Union[bool, str] = True
121+
self.timeout: int = 60
122+
self.url: str = DEFAULT_URL
123+
self.user_agent: str = USER_AGENT
82124

83-
self._config = configparser.ConfigParser()
84-
self._config.read(_files)
125+
self._files = _get_config_files(config_files)
126+
if self._files:
127+
self._parse_config()
128+
129+
def _parse_config(self) -> None:
130+
_config = configparser.ConfigParser()
131+
_config.read(self._files)
85132

86133
if self.gitlab_id is None:
87134
try:
88-
self.gitlab_id = self._config.get("global", "default")
135+
self.gitlab_id = _config.get("global", "default")
89136
except Exception as e:
90137
raise GitlabIDError(
91138
"Impossible to get the gitlab id (not specified in config file)"
92139
) from e
93140

94141
try:
95-
self.url = self._config.get(self.gitlab_id, "url")
142+
self.url = _config.get(self.gitlab_id, "url")
96143
except Exception as e:
97144
raise GitlabDataError(
98145
"Impossible to get gitlab details from "
99146
f"configuration ({self.gitlab_id})"
100147
) from e
101148

102-
self.ssl_verify: Union[bool, str] = True
103149
try:
104-
self.ssl_verify = self._config.getboolean("global", "ssl_verify")
150+
self.ssl_verify = _config.getboolean("global", "ssl_verify")
105151
except ValueError:
106152
# Value Error means the option exists but isn't a boolean.
107153
# Get as a string instead as it should then be a local path to a
108154
# CA bundle.
109155
try:
110-
self.ssl_verify = self._config.get("global", "ssl_verify")
156+
self.ssl_verify = _config.get("global", "ssl_verify")
111157
except Exception:
112158
pass
113159
except Exception:
114160
pass
115161
try:
116-
self.ssl_verify = self._config.getboolean(self.gitlab_id, "ssl_verify")
162+
self.ssl_verify = _config.getboolean(self.gitlab_id, "ssl_verify")
117163
except ValueError:
118164
# Value Error means the option exists but isn't a boolean.
119165
# Get as a string instead as it should then be a local path to a
120166
# CA bundle.
121167
try:
122-
self.ssl_verify = self._config.get(self.gitlab_id, "ssl_verify")
168+
self.ssl_verify = _config.get(self.gitlab_id, "ssl_verify")
123169
except Exception:
124170
pass
125171
except Exception:
126172
pass
127173

128-
self.timeout = 60
129174
try:
130-
self.timeout = self._config.getint("global", "timeout")
175+
self.timeout = _config.getint("global", "timeout")
131176
except Exception:
132177
pass
133178
try:
134-
self.timeout = self._config.getint(self.gitlab_id, "timeout")
179+
self.timeout = _config.getint(self.gitlab_id, "timeout")
135180
except Exception:
136181
pass
137182

138-
self.private_token = None
139183
try:
140-
self.private_token = self._config.get(self.gitlab_id, "private_token")
184+
self.private_token = _config.get(self.gitlab_id, "private_token")
141185
except Exception:
142186
pass
143187

144-
self.oauth_token = None
145188
try:
146-
self.oauth_token = self._config.get(self.gitlab_id, "oauth_token")
189+
self.oauth_token = _config.get(self.gitlab_id, "oauth_token")
147190
except Exception:
148191
pass
149192

150-
self.job_token = None
151193
try:
152-
self.job_token = self._config.get(self.gitlab_id, "job_token")
194+
self.job_token = _config.get(self.gitlab_id, "job_token")
153195
except Exception:
154196
pass
155197

156-
self.http_username = None
157-
self.http_password = None
158198
try:
159-
self.http_username = self._config.get(self.gitlab_id, "http_username")
160-
self.http_password = self._config.get(self.gitlab_id, "http_password")
199+
self.http_username = _config.get(self.gitlab_id, "http_username")
200+
self.http_password = _config.get(self.gitlab_id, "http_password")
161201
except Exception:
162202
pass
163203

164204
self._get_values_from_helper()
165205

166-
self.api_version = "4"
167206
try:
168-
self.api_version = self._config.get("global", "api_version")
207+
self.api_version = _config.get("global", "api_version")
169208
except Exception:
170209
pass
171210
try:
172-
self.api_version = self._config.get(self.gitlab_id, "api_version")
211+
self.api_version = _config.get(self.gitlab_id, "api_version")
173212
except Exception:
174213
pass
175214
if self.api_version not in ("4",):
176215
raise GitlabDataError(f"Unsupported API version: {self.api_version}")
177216

178-
self.per_page = None
179217
for section in ["global", self.gitlab_id]:
180218
try:
181-
self.per_page = self._config.getint(section, "per_page")
219+
self.per_page = _config.getint(section, "per_page")
182220
except Exception:
183221
pass
184222
if self.per_page is not None and not 0 <= self.per_page <= 100:
185223
raise GitlabDataError(f"Unsupported per_page number: {self.per_page}")
186224

187-
self.pagination = None
188225
try:
189-
self.pagination = self._config.get(self.gitlab_id, "pagination")
226+
self.pagination = _config.get(self.gitlab_id, "pagination")
190227
except Exception:
191228
pass
192229

193-
self.order_by = None
194230
try:
195-
self.order_by = self._config.get(self.gitlab_id, "order_by")
231+
self.order_by = _config.get(self.gitlab_id, "order_by")
196232
except Exception:
197233
pass
198234

199-
self.user_agent = USER_AGENT
200235
try:
201-
self.user_agent = self._config.get("global", "user_agent")
236+
self.user_agent = _config.get("global", "user_agent")
202237
except Exception:
203238
pass
204239
try:
205-
self.user_agent = self._config.get(self.gitlab_id, "user_agent")
240+
self.user_agent = _config.get(self.gitlab_id, "user_agent")
206241
except Exception:
207242
pass
208243

209-
self.retry_transient_errors = False
210244
try:
211-
self.retry_transient_errors = self._config.getboolean(
245+
self.retry_transient_errors = _config.getboolean(
212246
"global", "retry_transient_errors"
213247
)
214248
except Exception:
215249
pass
216250
try:
217-
self.retry_transient_errors = self._config.getboolean(
251+
self.retry_transient_errors = _config.getboolean(
218252
self.gitlab_id, "retry_transient_errors"
219253
)
220254
except Exception:

requirements-test.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
coverage
22
httmock
3-
pytest
3+
pytest==6.2.5
44
pytest-console-scripts==1.2.1
55
pytest-cov
66
responses

tests/functional/cli/test_cli.py

+39
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
11
import json
22

3+
import pytest
4+
import responses
5+
36
from gitlab import __version__
47

58

9+
@pytest.fixture
10+
def resp_get_project():
11+
with responses.RequestsMock() as rsps:
12+
rsps.add(
13+
method=responses.GET,
14+
url="https://gitlab.com/api/v4/projects/1",
15+
json={"name": "name", "path": "test-path", "id": 1},
16+
content_type="application/json",
17+
status=200,
18+
)
19+
yield rsps
20+
21+
622
def test_main_entrypoint(script_runner, gitlab_config):
723
ret = script_runner.run("python", "-m", "gitlab", "--config-file", gitlab_config)
824
assert ret.returncode == 2
@@ -13,6 +29,29 @@ def test_version(script_runner):
1329
assert ret.stdout.strip() == __version__
1430

1531

32+
@pytest.mark.script_launch_mode("inprocess")
33+
def test_defaults_to_gitlab_com(script_runner, resp_get_project):
34+
# Runs in-process to intercept requests to gitlab.com
35+
ret = script_runner.run("gitlab", "project", "get", "--id", "1")
36+
assert ret.success
37+
assert "id: 1" in ret.stdout
38+
39+
40+
def test_env_config_missing_file_raises(script_runner, monkeypatch):
41+
monkeypatch.setenv("PYTHON_GITLAB_CFG", "non-existent")
42+
ret = script_runner.run("gitlab", "project", "list")
43+
assert not ret.success
44+
assert ret.stderr.startswith("Cannot read config from PYTHON_GITLAB_CFG")
45+
46+
47+
def test_arg_config_missing_file_raises(script_runner):
48+
ret = script_runner.run(
49+
"gitlab", "--config-file", "non-existent", "project", "list"
50+
)
51+
assert not ret.success
52+
assert ret.stderr.startswith("Cannot read config from file")
53+
54+
1655
def test_invalid_config(script_runner):
1756
ret = script_runner.run("gitlab", "--gitlab", "invalid")
1857
assert not ret.success

0 commit comments

Comments
 (0)