Skip to content

Commit 00c0a7e

Browse files
authored
Merge pull request #6 from RazerM/feature/deploy-token
Deploy token support
2 parents f3df378 + f84a1ee commit 00c0a7e

7 files changed

Lines changed: 192 additions & 72 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ jobs:
4545
with:
4646
fail_ci_if_error: true
4747
token: ${{ secrets.CODECOV_TOKEN }}
48+
exclude: .nox

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 a personal access token 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 a deploy token 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

Lines changed: 8 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
11
from __future__ import annotations
22

3-
import os
4-
import sys
53
from pathlib import Path
64

75
import pytest
86
from keyring.credentials import SimpleCredential
97
from pyfakefs.fake_filesystem import FakeFilesystem
10-
from pytest import MonkeyPatch
118
from yarl import URL
129

13-
from keyrings.gitlab_pypi import GitlabPypi, system_config_paths, user_config_path
10+
from keyrings.gitlab_pypi import GitlabPypi
1411

1512

1613
def test_get_password(
17-
backend: GitlabPypi, config_file: Path, service: str, token: str
14+
backend: GitlabPypi, config_file_access_token: Path, service: str, token: str
1815
) -> None:
1916
assert backend.get_password(service, "__token__") == token
2017

2118

2219
def test_get_password_wrong_username(
23-
backend: GitlabPypi, config_file: Path, service: str, token: str
20+
backend: GitlabPypi, config_file_access_token: Path, service: str, token: str
2421
) -> None:
2522
assert backend.get_password(service, "__token__") == token
2623
assert backend.get_password(service, "alice") is None
@@ -29,7 +26,7 @@ def test_get_password_wrong_username(
2926
@pytest.mark.parametrize("username", [None, "", "username", "__token__"])
3027
def test_get_credential(
3128
backend: GitlabPypi,
32-
config_file: Path,
29+
config_file_access_token: Path,
3330
service: str,
3431
token: str,
3532
username: str | None,
@@ -41,7 +38,7 @@ def test_get_credential(
4138

4239

4340
def test_get_password_unknown_url(
44-
backend: GitlabPypi, config_file: Path, badservice: str
41+
backend: GitlabPypi, config_file_access_token: Path, badservice: str
4542
) -> None:
4643
assert backend.get_password(badservice, "__token__") is None
4744

@@ -53,7 +50,7 @@ def test_get_password_no_config(
5350

5451

5552
def test_get_password_wrong_url(
56-
backend: GitlabPypi, config_file: Path, service: str
53+
backend: GitlabPypi, config_file_access_token: Path, service: str
5754
) -> None:
5855
service = service.replace("/pypi/", "/banana/")
5956
assert backend.get_password(service, "__token__") is None
@@ -77,7 +74,7 @@ def test_get_password_invalid_url_scheme(
7774

7875

7976
def test_get_credential_unknown_url(
80-
backend: GitlabPypi, config_file: Path, badservice: str
77+
backend: GitlabPypi, config_file_access_token: Path, badservice: str
8178
) -> None:
8279
assert backend.get_credential(badservice, None) is None
8380

@@ -89,51 +86,7 @@ def test_get_credential_no_config(
8986

9087

9188
def test_get_credential_wrong_url(
92-
backend: GitlabPypi, config_file: Path, service: str
89+
backend: GitlabPypi, config_file_access_token: Path, service: str
9390
) -> None:
9491
service = service.replace("/pypi/", "/banana/")
9592
assert backend.get_credential(service, None) is None
96-
97-
98-
@pytest.mark.skipif(sys.platform != "linux", reason="requires Linux")
99-
def test_linux_user_config_dir() -> None:
100-
assert user_config_path() == Path("~/.config").expanduser()
101-
102-
103-
@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS")
104-
def test_macos_user_config_dir(fs: FakeFilesystem) -> None:
105-
# Default is Linux-like ~/.config
106-
assert user_config_path() == Path("~/.config").expanduser()
107-
108-
# macOS convention will be used if it exists
109-
path = Path("~/Library/Application Support/gitlab-pypi").expanduser()
110-
fs.create_dir(path)
111-
assert user_config_path() == path
112-
113-
114-
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows")
115-
def test_windows_user_config_dir() -> None:
116-
localappdata = os.environ["LOCALAPPDATA"]
117-
assert user_config_path() == Path(localappdata, "gitlab-pypi")
118-
119-
120-
@pytest.mark.skipif(sys.platform != "linux", reason="requires Linux")
121-
def test_linux_system_config_dir(monkeypatch: MonkeyPatch) -> None:
122-
assert system_config_paths() == [Path("/etc/xdg/gitlab-pypi"), Path("/etc")]
123-
monkeypatch.setenv("XDG_CONFIG_DIRS", "/etc/foo:/etc/bar")
124-
assert system_config_paths() == [
125-
Path("/etc/foo/gitlab-pypi"),
126-
Path("/etc/bar/gitlab-pypi"),
127-
Path("/etc"),
128-
]
129-
130-
131-
@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS")
132-
def test_macos_system_config_dir() -> None:
133-
assert system_config_paths() == [Path("/Library/Application Support/gitlab-pypi")]
134-
135-
136-
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows")
137-
def test_windows_system_config_dir() -> None:
138-
allusersprofile = os.environ["ALLUSERSPROFILE"]
139-
assert system_config_paths() == [Path(allusersprofile, "gitlab-pypi")]

0 commit comments

Comments
 (0)