Skip to content

Commit 9f7c74c

Browse files
committed
Deploy token support
1 parent 99a4114 commit 9f7c74c

5 files changed

Lines changed: 135 additions & 24 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,24 @@
5454
<dd><code>C:\ProgramData\gitlab-pypi\gitlab-pypi.toml</code></dd>
5555
</dl>
5656

57-
3. Create a personal access token with `read_api` scope and add it to the config file:
57+
3. Configure a token
58+
59+
### Personal Access Token
60+
61+
Create with the `read_api` scope and add it to the config file:
62+
63+
```toml
64+
["gitlab.com"]
65+
token = "<token>"
66+
```
67+
68+
### Deploy Token
69+
70+
Create with the `read_package_registry` scope and add it to the config file:
5871

5972
```toml
60-
["https://gitlab.com"]
73+
["gitlab.com"]
74+
username = "<username>"
6175
token = "<token>"
6276
```
6377

@@ -96,7 +110,7 @@
96110

97111
You need `authenticate = "always"` for uv to invoke [keyring] when no username is specified. This option is a good idea anyway!
98112

99-
Alternatively, add the username `__token__` to the URL, but this is not recommended for `pyproject.toml` as you likely want to use a different username in CI, for example.
113+
Alternatively, add the username (which is `__token__` for personal access tokens) to the URL, but this is not recommended for `pyproject.toml` as you likely want to use a different username in CI, for example.
100114

101115
6. Done! `keyring-gitlab-pypi` will return your token for URLs that look like package installs.
102116

src/keyrings/gitlab_pypi/__init__.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def iter_config_paths() -> Iterator[Path]:
7373
yield user_config_path()
7474

7575

76-
def _load_access_token(service: str) -> str | None:
76+
def _load_access_credential(service: str) -> tuple[str, str] | None:
7777
url = _gitlab_url_from_service(service)
7878

7979
if url is None:
@@ -82,13 +82,15 @@ def _load_access_token(service: str) -> str | None:
8282
# Since we don't need to merge config files, we can start with the
8383
# highest-precedence file and return the first token we find.
8484
for path in reversed(list(iter_config_paths())):
85-
if token := _load_access_token_from_config_path(path, url):
86-
return token
85+
if credential := _load_access_credential_from_config_path(path, url):
86+
return credential
8787

8888
return None
8989

9090

91-
def _load_access_token_from_config_path(path: Path, url: URL) -> str | None:
91+
def _load_access_credential_from_config_path(
92+
path: Path, url: URL
93+
) -> tuple[str, str] | None:
9294
try:
9395
with open(path / CONFIG_FILENAME, "rb") as f:
9496
config = tomllib.load(f)
@@ -134,8 +136,14 @@ def _load_access_token_from_config_path(path: Path, url: URL) -> str | None:
134136
if not token:
135137
continue
136138

137-
if isinstance(token, str):
138-
return token
139+
if not isinstance(token, str):
140+
continue
141+
142+
username = host_config.get("username", "__token__")
143+
if not isinstance(username, str):
144+
continue
145+
146+
return username, token
139147

140148
return None
141149

@@ -177,11 +185,17 @@ class GitlabPypi(KeyringBackend):
177185
def __init__(self) -> None: ...
178186

179187
def get_password(self, service: str, username: str) -> str | None:
180-
if username == "__token__":
181-
return _load_access_token(service)
182-
elif username == "gitlab-ci-token":
188+
if username == "gitlab-ci-token":
183189
return _load_ci_job_token(service)
184190

191+
credential = _load_access_credential(service)
192+
if credential is None:
193+
return None
194+
195+
config_username, token = credential
196+
if config_username == username:
197+
return token
198+
185199
return None
186200

