Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions dandi/cli/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import click

from .. import get_logger
from ..consts import known_instances

lgr = get_logger()

Expand Down Expand Up @@ -95,7 +94,6 @@ def dandiset_path_option(**kwargs):
def instance_option(**kwargs):
params = {
"help": "DANDI instance to use",
"type": click.Choice(sorted(known_instances)),
"default": "dandi",
"show_default": True,
"envvar": "DANDI_INSTANCE",
Expand Down
12 changes: 4 additions & 8 deletions dandi/cli/cmd_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import click

from .base import ChoiceList, IntColonInt, instance_option, map_to_click_exceptions
from ..consts import known_instances, known_instances_rev
from ..dandiarchive import _dandi_url_parser, parse_dandi_url
from ..utils import get_instance


# The use of f-strings apparently makes this not a proper docstring, and so
Expand Down Expand Up @@ -111,10 +111,11 @@ def download(
from .. import download

if dandi_instance is not None:
instance = get_instance(dandi_instance)
if url:
for u in url:
parsed_url = parse_dandi_url(u)
if known_instances_rev.get(parsed_url.api_url) != dandi_instance:
if parsed_url.instance != instance:
raise click.UsageError(
f"{u} does not point to {dandi_instance!r} instance"
)
Expand All @@ -127,15 +128,10 @@ def download(
# No Dandiset here; leave `url` alone
pass
else:
instance = known_instances[dandi_instance]
if instance.gui is not None:
url = [f"{instance.gui}/#/dandiset/{dandiset_id}/draft"]
elif instance.api is not None:
url = [f"{instance.api}/dandisets/{dandiset_id}/"]
else:
raise NotImplementedError(
f"Do not know how to construct URLs for {dandi_instance!r}"
)
url = [f"{instance.api}/dandisets/{dandiset_id}/"]

return download.download(
url,
Expand Down
8 changes: 7 additions & 1 deletion dandi/cli/cmd_instances.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import asdict
import sys

import click
Expand All @@ -13,4 +14,9 @@ def instances():
"""List known Dandi Archive instances that the CLI can interact with"""
yaml = ruamel.yaml.YAML(typ="safe")
yaml.default_flow_style = False
yaml.dump({k: v._asdict() for k, v in known_instances.items()}, sys.stdout)
instances = {}
for inst in known_instances.values():
data = asdict(inst)
data.pop("name")
instances[inst.name] = data
yaml.dump(instances, sys.stdout)
2 changes: 1 addition & 1 deletion dandi/cli/tests/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def test_download_gui_instance_in_dandiset(mocker):
r = runner.invoke(download, ["-i", "dandi"])
assert r.exit_code == 0
mock_download.assert_called_once_with(
["https://gui.dandiarchive.org/#/dandiset/123456/draft"],
["https://dandiarchive.org/#/dandiset/123456/draft"],
os.curdir,
existing="error",
format="pyout",
Expand Down
12 changes: 1 addition & 11 deletions dandi/cli/tests/test_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,17 @@


def test_cmd_instances(monkeypatch):
redirector_base = os.environ.get(
"DANDI_REDIRECTOR_BASE", "https://dandiarchive.org"
)
instancehost = os.environ.get("DANDI_INSTANCEHOST", "localhost")
r = CliRunner().invoke(instances, [])
assert r.exit_code == 0
assert r.output == (
"dandi:\n"
" api: https://api.dandiarchive.org/api\n"
" gui: https://gui.dandiarchive.org\n"
f" redirector: {redirector_base}\n"
" gui: https://dandiarchive.org\n"
"dandi-api-local-docker-tests:\n"
f" api: http://{instancehost}:8000/api\n"
f" gui: http://{instancehost}:8085\n"
" redirector: null\n"
"dandi-devel:\n"
" api: null\n"
" gui: https://gui-beta-dandiarchive-org.netlify.app\n"
" redirector: null\n"
"dandi-staging:\n"
" api: https://api-staging.dandiarchive.org/api\n"
" gui: https://gui-staging.dandiarchive.org\n"
" redirector: null\n"
)
44 changes: 28 additions & 16 deletions dandi/consts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from __future__ import annotations

from collections.abc import Iterator
from dataclasses import dataclass
from enum import Enum
import os
from typing import NamedTuple, Optional
from typing import Optional

#: A list of metadata fields which dandi extracts from .nwb files.
#: Additional fields (such as ``number_of_*``) might be added by
Expand Down Expand Up @@ -92,40 +96,48 @@ class EmbargoStatus(Enum):
dandiset_identifier_regex = f"^{DANDISET_ID_REGEX}$"


class DandiInstance(NamedTuple):
@dataclass(frozen=True)
class DandiInstance:
name: str
gui: Optional[str]
redirector: Optional[str]
api: Optional[str]
api: str

@property
def redirector(self) -> None:
# For "backwards compatibility"
return None

def urls(self) -> Iterator[str]:
if self.gui is not None:
yield self.gui
yield self.api


# So it could be easily mapped to external IP (e.g. from within VM)
# to test against instance running outside of current environment
instancehost = os.environ.get("DANDI_INSTANCEHOST", "localhost")

redirector_base = os.environ.get("DANDI_REDIRECTOR_BASE", "https://dandiarchive.org")

known_instances = {
"dandi": DandiInstance(
"https://gui.dandiarchive.org",
redirector_base,
"dandi",
"https://dandiarchive.org",
"https://api.dandiarchive.org/api",
),
"dandi-devel": DandiInstance(
"https://gui-beta-dandiarchive-org.netlify.app",
None,
None,
),
"dandi-staging": DandiInstance(
"dandi-staging",
"https://gui-staging.dandiarchive.org",
None,
"https://api-staging.dandiarchive.org/api",
),
"dandi-api-local-docker-tests": DandiInstance(
f"http://{instancehost}:8085", None, f"http://{instancehost}:8000/api"
"dandi-api-local-docker-tests",
f"http://{instancehost}:8085",
f"http://{instancehost}:8000/api",
),
}
# to map back url: name
known_instances_rev = {vv: k for k, v in known_instances.items() for vv in v if vv}
known_instances_rev = {
vv: k for k, v in known_instances.items() for vv in v.urls() if vv
}

file_operation_modes = [
"dry",
Expand Down
53 changes: 26 additions & 27 deletions dandi/dandiapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@
ZARR_DELETE_BATCH_SIZE,
DandiInstance,
EmbargoStatus,
known_instances,
known_instances_rev,
)
from .exceptions import HTTP404Error, NotFoundError, SchemaVersionError
from .keyring import keyring_lookup, keyring_save
Expand All @@ -56,6 +54,7 @@
check_dandi_version,
chunked,
ensure_datetime,
get_instance,
is_interactive,
is_page2_url,
)
Expand Down Expand Up @@ -412,27 +411,36 @@ class DandiAPIClient(RESTFullAPIClient):
"""A client for interacting with a Dandi Archive server"""

def __init__(
self, api_url: Optional[str] = None, token: Optional[str] = None
self,
api_url: Optional[str] = None,
token: Optional[str] = None,
dandi_instance: Optional[DandiInstance] = None,
) -> None:
"""
Construct a client instance.
Construct a client instance for the given API URL or Dandi instance
(mutually exclusive options). If no URL or instance is supplied, the
instance specified by the :envvar:`DANDI_INSTANCE` environment variable
(default value: ``"dandi"``) is used.

:param str api_url: Base API URL of the server to interact with.
- For DANDI production, use ``"https://api.dandiarchive.org/api"``
- For DANDI staging, use ``"https://api-staging.dandiarchive.org/api"``
- If no URL is supplied, the URL is looked up in `known_instances` using the value of the
:envvar:`DANDI_INSTANCE` environment variable (default value: ``"dandi"``).
:param str token: User API Key. Note that different instance APIs have different
keys.
- For DANDI production, use ``"https://api.dandiarchive.org/api"``
- For DANDI staging, use
``"https://api-staging.dandiarchive.org/api"``
:param str token: User API Key. Note that different instance APIs have
different keys.
"""

check_dandi_version()
if api_url is None:
instance_name = os.environ.get("DANDI_INSTANCE", "dandi")
api_url = known_instances[instance_name].api
if api_url is None:
raise ValueError(f"No API URL for instance {instance_name!r}")
if dandi_instance is None:
instance_name = os.environ.get("DANDI_INSTANCE", "dandi")
dandi_instance = get_instance(instance_name)
api_url = dandi_instance.api
elif dandi_instance is not None:
raise ValueError("api_url and dandi_instance are mutually exclusive")
else:
dandi_instance = get_instance(api_url)
super().__init__(api_url)
self.dandi_instance: DandiInstance = dandi_instance
if token is not None:
self.authenticate(token)

Expand All @@ -450,9 +458,7 @@ def for_dandi_instance(
If no token is supplied and ``authenticate`` is true,
`dandi_authenticate()` is called on the instance before returning it.
"""
if isinstance(instance, str):
instance = known_instances[instance]
client = cls(instance.api, token=token)
client = cls(dandi_instance=get_instance(instance), token=token)
if token is None and authenticate:
client.dandi_authenticate()
return client
Expand Down Expand Up @@ -527,19 +533,12 @@ def dandi_authenticate(self) -> None:
break

def _get_keyring_ids(self) -> tuple[str, str]:
try:
client_name = known_instances_rev[self.api_url]
except KeyError:
raise NotImplementedError("TODO client name derivation for keyring")
client_name = self.dandi_instance.name
return (client_name, f"dandi-api-{client_name}")

@property
def _instance_id(self) -> str:
url = self.api_url.rstrip("/")
try:
return known_instances_rev[url].upper()
except KeyError:
return url
return self.dandi_instance.name.upper()

def get_dandiset(
self, dandiset_id: str, version_id: Optional[str] = None, lazy: bool = True
Expand Down
Loading