Skip to content

Commit dad37b8

Browse files
authored
Merge pull request #1731 from candleindark/no-hardcoded-api-key-name
Obtain API key for DANDI instance from corresponding env var
2 parents a656bcd + 73694d0 commit dad37b8

File tree

8 files changed

+122
-66
lines changed

8 files changed

+122
-66
lines changed

DEVELOPMENT.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,17 @@ development command line options.
5555
otherwise be hidden from the user-visible (`--help`) interface, unless this
5656
env variable is set to a non-empty value
5757

58-
- `DANDI_API_KEY` -- avoids using keyrings, thus making it possible to
59-
"temporarily" use another account etc for the "API" version of the server.
58+
- `{CAPITALIZED_INSTANCE_NAME_WITH_UNDERSCORE}_API_KEY` --
59+
Provides the API key to access a known DANDI instance.
60+
Respective keys for multiple instances can be provided. The name of the environment
61+
variable providing the key for a specific known DANDI instance corresponds to the name
62+
of the instance. For example, the environment variable `DANDI_API_KEY` provides the key
63+
for the known instance named `dandi` and the environment variable
64+
`EMBER_SANDBOX_API_KEY` provides the key for the known instance named `ember-sandbox`.
65+
I.e., the environment variable name is the capitalized version of the instance's name
66+
with "-" replaced by "_" suffixed by "_API_KEY". Providing API keys through environment
67+
variables avoids using keyrings, thus making it possible to "temporarily" use another
68+
account etc for the "API" version of the server.
6069

6170
- `DANDI_LOG_LEVEL` -- set log level. By default `INFO`, should be an int (`10` - `DEBUG`).
6271