187201
def set_password(self, service: str, username: str, password: str) -> None:
@@ -195,8 +209,8 @@ def get_credential(
195209
service: str,
196210
username: str | None,
197211
) -> SimpleCredential | None:
198-
if token := _load_access_token(service):
199-
return SimpleCredential("__token__", token)
212+
if credential := _load_access_credential(service):
213+
return SimpleCredential(*credential)
200214
elif token := _load_ci_job_token(service):
201215
return SimpleCredential("gitlab-ci-token", token)
202216

tests/conftest.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import random
45
import re
56
import secrets
67
import string
@@ -139,6 +140,11 @@ def token() -> str:
139140
return "".join(secrets.choice(alphabet) for _ in range(20))
140141

141142

143+
@pytest.fixture
144+
def deploy_token_username() -> str:
145+
return f"gitlab+deploy-token-{random.randint(0, 999):03d}"
146+
147+
142148
@pytest.fixture(
143149
# s: explicit scheme even if https
144150
# p: explicit port even if default
@@ -163,13 +169,17 @@ def section(request: FixtureRequest, gitlab_base_url: URL) -> str:
163169
return "".join(parts)
164170

165171

166-
@pytest.fixture
167-
def config_file(
172+
@pytest.fixture(
173+
params=[None, "__token__"],
174+
ids=["implicit-username", "explicit-username"],
175+
)
176+
def config_file_access_token(
168177
config_dir_env: ConfigDirEnv,
169178
monkeypatch: MonkeyPatch,
170179
fs: FakeFilesystem,
171180
token: str,
172181
section: str,
182+
request: FixtureRequest,
173183
) -> Path:
174184
config_dir_env.path.mkdir(parents=True)
175185
for key, value in config_dir_env.env.items():
@@ -186,7 +196,45 @@ def config_file(
186196
tomli_w.dump(doc, f)
187197

188198
path = config_dir_env.path / "gitlab-pypi.toml"
189-
doc = {section: {"token": token}}
199+
host_config = {"token": token}
200+
if request.param is not None:
201+
host_config["username"] = request.param
202+
doc = {section: host_config}
203+
with open(path, "wb") as f:
204+
tomli_w.dump(doc, f)
205+
return path
206+
207+
208+
@pytest.fixture
209+
def config_file_deploy_token(
210+
config_dir_env: ConfigDirEnv,
211+
monkeypatch: MonkeyPatch,
212+
fs: FakeFilesystem,
213+
token: str,
214+
section: str,
215+
deploy_token_username: str,
216+
) -> Path:
217+
config_dir_env.path.mkdir(parents=True)
218+
for key, value in config_dir_env.env.items():
219+
monkeypatch.setenv(key, value)
220+
221+
# Set bad tokens in lower precedence config files to verify that they are
222+
# not used.
223+
for lower_precedence_path in iter_config_paths():
224+
if lower_precedence_path == config_dir_env.path:
225+
break
226+
lower_precedence_path.mkdir(parents=True, exist_ok=True)
227+
doc = {
228+
section: {
229+
"username": deploy_token_username,
230+
"token": f"token from {lower_precedence_path}",
231+
}
232+
}
233+
with open(lower_precedence_path / "gitlab-pypi.toml", "wb") as f:
234+
tomli_w.dump(doc, f)
235+
236+
path = config_dir_env.path / "gitlab-pypi.toml"
237+
doc = {section: {"username": deploy_token_username, "token": token}}
190238
with open(path, "wb") as f:
191239
tomli_w.dump(doc, f)
192240
return path
@@ -197,6 +245,7 @@ class InvalidConfig(Enum):
197245
NO_TOKEN = auto()
198246
BLANK_TOKEN = auto()
199247
NON_STR_TOKEN = auto()
248+
NON_STR_USERNAME = auto()
200249

201250

202251
@pytest.fixture(
@@ -205,6 +254,7 @@ class InvalidConfig(Enum):
205254
InvalidConfig.NO_TOKEN,
206255
InvalidConfig.BLANK_TOKEN,
207256
InvalidConfig.NON_STR_TOKEN,
257+
InvalidConfig.NON_STR_USERNAME,
208258
]
209259
)
210260
def invalid_config_file(
@@ -228,6 +278,8 @@ def invalid_config_file(
228278
doc = {section: {"token": ""}}
229279
elif request.param is InvalidConfig.NON_STR_TOKEN:
230280
doc = {section: {"token": 123}}
281+
elif request.param is InvalidConfig.NON_STR_USERNAME:
282+
doc = {section: {"username": 123, "token": token}}
231283
else:
232284
raise NotImplementedError(request.param)
233285

tests/test_access_token.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111

1212

1313
def test_get_password(
14-
backend: GitlabPypi, config_file: Path, service: str, token: str
14+
backend: GitlabPypi, config_file_access_token: Path, service: str, token: str
1515
) -> None:
1616
assert backend.get_password(service, "__token__") == token
1717

1818

1919
def test_get_password_wrong_username(
20-
backend: GitlabPypi, config_file: Path, service: str, token: str
20+
backend: GitlabPypi, config_file_access_token: Path, service: str, token: str
2121
) -> None:
2222
assert backend.get_password(service, "__token__") == token
2323
assert backend.get_password(service, "alice") is None
@@ -26,7 +26,7 @@ def test_get_password_wrong_username(
2626
@pytest.mark.parametrize("username", [None, "", "username", "__token__"])
2727
def test_get_credential(
2828
backend: GitlabPypi,
29-
config_file: Path,
29+
config_file_access_token: Path,
3030
service: str,
3131
token: str,
3232
username: str | None,
@@ -38,7 +38,7 @@ def test_get_credential(
3838

3939

4040
def test_get_password_unknown_url(
41-
backend: GitlabPypi, config_file: Path, badservice: str
41+
backend: GitlabPypi, config_file_access_token: Path, badservice: str
4242
) -> None:
4343
assert backend.get_password(badservice, "__token__") is None
4444

@@ -50,7 +50,7 @@ def test_get_password_no_config(
5050

5151

5252
def test_get_password_wrong_url(
53-
backend: GitlabPypi, config_file: Path, service: str
53+
backend: GitlabPypi, config_file_access_token: Path, service: str
5454
) -> None:
5555
service = service.replace("/pypi/", "/banana/")
5656
assert backend.get_password(service, "__token__") is None
@@ -74,7 +74,7 @@ def test_get_password_invalid_url_scheme(
7474

7575

7676
def test_get_credential_unknown_url(
77-
backend: GitlabPypi, config_file: Path, badservice: str
77+
backend: GitlabPypi, config_file_access_token: Path, badservice: str
7878
) -> None:
7979
assert backend.get_credential(badservice, None) is None
8080

@@ -86,7 +86,7 @@ def test_get_credential_no_config(
8686

8787

8888
def test_get_credential_wrong_url(
89-
backend: GitlabPypi, config_file: Path, service: str
89+
backend: GitlabPypi, config_file_access_token: Path, service: str
9090
) -> None:
9191
service = service.replace("/pypi/", "/banana/")
9292
assert backend.get_credential(service, None) is None

tests/test_deploy_token.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from keyring.credentials import SimpleCredential
6+
7+
from keyrings.gitlab_pypi import GitlabPypi
8+
9+
10+
def test_get_password(
11+
backend: GitlabPypi,
12+
config_file_deploy_token: Path,
13+
service: str,
14+
token: str,
15+
deploy_token_username: str,
16+
) -> None:
17+
assert backend.get_password(service, "__token__") is None
18+
assert backend.get_password(service, deploy_token_username) == token
19+
20+
21+
def test_get_credential(
22+
backend: GitlabPypi,
23+
config_file_deploy_token: Path,
24+
service: str,
25+
token: str,
26+
deploy_token_username: str,
27+
) -> None:
28+
credential = backend.get_credential(service, None)
29+
assert isinstance(credential, SimpleCredential)
30+
assert credential.username == deploy_token_username
31+
assert credential.password == token

0 commit comments

Comments
 (0)