Skip to content

Commit 3ee7aba

Browse files
committed
Add BIDSAsset.get_bids_metadata() to get only the BIDS metadata of an asset
Getting the metadata of an NWB file in a BIDS dataset recursed because of a cycle between two methods that each merge BIDS and NWB metadata: `NWBBIDSAsset.get_metadata()` extracts the NWB metadata with `dandi.metadata.nwb.get_metadata()`, which itself got the BIDS metadata of the file with `get_metadata()` of its `NWBBIDSAsset`. Calling `BIDSAsset.get_metadata()` unbound only patched this one call site. Instead, give BIDS assets a method returning only their BIDS metadata, which no subclass overrides to read the content, and use it wherever only the BIDS metadata is wanted: when extracting the metadata of an NWB file, and in `NWBBIDSAsset.get_metadata()` and `ZarrBIDSAsset.get_metadata()` (no change in behavior). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
1 parent e38507b commit 3ee7aba

3 files changed

Lines changed: 49 additions & 10 deletions

File tree

‎dandi/files/bids.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,18 @@ def get_metadata(
248248
digest: Digest | None = None,
249249
ignore_errors: bool = True,
250250
) -> BareAsset:
251+
return self.get_bids_metadata(digest)
252+
253+
def get_bids_metadata(self, digest: Digest | None = None) -> BareAsset:
254+
"""
255+
Return the metadata of the asset that comes from the BIDS dataset (its
256+
path and files such as :file:`participants.tsv`) only.
257+
258+
Unlike `get_metadata()`, which subclasses override to also extract
259+
metadata from the content of the asset (e.g., `NWBBIDSAsset` from the
260+
NWB file), this never reads the content, so that the extraction of
261+
metadata from the content can use it without recursing.
262+
"""
251263
metadata = self.bids_dataset_description.get_asset_metadata(self)
252264
start_time = end_time = datetime.now().astimezone()
253265
add_common_metadata(metadata, self.filepath, start_time, end_time, digest)
@@ -283,7 +295,7 @@ def get_metadata(
283295
digest: Digest | None = None,
284296
ignore_errors: bool = True,
285297
) -> BareAsset:
286-
bids_metadata = BIDSAsset.get_metadata(self, digest, ignore_errors)
298+
bids_metadata = self.get_bids_metadata(digest)
287299
nwb_metadata = NWBAsset.get_metadata(self, digest, ignore_errors)
288300
return BareAsset(
289301
**{
@@ -318,10 +330,7 @@ def get_metadata(
318330
digest: Digest | None = None,
319331
ignore_errors: bool = True,
320332
) -> BareAsset:
321-
metadata = self.bids_dataset_description.get_asset_metadata(self)
322-
start_time = end_time = datetime.now().astimezone()
323-
add_common_metadata(metadata, self.filepath, start_time, end_time, digest)
324-
metadata.path = self.path
333+
metadata = self.get_bids_metadata(digest)
325334
metadata.encodingFormat = ZARR_MIME_TYPE
326335
return metadata
327336

‎dandi/metadata/nwb.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,9 @@ def get_metadata(
6969
assert isinstance(df, bids.BIDSAsset)
7070
if not digest:
7171
digest = DUMMY_DANDI_ETAG
72-
# Only the BIDS metadata: `df` is an `NWBBIDSAsset`, whose own
73-
# `get_metadata()` would extract the NWB metadata by calling this
74-
# function again, recursing until `RecursionError`
75-
path_metadata = bids.BIDSAsset.get_metadata(df, digest=digest)
72+
# Not `df.get_metadata()`, which for an NWB file would extract
73+
# its NWB metadata, i.e., call this function again
74+
path_metadata = df.get_bids_metadata(digest=digest)
7675
meta["bids_version"] = df.get_validation_bids_version()
7776
# there might be a more elegant way to do this:
7877
if path_metadata.wasAttributedTo:

‎dandi/tests/test_metadata.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@
4343
from .fixtures import SampleDandiset
4444
from .skip import mark
4545
from .. import __version__
46-
from ..consts import metadata_nwb_subject_fields
46+
from ..consts import dandiset_metadata_file, metadata_nwb_subject_fields
4747
from ..dandiapi import RemoteBlobAsset
48+
from ..files import NWBBIDSAsset, dandi_file, find_bids_dataset_description
4849
from ..metadata import nwb as metadata_nwb
4950
from ..metadata.core import prepare_metadata
5051
from ..metadata.nwb import get_metadata, nwb2asset
@@ -149,6 +150,36 @@ def test_bids_nwb_metadata_no_recursion(
149150
assert metadata["sex"] == "U"
150151

151152

153+
@pytest.mark.ai_generated
154+
def test_nwb_bids_asset_metadata_no_recursion(
155+
bids_examples: Path, tmp_path: Path, mocker: MockerFixture
156+
) -> None:
157+
# The metadata of an NWBBIDSAsset merges its BIDS and NWB metadata,
158+
# extracting the latter (which itself includes BIDS metadata) only once
159+
dpath = tmp_path / "ieeg_epilepsyNWB"
160+
shutil.copytree(bids_examples / "ieeg_epilepsyNWB", dpath)
161+
(dpath / dandiset_metadata_file).write_text("identifier: '000027'\n")
162+
file_path = (
163+
dpath
164+
/ "sub-01"
165+
/ "ses-postimp"
166+
/ "ieeg"
167+
/ "sub-01_ses-postimp_task-seizure_run-01_ieeg.nwb"
168+
)
169+
# (The asset refers to it only weakly)
170+
bids_dataset_description = find_bids_dataset_description(file_path)
171+
df = dandi_file(file_path, dpath, bids_dataset_description=bids_dataset_description)
172+
assert isinstance(df, NWBBIDSAsset)
173+
spy = mocker.spy(metadata_nwb, "nwb2asset")
174+
metadata = df.get_metadata(digest=DUMMY_DANDI_ETAG, ignore_errors=False)
175+
assert spy.call_count == 1
176+
assert metadata.wasAttributedTo is not None
177+
participant = metadata.wasAttributedTo[0]
178+
assert participant.identifier == "01"
179+
assert participant.sex is not None
180+
assert participant.sex.name == "Unknown"
181+
182+
152183
@pytest.mark.parametrize(
153184
"age,duration",
154185
[

0 commit comments

Comments
 (0)