dandi/cli/tests/test_service_scripts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_reextract_metadata(
3131
asset_id = nwb_dandiset.dandiset.get_asset_by_path(
3232
"sub-mouse001/sub-mouse001.nwb"
3333
).identifier
34-
monkeypatch.setenv("DANDI_API_KEY", nwb_dandiset.api.api_key)
34+
nwb_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
3535
r = CliRunner().invoke(
3636
service_scripts,
3737
["reextract-metadata", "--when=always", nwb_dandiset.dandiset.version_api_url],
@@ -74,7 +74,7 @@ def test_update_dandiset_from_doi(
7474
) -> None:
7575
dandiset_id = new_dandiset.dandiset_id
7676
repository = new_dandiset.api.instance.gui
77-
monkeypatch.setenv("DANDI_API_KEY", new_dandiset.api.api_key)
77+
new_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
7878
if os.environ.get("DANDI_TESTS_NO_VCR", "") or sys.version_info <= (3, 10):
7979
# Older vcrpy has an issue with Python 3.9 and newer urllib2 >= 2
8080
# But we require newer urllib2 for more correct operation, and

dandi/dandiapi.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -488,20 +488,23 @@ def authenticate(self, token: str, save_to_keyring: bool = False) -> None:
488488
def dandi_authenticate(self) -> None:
489489
"""
490490
Acquire and set the authentication token/API key used by the
491-
`DandiAPIClient`. If the :envvar:`DANDI_API_KEY` environment variable
492-
is set, its value is used as the token. Otherwise, the token is looked
493-
up in the user's keyring under the service
494-
":samp:`dandi-api-{INSTANCE_NAME}`" [#auth]_ and username "``key``".
495-
If no token is found there, the user is prompted for the token, and, if
496-
it proves to be valid, it is stored in the user's keyring.
491+
`DandiAPIClient`.
492+
If the :envvar:`{INSTANCE_NAME}_API_KEY` environment variable is set, its value
493+
is used as the token. Here, ``{INSTANCE_NAME}`` is the uppercased instance name
494+
with hyphens replaced by underscores. Otherwise, the token is looked up in the
495+
user's keyring under the service ":samp:`dandi-api-{self.dandi_instance.name}`"
496+
[#auth]_ and username "``key``". If no token is found there, the user is
497+
prompted for the token, and, if it proves to be valid, it is stored in the
498+
user's keyring.
497499
498500
.. [#auth] E.g., "``dandi-api-dandi``" for the production server or
499501
"``dandi-api-dandi-sandbox``" for the sandbox server
500502
"""
501503
# Shortcut for advanced folks
502-
api_key = os.environ.get("DANDI_API_KEY", None)
504+
env_var_name = self.api_key_env_var
505+
api_key = os.environ.get(env_var_name, None)
503506
if api_key:
504-
lgr.debug("Using api key from DANDI_API_KEY environment variable")
507+
lgr.debug(f"Using `{env_var_name}` environment variable as the API key")
505508
self.authenticate(api_key)
506509
return
507510
client_name, app_id = self._get_keyring_ids()
@@ -733,6 +736,14 @@ def get_asset(self, asset_id: str) -> BaseRemoteAsset:
733736
metadata = info.pop("metadata", None)
734737
return BaseRemoteAsset.from_base_data(self, info, metadata)
735738

739+
@property
740+
def api_key_env_var(self) -> str:
741+
"""
742+
Get the name of the environment variable that can be used to specify the
743+
API key for the associated DANDI instance.
744+
"""
745+
return f"{self.dandi_instance.name.upper().replace('-', '_')}_API_KEY"
746+
736747

737748
# `arbitrary_types_allowed` is needed for `client: DandiAPIClient`
738749
class APIBase(BaseModel, populate_by_name=True, arbitrary_types_allowed=True):

dandi/tests/fixtures.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,23 @@ def instance_id(self) -> str:
535535
def api_url(self) -> str:
536536
return self.instance.api
537537

538+
def monkeypatch_set_api_key_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
539+
"""
540+
Monkeypatch the environment variable that provides the API key for accessing
541+
the associated DANDI instance
542+
"""
543+
monkeypatch.setenv(
544+
self.client.api_key_env_var,
545+
self.api_key,
546+
)
547+
548+
def monkeypatch_del_api_key_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
549+
"""
550+
Monkeypatch to remove the environment variable that provides the API key for
551+
accessing the associated DANDI instance.
552+
"""
553+
monkeypatch.delenv(self.client.api_key_env_var, raising=False)
554+
538555

539556
@pytest.fixture(scope="session")
540557
def local_dandi_api(docker_compose_setup: dict[str, str]) -> Iterator[DandiAPI]:
@@ -558,7 +575,7 @@ def client(self) -> DandiAPIClient:
558575

559576
def upload(self, paths: list[str | Path] | None = None, **kwargs: Any) -> None:
560577
with pytest.MonkeyPatch().context() as m:
561-
m.setenv("DANDI_API_KEY", self.api.api_key)
578+
self.api.monkeypatch_set_api_key_env(m)
562579
upload(
563580
paths=paths or [self.dspath],
564581
dandi_instance=self.api.instance_id,

dandi/tests/test_dandiapi.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..consts import (
2424
DRAFT,
2525
VERSION_REGEX,
26+
DandiInstance,
2627
dandiset_identifier_regex,
2728
dandiset_metadata_file,
2829
)
@@ -138,7 +139,7 @@ def test_authenticate_bad_key_good_key_input(
138139
)
139140
confirm_mock = mocker.patch("click.confirm", return_value=True)
140141

141-
monkeypatch.delenv("DANDI_API_KEY", raising=False)
142+
local_dandi_api.monkeypatch_del_api_key_env(monkeypatch)
142143

143144
client = DandiAPIClient(local_dandi_api.api_url)
144145
assert "Authorization" not in client.session.headers
@@ -169,7 +170,7 @@ def test_authenticate_good_key_keyring(
169170
is_interactive_spy = mocker.spy(dandiapi, "is_interactive")
170171
confirm_spy = mocker.spy(click, "confirm")
171172

172-
monkeypatch.delenv("DANDI_API_KEY", raising=False)
173+
local_dandi_api.monkeypatch_del_api_key_env(monkeypatch)
173174

174175
client = DandiAPIClient(local_dandi_api.api_url)
175176
assert "Authorization" not in client.session.headers
@@ -201,7 +202,7 @@ def test_authenticate_bad_key_keyring_good_key_input(
201202
)
202203
confirm_mock = mocker.patch("click.confirm", return_value=True)
203204

204-
monkeypatch.delenv("DANDI_API_KEY", raising=False)
205+
local_dandi_api.monkeypatch_del_api_key_env(monkeypatch)
205206

206207
client = DandiAPIClient(local_dandi_api.api_url)
207208
assert "Authorization" not in client.session.headers
@@ -860,3 +861,21 @@ def test_asset_as_readable_open(new_dandiset: SampleDandiset, tmp_path: Path) ->
860861
assert fp.read() == b"This is test text.\n"
861862
finally:
862863
fp.close()
864+
865+
866+
@pytest.mark.parametrize(
867+
("instance_name", "expected_env_var_name"),
868+
[
869+
("dandi", "DANDI_API_KEY"),
870+
("dandi-api-local-docker-tests", "DANDI_API_LOCAL_DOCKER_TESTS_API_KEY"),
871+
("dandi-sandbox", "DANDI_SANDBOX_API_KEY"),
872+
("ember-sandbox", "EMBER_SANDBOX_API_KEY"),
873+
],
874+
)
875+
def test_get_api_key_env_var(instance_name: str, expected_env_var_name: str) -> None:
876+
dandi_api_client = DandiAPIClient(
877+
dandi_instance=DandiInstance(
878+
name=instance_name, gui="https://example.com", api="https://api.example.com"
879+
)
880+
)
881+
assert dandi_api_client.api_key_env_var == expected_env_var_name

dandi/tests/test_delete.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def test_delete_paths(
6767
remainder: list[Path],
6868
) -> None:
6969
monkeypatch.chdir(text_dandiset.dspath)
70-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
70+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
7171
instance = text_dandiset.api.instance_id
7272
dandiset_id = text_dandiset.dandiset_id
7373
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -92,7 +92,7 @@ def test_delete_path_confirm(
9292
text_dandiset: SampleDandiset,
9393
) -> None:
9494
monkeypatch.chdir(text_dandiset.dspath)
95-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
95+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
9696
instance = text_dandiset.api.instance_id
9797
dandiset_id = text_dandiset.dandiset_id
9898
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -113,7 +113,7 @@ def test_delete_path_pyout(
113113
text_dandiset: SampleDandiset,
114114
) -> None:
115115
monkeypatch.chdir(text_dandiset.dspath)
116-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
116+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
117117
instance = text_dandiset.api.instance_id
118118
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
119119
delete(["subdir2/coconut.txt"], dandi_instance=instance, force=True)
@@ -143,7 +143,7 @@ def test_delete_dandiset(
143143
paths: list[str],
144144
) -> None:
145145
monkeypatch.chdir(text_dandiset.dspath)
146-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
146+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
147147
instance = text_dandiset.api.instance_id
148148
dandiset_id = text_dandiset.dandiset_id
149149
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -166,7 +166,7 @@ def test_delete_dandiset_confirm(
166166
text_dandiset: SampleDandiset,
167167
) -> None:
168168
monkeypatch.chdir(text_dandiset.dspath)
169-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
169+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
170170
instance = text_dandiset.api.instance_id
171171
dandiset_id = text_dandiset.dandiset_id
172172
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -187,7 +187,7 @@ def test_delete_dandiset_mismatch(
187187
text_dandiset: SampleDandiset,
188188
) -> None:
189189
monkeypatch.chdir(text_dandiset.dspath)
190-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
190+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
191191
instance = text_dandiset.api.instance_id
192192
dandiset_id = text_dandiset.dandiset_id
193193
not_dandiset = str(int(dandiset_id) - 1).zfill(6)
@@ -216,7 +216,7 @@ def test_delete_instance_mismatch(
216216
text_dandiset: SampleDandiset,
217217
) -> None:
218218
monkeypatch.chdir(text_dandiset.dspath)
219-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
219+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
220220
instance = text_dandiset.api.instance_id
221221
dandiset_id = text_dandiset.dandiset_id
222222
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -242,7 +242,7 @@ def test_delete_instance_mismatch(
242242
def test_delete_nonexistent_dandiset(
243243
local_dandi_api: DandiAPI, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
244244
) -> None:
245-
monkeypatch.setenv("DANDI_API_KEY", local_dandi_api.api_key)
245+
local_dandi_api.monkeypatch_set_api_key_env(monkeypatch)
246246
instance = local_dandi_api.instance_id
247247
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
248248
with pytest.raises(NotFoundError) as excinfo:
@@ -259,7 +259,7 @@ def test_delete_nonexistent_dandiset(
259259
def test_delete_nonexistent_dandiset_skip_missing(
260260
local_dandi_api: DandiAPI, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
261261
) -> None:
262-
monkeypatch.setenv("DANDI_API_KEY", local_dandi_api.api_key)
262+
local_dandi_api.monkeypatch_set_api_key_env(monkeypatch)
263263
instance = local_dandi_api.instance_id
264264
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
265265
delete(
@@ -277,7 +277,7 @@ def test_delete_nonexistent_asset(
277277
monkeypatch: pytest.MonkeyPatch,
278278
text_dandiset: SampleDandiset,
279279
) -> None:
280-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
280+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
281281
instance = text_dandiset.api.instance_id
282282
dandiset_id = text_dandiset.dandiset_id
283283
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -304,7 +304,7 @@ def test_delete_nonexistent_asset_skip_missing(
304304
text_dandiset: SampleDandiset,
305305
tmp_path: Path,
306306
) -> None:
307-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
307+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
308308
instance = text_dandiset.api.instance_id
309309
dandiset_id = text_dandiset.dandiset_id
310310
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -333,7 +333,7 @@ def test_delete_nonexistent_asset_folder(
333333
monkeypatch: pytest.MonkeyPatch,
334334
text_dandiset: SampleDandiset,
335335
) -> None:
336-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
336+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
337337
instance = text_dandiset.api.instance_id
338338
dandiset_id = text_dandiset.dandiset_id
339339
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -360,7 +360,7 @@ def test_delete_nonexistent_asset_folder_skip_missing(
360360
text_dandiset: SampleDandiset,
361361
tmp_path: Path,
362362
) -> None:
363-
monkeypatch.setenv("DANDI_API_KEY", text_dandiset.api.api_key)
363+
text_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
364364
instance = text_dandiset.api.instance_id
365365
dandiset_id = text_dandiset.dandiset_id
366366
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
@@ -387,7 +387,7 @@ def test_delete_nonexistent_asset_folder_skip_missing(
387387
def test_delete_version(
388388
local_dandi_api: DandiAPI, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
389389
) -> None:
390-
monkeypatch.setenv("DANDI_API_KEY", local_dandi_api.api_key)
390+
local_dandi_api.monkeypatch_set_api_key_env(monkeypatch)
391391
instance = local_dandi_api.instance_id
392392
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
393393
with pytest.raises(NotImplementedError) as excinfo:
@@ -430,7 +430,7 @@ def test_delete_zarr_path(
430430
tmp_path: Path,
431431
) -> None:
432432
monkeypatch.chdir(zarr_dandiset.dspath)
433-
monkeypatch.setenv("DANDI_API_KEY", zarr_dandiset.api.api_key)
433+
zarr_dandiset.api.monkeypatch_set_api_key_env(monkeypatch)
434434
instance = zarr_dandiset.api.instance_id
435435
delete_spy = mocker.spy(RESTFullAPIClient, "delete")
436436
delete(["sample.zarr"], dandi_instance=instance, devel_debug=True, force=True)

dandi/tests/test_keyring.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def ensure_keyring_backends() -> None:
2929
def test_dandi_authenticate_no_env_var(
3030
local_dandi_api: DandiAPI, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
3131
) -> None:
32-
monkeypatch.delenv("DANDI_API_KEY", raising=False)
32+
local_dandi_api.monkeypatch_del_api_key_env(monkeypatch)
3333
monkeypatch.setenv("PYTHON_KEYRING_BACKEND", "keyring.backends.null.Keyring")
3434
inputmock = mocker.patch(
3535
"dandi.dandiapi.input", return_value=local_dandi_api.api_key

0 commit comments

Comments
 (0)