Skip to content

Commit 338fb87

Browse files
Merge branch 'master' into enh-lad-framework
2 parents 00bc42d + 7980524 commit 338fb87

17 files changed

Lines changed: 280 additions & 44 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
# 0.74.3 (Sat Feb 14 2026)
2+
3+
#### 🐛 Bug Fix
4+
5+
- bf: pass aiohttp timeouts to fsspec to fix test hang [#1795](https://github.com/dandi/dandi-cli/pull/1795) ([@yarikoptic](https://github.com/yarikoptic))
6+
- Enhance dandiset metadata error messages [#1790](https://github.com/dandi/dandi-cli/pull/1790) ([@yarikoptic](https://github.com/yarikoptic) [@yarikoptic-gitmate](https://github.com/yarikoptic-gitmate))
7+
- Fix type annotation in upload sync path prefix calculation [#1794](https://github.com/dandi/dandi-cli/pull/1794) ([@yarikoptic](https://github.com/yarikoptic) [@yarikoptic-gitmate](https://github.com/yarikoptic-gitmate))
8+
- Fix macOS-15-intel CI failures: h5py and opencv-python regressions [#1783](https://github.com/dandi/dandi-cli/pull/1783) ([@yarikoptic](https://github.com/yarikoptic))
9+
10+
#### 📝 Documentation
11+
12+
- Add module docstrings to validation and NWB utilities [#1789](https://github.com/dandi/dandi-cli/pull/1789) ([@yarikoptic](https://github.com/yarikoptic) [@yarikoptic-gitmate](https://github.com/yarikoptic-gitmate))
13+
14+
#### Authors: 2
15+
16+
- GitMate for @yarikoptic ([@yarikoptic-gitmate](https://github.com/yarikoptic-gitmate))
17+
- Yaroslav Halchenko ([@yarikoptic](https://github.com/yarikoptic))
18+
19+
---
20+
121
# 0.74.2 (Fri Jan 30 2026)
222

323
#### 🐛 Bug Fix

dandi/consts.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"""Constants and configuration for DANDI CLI.
2+
3+
This module defines constants used throughout the DANDI CLI including:
4+
- Metadata field definitions for NWB files
5+
- Known DANDI Archive instances and their configurations
6+
- File organization patterns and BIDS-related constants
7+
- Request timeouts and retry settings
8+
"""
9+
110
from __future__ import annotations
211

312
from collections.abc import Iterator

dandi/dandiapi.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
"""REST API client for interacting with DANDI Archive instances.
2+
3+
This module provides client classes for communicating with DANDI Archive API
4+
servers, including asset management, dandiset operations, and authentication.
5+
6+
The main classes are:
7+
- DandiAPIClient: High-level client for DANDI API operations
8+
- RESTFullAPIClient: Base HTTP client with retry and authentication
9+
- RemoteDandiset: Represents a dandiset on the server
10+
- RemoteAsset: Represents an asset (file) on the server
11+
"""
12+
113
from __future__ import annotations
214

315
from abc import ABC, abstractmethod
@@ -435,7 +447,11 @@ def __init__(
435447
dandi_instance = get_instance(instance_name)
436448
api_url = dandi_instance.api
437449
elif dandi_instance is not None:
438-
raise ValueError("api_url and dandi_instance are mutually exclusive")
450+
raise ValueError(
451+
"api_url and dandi_instance are mutually exclusive. "
452+
"Use either 'api_url' to specify a custom API URL, "
453+
"or 'dandi_instance' to use a registered DANDI instance, but not both."
454+
)
439455
else:
440456
dandi_instance = get_instance(api_url)
441457
super().__init__(api_url)
@@ -562,7 +578,10 @@ def get_dandiset(
562578
self, self.get(f"/dandisets/{dandiset_id}/")
563579
)
564580
except HTTP404Error:
565-
raise NotFoundError(f"No such Dandiset: {dandiset_id!r}")
581+
raise NotFoundError(
582+
f"No such Dandiset: {dandiset_id!r}. "
583+
"Verify the Dandiset ID is correct and that you have access. "
584+
)
566585
if version_id is not None and version_id != d.version_id:
567586
if version_id == DRAFT:
568587
return d.for_version(d.draft_version)
@@ -732,7 +751,11 @@ def get_asset(self, asset_id: str) -> BaseRemoteAsset:
732751
try:
733752
info = self.get(f"/assets/{asset_id}/info/")
734753
except HTTP404Error:
735-
raise NotFoundError(f"No such asset: {asset_id!r}")
754+
raise NotFoundError(
755+
f"No such asset: {asset_id!r}. "
756+
"Verify the asset ID is correct. "
757+
"Use 'dandi ls' to list available assets."
758+
)
736759
metadata = info.pop("metadata", None)
737760
return BaseRemoteAsset.from_base_data(self, info, metadata)
738761

@@ -1306,7 +1329,11 @@ def get_asset_by_path(self, path: str) -> RemoteAsset:
13061329
a for a in self.get_assets_with_path_prefix(path) if a.path == path
13071330
)
13081331
except ValueError:
1309-
raise NotFoundError(f"No asset at path {path!r}")
1332+
raise NotFoundError(
1333+
f"No asset at path {path!r} in version {self.version_id}. "
1334+
"Verify the path is correct and the asset exists in this version. "
1335+
"Use 'dandi ls' to list available assets."
1336+
)
13101337
else:
13111338
return asset
13121339

dandi/dandiset.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ def __init__(
4242
if not allow_empty and not os.path.lexists(
4343
self.path_obj / dandiset_metadata_file
4444
):
45-
raise ValueError(f"No dandiset at {path}")
45+
raise ValueError(
46+
f"No dandiset at {path}. "
47+
f"The directory does not contain a '{dandiset_metadata_file}' file. "
48+
"Use 'dandi download' to download a dandiset or check the path."
49+
)
4650
self.metadata: dict | None = None
4751
self._metadata_file_obj = self.path_obj / dandiset_metadata_file
4852
self._load_metadata()
@@ -139,11 +143,17 @@ def _get_identifier(metadata: dict) -> str | None:
139143
@property
140144
def identifier(self) -> str:
141145
if self.metadata is None:
142-
raise ValueError("No metadata record found in Dandiset")
146+
raise ValueError(
147+
f"No metadata record found in Dandiset at {self.path}. "
148+
f"The '{dandiset_metadata_file}' file may be empty or corrupted. "
149+
"Use 'dandi download' to re-download the dandiset metadata."
150+
)
143151
id_ = self._get_identifier(self.metadata)
144152
if not id_:
145153
raise ValueError(
146-
f"Found no dandiset.identifier in metadata record: {self.metadata}"
154+
f"Found no dandiset.identifier in metadata record. "
155+
f"The '{dandiset_metadata_file}' file must contain an 'identifier' field. "
156+
f"Metadata: {self.metadata}"
147157
)
148158
return id_
149159

dandi/delete.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
"""Delete assets and dandisets from DANDI Archive.
2+
3+
This module provides functionality for deleting assets and entire dandisets
4+
from DANDI Archive instances. It supports:
5+
- Single and batch asset deletion
6+
- Dandiset deletion with confirmation
7+
- URL-based and path-based deletion
8+
- Skip-missing option for non-existent resources
9+
"""
10+
111
from __future__ import annotations
212

313
from collections.abc import Iterable, Iterator

dandi/download.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
"""Download assets from DANDI Archive.
2+
3+
This module provides functionality for downloading files and Zarr archives
4+
from DANDI Archive instances. It supports:
5+
- Individual file downloads with integrity verification
6+
- Zarr archive downloads with parallel entry handling
7+
- Resume capability for interrupted downloads
8+
- Progress tracking and error recovery
9+
"""
10+
111
from __future__ import annotations
212

313
from collections import Counter, deque

dandi/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
"""Custom exceptions for DANDI CLI operations.
2+
3+
This module defines exception classes used throughout the DANDI CLI for
4+
handling various error conditions including network errors, validation
5+
failures, and version incompatibilities.
6+
"""
7+
18
from __future__ import annotations
29

310
import requests

dandi/misctypes.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,27 @@ def open(self) -> IO[bytes]:
345345
# Optional dependency:
346346
import fsspec
347347

348+
from aiohttp import ClientTimeout
349+
348350
# We need to call open() on the return value of fsspec.open() because
349351
# otherwise the filehandle will only be opened when used to enter a
350352
# context manager.
351-
return cast(IO[bytes], fsspec.open(self.url, mode="rb").open())
353+
#
354+
# Pass explicit timeouts to aiohttp to prevent indefinite hangs in
355+
# fsspec's sync() wrapper. Without these, a stalled connection to S3
356+
# (or minio in tests) causes fsspec's background IO thread to block
357+
# forever, which in turn blocks the calling thread in
358+
# threading.Event.wait() — see https://github.com/fsspec/filesystem_spec/issues/1666
359+
return cast(
360+
IO[bytes],
361+
fsspec.open(
362+
self.url,
363+
mode="rb",
364+
client_kwargs={
365+
"timeout": ClientTimeout(total=120, sock_read=60, sock_connect=30)
366+
},
367+
).open(),
368+
)
352369

353370
def get_size(self) -> int:
354371
return self.size

dandi/move.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
"""Move and rename assets in DANDI Archive.
2+
3+
This module provides functionality for moving and renaming assets both
4+
locally and remotely in DANDI Archive instances. Features include:
5+
- Local file reorganization
6+
- Remote asset path changes
7+
- Combined local and remote moves
8+
- Conflict resolution (skip, overwrite, error)
9+
- Validation of move operations
10+
"""
11+
112
from __future__ import annotations
213

314
from abc import ABC, abstractmethod
@@ -13,7 +24,7 @@
1324
from typing import NewType
1425

1526
from . import get_logger
16-
from .consts import DandiInstance
27+
from .consts import DandiInstance, dandiset_metadata_file
1728
from .dandiapi import DandiAPIClient, RemoteAsset, RemoteDandiset
1829
from .dandiarchive import DandisetURL, parse_dandi_url
1930
from .dandiset import Dandiset
@@ -233,7 +244,11 @@ def resolve(self, path: str) -> tuple[AssetPath, bool]:
233244
posixpath.normpath(posixpath.join(self.subpath.as_posix(), path))
234245
)
235246
if p.parts and p.parts[0] == os.pardir:
236-
raise ValueError(f"{path!r} is outside of Dandiset")
247+
raise ValueError(
248+
f"{path!r} is outside of Dandiset. "
249+
"Paths cannot use '..' to navigate above the Dandiset root. "
250+
"All assets must remain within the Dandiset directory structure."
251+
)
237252
return (AssetPath(str(p)), path.endswith("/"))
238253

239254
def calculate_moves(
@@ -472,11 +487,17 @@ def get_path(self, path: str, is_src: bool = True) -> File | Folder:
472487
rpath, needs_dir = self.resolve(path)
473488
p = self.dandiset_path / rpath
474489
if not os.path.lexists(p):
475-
raise NotFoundError(f"No asset at local path {path!r}")
490+
raise NotFoundError(
491+
f"No asset at local path {path!r}. "
492+
"Verify the path is correct and the file exists locally."
493+
)
476494
if p.is_dir():
477495
if is_src:
478496
if p == self.dandiset_path / self.subpath:
479-
raise ValueError("Cannot move current working directory")
497+
raise ValueError(
498+
"Cannot move current working directory. "
499+
"Change to a different directory before moving this location."
500+
)
480501
files = [
481502
df.filepath.relative_to(p).as_posix()
482503
for df in find_dandi_files(
@@ -488,7 +509,10 @@ def get_path(self, path: str, is_src: bool = True) -> File | Folder:
488509
files = []
489510
return Folder(rpath, files)
490511
elif needs_dir:
491-
raise ValueError(f"Local path {path!r} is a file")
512+
raise ValueError(
513+
f"Local path {path!r} is a file but a directory was expected. "
514+
"Use a path ending with '/' for directories."
515+
)
492516
else:
493517
return File(rpath)
494518

@@ -612,7 +636,10 @@ def get_path(self, path: str, is_src: bool = True) -> File | Folder:
612636
file_found = False
613637
if rpath == self.subpath.as_posix():
614638
if is_src:
615-
raise ValueError("Cannot move current working directory")
639+
raise ValueError(
640+
"Cannot move current working directory. "
641+
"Change to a different directory before moving this location."
642+
)
616643
else:
617644
return Folder(rpath, [])
618645
for p in self.assets.keys():
@@ -629,7 +656,10 @@ def get_path(self, path: str, is_src: bool = True) -> File | Folder:
629656
if relcontents:
630657
return Folder(rpath, relcontents)
631658
if needs_dir and file_found:
632-
raise ValueError(f"Remote path {path!r} is a file")
659+
raise ValueError(
660+
f"Remote path {path!r} is a file but a directory was expected. "
661+
"Use a path ending with '/' for directories."
662+
)
633663
elif (
634664
not needs_dir
635665
and not is_src
@@ -641,7 +671,11 @@ def get_path(self, path: str, is_src: bool = True) -> File | Folder:
641671
# remote directory.
642672
return Folder(rpath, [])
643673
else:
644-
raise NotFoundError(f"No asset at remote path {path!r}")
674+
raise NotFoundError(
675+
f"No asset at remote path {path!r}. "
676+
"Verify the path is correct and the asset exists on the server. "
677+
"Use 'dandi ls' to list available assets."
678+
)
645679

646680
def is_dir(self, path: AssetPath) -> bool:
647681
"""Returns true if the given path points to a directory"""
@@ -891,7 +925,11 @@ def find_dandiset_and_subpath(path: Path) -> tuple[Dandiset, Path]:
891925
path = path.absolute()
892926
ds = Dandiset.find(path)
893927
if ds is None:
894-
raise ValueError(f"{path}: not a Dandiset")
928+
raise ValueError(
929+
f"{path}: not a Dandiset. "
930+
f"The directory does not contain a '{dandiset_metadata_file}' file. "
931+
"Use 'dandi download' to download a dandiset first."
932+
)
895933
return (ds, path.relative_to(ds.path))
896934

897935

dandi/organize.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
"""
2-
ATM primarily a sandbox for some functionality for dandi organize
1+
"""Organize and structure NWB files according to DANDI conventions.
2+
3+
This module provides functionality for organizing neuroscience data files
4+
according to DANDI's file organization schema. Features include:
5+
- Automatic path generation from metadata
6+
- BIDS-like subject/session organization
7+
- Metadata-driven file naming
8+
- Validation of organized paths
9+
- Support for videos and generic files
310
"""
411

512
from __future__ import annotations

0 commit comments

Comments
 (0)