diff --git a/dandi/cli/tests/test_cmd_validate.py b/dandi/cli/tests/test_cmd_validate.py index 66e0cbb0c..d108cf8b4 100644 --- a/dandi/cli/tests/test_cmd_validate.py +++ b/dandi/cli/tests/test_cmd_validate.py @@ -6,7 +6,14 @@ from ..cmd_validate import _process_issues, validate from ...tests.fixtures import BIDS_ERROR_TESTDATA_SELECTION -from ...validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from ...validate_types import ( + Origin, + OriginType, + Scope, + Severity, + ValidationResult, + Validator, +) @pytest.mark.parametrize("dataset", BIDS_ERROR_TESTDATA_SELECTION) @@ -68,13 +75,16 @@ def test_validate_nwb_path_grouping(organized_nwb_dir4: Path) -> None: def test_process_issues(capsys): + origin_validation_nwbinspector = Origin( + type=OriginType.VALIDATION, + validator=Validator.nwbinspector, + validator_version="", + ) + issues = [ ValidationResult( id="NWBI.check_data_orientation", - origin=ValidationOrigin( - name="nwbinspector", - version="", - ), + origin=origin_validation_nwbinspector, scope=Scope.FILE, message="Data may be in the wrong orientation.", path=Path("dir0/sub-mouse004/sub-mouse004.nwb"), @@ -82,10 +92,7 @@ def test_process_issues(capsys): ), ValidationResult( id="NWBI.check_data_orientation", - origin=ValidationOrigin( - name="nwbinspector", - version="", - ), + origin=origin_validation_nwbinspector, scope=Scope.FILE, message="Data may be in the wrong orientation.", path=Path("dir1/sub-mouse001/sub-mouse001.nwb"), @@ -93,10 +100,7 @@ def test_process_issues(capsys): ), ValidationResult( id="NWBI.check_missing_unit", - origin=ValidationOrigin( - name="nwbinspector", - version="", - ), + origin=origin_validation_nwbinspector, scope=Scope.FILE, message="Missing text for attribute 'unit'.", path=Path("dir1/sub-mouse001/sub-mouse001.nwb"), diff --git a/dandi/files/bases.py b/dandi/files/bases.py index 8db802656..e7d382a83 100644 --- a/dandi/files/bases.py +++ b/dandi/files/bases.py @@ -14,6 +14,7 @@ from xml.etree.ElementTree import fromstring import dandischema +from dandischema.consts import DANDI_SCHEMA_VERSION from dandischema.digests.dandietag import DandiETag from dandischema.models import BareAsset, CommonModel from dandischema.models import Dandiset as DandisetMeta @@ -27,7 +28,17 @@ from dandi.metadata.core import get_default_metadata from dandi.misctypes import DUMMY_DANDI_ETAG, Digest, LocalReadableFile, P from dandi.utils import post_upload_size_check, pre_upload_size_check, yaml_load -from dandi.validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from dandi.validate_types import ( + ORIGIN_INTERNAL_DANDI, + ORIGIN_VALIDATION_DANDI, + Origin, + OriginType, + Scope, + Severity, + Standard, + ValidationResult, + Validator, +) lgr = dandi.get_logger() @@ -201,13 +212,11 @@ def get_validation_errors( ) return [ ValidationResult( - origin=ValidationOrigin( - name="dandi", - version=dandi.__version__, - ), + origin=ORIGIN_INTERNAL_DANDI, severity=Severity.ERROR, id="dandi.SOFTWARE_ERROR", scope=Scope.FILE, + origin_result=e, # metadata=metadata, path=self.filepath, # note that it is not relative .path message=f"Failed to read metadata: {e}", @@ -526,9 +535,10 @@ def get_validation_errors( else: # make sure that we have some basic metadata fields we require try: - origin = ValidationOrigin( - name="nwbinspector", - version=str(_get_nwb_inspector_version()), + origin_validation_nwbinspector = Origin( + type=OriginType.VALIDATION, + validator=Validator.nwbinspector, + validator_version=str(_get_nwb_inspector_version()), ) for error in inspect_nwbfile( @@ -549,10 +559,11 @@ def get_validation_errors( } errors.append( ValidationResult( - origin=origin, + origin=origin_validation_nwbinspector, severity=severity, id=f"NWBI.{error.check_function_name}", scope=Scope.FILE, + origin_result=error, path=Path(error.file_path), message=error.message, # Assuming multiple sessions per multiple subjects, @@ -694,10 +705,7 @@ def _check_required_fields( message = f"Required field {f!r} has no value" errors.append( ValidationResult( - origin=ValidationOrigin( - name="dandischema", - version=dandischema.__version__, # type: ignore[attr-defined] - ), + origin=ORIGIN_VALIDATION_DANDI, severity=Severity.ERROR, id="dandischema.requred_field", scope=Scope.FILE, @@ -709,10 +717,7 @@ def _check_required_fields( message = f"Required field {f!r} has value {v!r}" errors.append( ValidationResult( - origin=ValidationOrigin( - name="dandischema", - version=dandischema.__version__, # type: ignore[attr-defined] - ), + origin=ORIGIN_VALIDATION_DANDI, severity=Severity.WARNING, id="dandischema.placeholder_value", scope=Scope.FILE, @@ -793,13 +798,17 @@ def _pydantic_errors_to_validation_results( message = e.get("message", e.get("msg", None)) out.append( ValidationResult( - origin=ValidationOrigin( - name="dandischema", - version=dandischema.__version__, # type: ignore[attr-defined] + origin=Origin( + type=OriginType.VALIDATION, + validator=Validator.dandischema, + validator_version=dandischema.__version__, # type: ignore[attr-defined] + standard=Standard.DANDI_SCHEMA, + standard_version=DANDI_SCHEMA_VERSION, ), severity=Severity.ERROR, id=id, scope=scope, + origin_result=e, path=file_path, message=message, # TODO? dataset_path=dataset_path, diff --git a/dandi/files/bids.py b/dandi/files/bids.py index 8541b5030..907a14c59 100644 --- a/dandi/files/bids.py +++ b/dandi/files/bids.py @@ -111,7 +111,7 @@ def _validate(self) -> None: self._asset_metadata[bids_path] = prepare_metadata( result.metadata ) - self._bids_version = result.origin.bids_version + self._bids_version = result.origin.standard_version def get_asset_errors(self, asset: BIDSAsset) -> list[ValidationResult]: """:meta private:""" diff --git a/dandi/files/zarr.py b/dandi/files/zarr.py index f2f79cbde..4a19e34bd 100644 --- a/dandi/files/zarr.py +++ b/dandi/files/zarr.py @@ -42,11 +42,47 @@ ) from .bases import LocalDirectoryAsset -from ..validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from ..validate_types import ( + ORIGIN_VALIDATION_DANDI_ZARR, + Origin, + OriginType, + Scope, + Severity, + Standard, + ValidationResult, + Validator, +) lgr = get_logger() +def get_zarr_format_version(data: Any) -> str: + """ + Get the Zarr storage specification version from a Zarr data object + + Parameters + ---------- + data : zarr.core.Array or zarr.hierarchy.Group + The Zarr data object from which to extract the storage specification version + + Returns + ------- + str + The Zarr storage specification version, + https://zarr-specs.readthedocs.io/en/latest/specs.html, used in the Zarr data + object + """ + import zarr # Delay heavy import + + if isinstance(data, zarr.Group): + meta = json.loads(data.store.get(".zgroup")) + elif isinstance(data, zarr.Array): + meta = json.loads(data.store.get(".zarray")) + else: + raise TypeError("`data` must be a `zarr.core.Array` or `zarr.hierarchy.Group`") + return str(meta["zarr_format"]) + + @dataclass class LocalZarrEntry(BasePath): """A file or directory within a `ZarrAsset`""" @@ -209,34 +245,37 @@ def get_validation_errors( import zarr errors: list[ValidationResult] = [] + origin_internal_zarr: Origin = Origin( + type=OriginType.INTERNAL, + validator=Validator.zarr, + validator_version=zarr.__version__, + standard=Standard.ZARR, + ) + try: data = zarr.open(str(self.filepath)) - except Exception: + except Exception as e: if devel_debug: raise errors.append( ValidationResult( - origin=ValidationOrigin( - name="zarr", - version=zarr.version.version, - ), + origin=origin_internal_zarr, severity=Severity.ERROR, id="zarr.cannot_open", scope=Scope.FILE, + origin_result=e, path=self.filepath, message="Error opening file.", ) ) data = None + if isinstance(data, zarr.Group) and not data: errors.append( ValidationResult( - origin=ValidationOrigin( - name="zarr", - version=zarr.version.version, - ), + origin=ORIGIN_VALIDATION_DANDI_ZARR, severity=Severity.ERROR, - id="zarr.empty_group", + id="dandi_zarr.empty_group", scope=Scope.FILE, path=self.filepath, message="Zarr group is empty.", @@ -248,12 +287,9 @@ def get_validation_errors( raise ValueError(msg) errors.append( ValidationResult( - origin=ValidationOrigin( - name="zarr", - version=zarr.version.version, - ), + origin=ORIGIN_VALIDATION_DANDI_ZARR, severity=Severity.ERROR, - id="zarr.tree_depth_exceeded", + id="dandi_zarr.tree_depth_exceeded", scope=Scope.FILE, path=self.filepath, message=msg, diff --git a/dandi/organize.py b/dandi/organize.py index 0de237da7..cca5a0e31 100644 --- a/dandi/organize.py +++ b/dandi/organize.py @@ -18,7 +18,7 @@ import ruamel.yaml -from . import __version__, get_logger +from . import get_logger from .consts import dandi_layout_fields from .dandiset import Dandiset from .exceptions import OrganizeImpossibleError @@ -35,7 +35,12 @@ move_file, yaml_load, ) -from .validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from .validate_types import ( + ORIGIN_VALIDATION_DANDI_LAYOUT, + Scope, + Severity, + ValidationResult, +) lgr = get_logger() @@ -1119,7 +1124,7 @@ def validate_organized_path( errors.append( ValidationResult( id="DANDI.NON_DANDI_FILENAME", - origin=ValidationOrigin(name="dandi", version=__version__), + origin=ORIGIN_VALIDATION_DANDI_LAYOUT, severity=Severity.ERROR, scope=Scope.FILE, path=filepath, @@ -1135,7 +1140,7 @@ def validate_organized_path( errors.append( ValidationResult( id="DANDI.NON_DANDI_FOLDERNAME", - origin=ValidationOrigin(name="dandi", version=__version__), + origin=ORIGIN_VALIDATION_DANDI_LAYOUT, severity=Severity.ERROR, scope=Scope.FOLDER, path=filepath, @@ -1151,7 +1156,7 @@ def validate_organized_path( errors.append( ValidationResult( id="DANDI.METADATA_MISMATCH_SUBJECT", - origin=ValidationOrigin(name="dandi", version=__version__), + origin=ORIGIN_VALIDATION_DANDI_LAYOUT, severity=Severity.ERROR, scope=Scope.FILE, path=filepath, diff --git a/dandi/pynwb_utils.py b/dandi/pynwb_utils.py index 8656ee779..ace9de6f4 100644 --- a/dandi/pynwb_utils.py +++ b/dandi/pynwb_utils.py @@ -29,7 +29,15 @@ ) from .misctypes import Readable from .utils import get_module_version, is_url -from .validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from .validate_types import ( + Origin, + OriginType, + Scope, + Severity, + Standard, + ValidationResult, + Validator, +) lgr = get_logger() @@ -351,48 +359,6 @@ def validate(path: str | Path, devel_debug: bool = False) -> list[ValidationResu """ path = str(path) # Might come in as pathlib's PATH errors: list[ValidationResult] = [] - try: - if Version(pynwb.__version__) >= Version("3.0.0"): - error_outputs = pynwb.validate(path=path) - elif Version(pynwb.__version__) >= Version( - "2.2.0" - ): # Use cached namespace feature - # argument get_cached_namespaces is True by default - error_outputs, _ = pynwb.validate(paths=[path]) - else: # Fallback if an older version - with pynwb.NWBHDF5IO(path=path, mode="r", load_namespaces=True) as reader: - error_outputs = pynwb.validate(io=reader) - for error in error_outputs: - errors.append( - ValidationResult( - origin=ValidationOrigin( - name="pynwb", - version=pynwb.__version__, - ), - severity=Severity.ERROR, - id=f"pynwb.{error}", - scope=Scope.FILE, - path=Path(path), - message=f"Failed to validate. {error.reason}", - within_asset_paths={path: error.location}, - ) - ) - except Exception as exc: - if devel_debug: - raise - errors.append( - ValidationResult( - origin=ValidationOrigin( - name="pynwb", - version=pynwb.__version__, - ), - severity=Severity.ERROR, - id="pynwb.GENERIC", - scope=Scope.FILE, - path=Path(path), - message=f"{exc}", - ) - ) # To overcome # https://github.com/NeurodataWithoutBorders/pynwb/issues/1090 @@ -401,6 +367,7 @@ def validate(path: str | Path, devel_debug: bool = False) -> list[ValidationResu r"general/(experimenter|related_publications)\): " r"incorrect shape - expected an array of shape .\[None\]." ) + version = None try: version = get_nwb_version(path, sanitize=False) except Exception: @@ -414,13 +381,15 @@ def validate(path: str | Path, devel_debug: bool = False) -> list[ValidationResu for e in nwb_errors: errors.append( ValidationResult( - origin=ValidationOrigin( - name="pynwb", - version=pynwb.__version__, + origin=Origin( + type=OriginType.VALIDATION, + validator=Validator.dandi, + validator_version=__version__, ), severity=Severity.ERROR, id="pynwb.GENERIC", scope=Scope.FILE, + origin_result=e, path=Path(path), message=e, ) @@ -444,6 +413,57 @@ def validate(path: str | Path, devel_debug: bool = False) -> list[ValidationResu len(errors_) - len(errors), path, ) + + try: + if Version(pynwb.__version__) >= Version("3.0.0"): + error_outputs = pynwb.validate(path=path) + elif Version(pynwb.__version__) >= Version( + "2.2.0" + ): # Use cached namespace feature + # argument get_cached_namespaces is True by default + error_outputs, _ = pynwb.validate(paths=[path]) + else: # Fallback if an older version + with pynwb.NWBHDF5IO(path=path, mode="r", load_namespaces=True) as reader: + error_outputs = pynwb.validate(io=reader) + except Exception as exc: + if devel_debug: + raise + errors.append( + ValidationResult( + origin=Origin( + type=OriginType.INTERNAL, + validator=Validator.pynwb, + validator_version=pynwb.__version__, + ), + severity=Severity.ERROR, + id="pynwb.GENERIC", + scope=Scope.FILE, + origin_result=exc, + path=Path(path), + message=f"{exc}", + ) + ) + else: + for error in error_outputs: + errors.append( + ValidationResult( + origin=Origin( + type=OriginType.VALIDATION, + validator=Validator.pynwb, + validator_version=pynwb.__version__, + standard=Standard.NWB, + standard_version=version, + ), + severity=Severity.ERROR, + id=f"pynwb.{error}", + scope=Scope.FILE, + origin_result=error, + path=Path(path), + message=f"Failed to validate. {error.reason}", + within_asset_paths={path: error.location}, + ) + ) + return errors diff --git a/dandi/tests/test_files.py b/dandi/tests/test_files.py index 7a26603a0..6f9d6c053 100644 --- a/dandi/tests/test_files.py +++ b/dandi/tests/test_files.py @@ -530,7 +530,9 @@ def test_validate_deep_zarr(tmp_path: Path) -> None: zf = dandi_file(zarr_path) assert zf.get_validation_errors() == [] mkpaths(zarr_path, "a/b/c/d/e/f/g/h.txt") - assert [e.id for e in zf.get_validation_errors()] == ["zarr.tree_depth_exceeded"] + assert [e.id for e in zf.get_validation_errors()] == [ + "dandi_zarr.tree_depth_exceeded" + ] def test_validate_zarr_deep_via_excluded_dotfiles(tmp_path: Path) -> None: diff --git a/dandi/tests/test_validate.py b/dandi/tests/test_validate.py index a05cfbcf2..f852039b1 100644 --- a/dandi/tests/test_validate.py +++ b/dandi/tests/test_validate.py @@ -7,7 +7,15 @@ from .. import __version__ from ..consts import dandiset_metadata_file from ..validate import validate -from ..validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from ..validate_types import ( + Origin, + OriginType, + Scope, + Severity, + Standard, + ValidationResult, + Validator, +) def test_validate_nwb_error(simple3_nwb: Path) -> None: @@ -31,7 +39,12 @@ def test_validate_empty(tmp_path: Path) -> None: assert list(validate(tmp_path)) == [ ValidationResult( id="DANDI.NO_DANDISET_FOUND", - origin=ValidationOrigin(name="dandi", version=__version__), + origin=Origin( + type=OriginType.VALIDATION, + validator=Validator.dandi, + validator_version=__version__, + standard=Standard.DANDI_LAYOUT, + ), severity=Severity.ERROR, scope=Scope.DANDISET, path=tmp_path, diff --git a/dandi/utils.py b/dandi/utils.py index a5c3fbc16..06e461fbc 100644 --- a/dandi/utils.py +++ b/dandi/utils.py @@ -4,6 +4,7 @@ from collections.abc import Iterable, Iterator import datetime from email.utils import parsedate_to_datetime +from enum import Enum from functools import lru_cache from importlib.metadata import version as importlib_version import inspect @@ -953,3 +954,16 @@ def get_retry_after(response: requests.Response) -> Optional[int]: sleep_amount, ) return sleep_amount + + +class StrEnum(str, Enum): + """ + A variation of Enum that is also a subclass of str, akin to IntEnum + + Member instances of a subclass of this class assigned to `auto()` will have their + own name as their value. + """ + + @staticmethod + def _generate_next_value_(name, _start, _count, _last_values): + return name diff --git a/dandi/validate.py b/dandi/validate.py index 3746434f8..1be68229a 100644 --- a/dandi/validate.py +++ b/dandi/validate.py @@ -4,11 +4,19 @@ import os from pathlib import Path -from . import __version__ from .consts import dandiset_metadata_file from .files import find_dandi_files from .utils import find_parent_directory_containing -from .validate_types import Scope, Severity, ValidationOrigin, ValidationResult +from .validate_types import ( + ORIGIN_VALIDATION_DANDI_LAYOUT, + Origin, + OriginType, + Scope, + Severity, + Standard, + ValidationResult, + Validator, +) BIDS_TO_DANDI = { "subject": "subject_id", @@ -46,10 +54,12 @@ def validate_bids( validation_result = validate_bids_(paths, exclude_files=["dandiset.yaml"]) our_validation_result = [] - origin = ValidationOrigin( - name="bidsschematools", - version=bidsschematools.__version__, - bids_version=validation_result["bids_version"], + origin = Origin( + type=OriginType.VALIDATION, + validator=Validator.bidsschematools, + validator_version=bidsschematools.__version__, + standard=Standard.BIDS, + standard_version=validation_result["bids_version"], ) # Storing variable to not re-compute set paths for each individual file. @@ -73,6 +83,7 @@ def validate_bids( severity=Severity.ERROR, id="BIDS.NON_BIDS_PATH_PLACEHOLDER", scope=Scope.FILE, + origin_result=validation_result, path=Path(path), message="File does not match any pattern known to BIDS.", dataset_path=dataset_path, @@ -92,6 +103,7 @@ def validate_bids( severity=Severity.ERROR, id="BIDS.MANDATORY_FILE_MISSING_PLACEHOLDER", scope=Scope.DATASET, + origin_result=validation_result, path_regex=pattern["regex"], message="BIDS-required file is not present.", ) @@ -115,6 +127,7 @@ def validate_bids( origin=origin, id="BIDS.MATCH", scope=Scope.FILE, + origin_result=validation_result, path=Path(file_path), metadata=meta, dataset_path=dataset_path, @@ -149,7 +162,7 @@ def validate( if dandiset_path is None: yield ValidationResult( id="DANDI.NO_DANDISET_FOUND", - origin=ValidationOrigin(name="dandi", version=__version__), + origin=ORIGIN_VALIDATION_DANDI_LAYOUT, severity=Severity.ERROR, scope=Scope.DANDISET, path=Path(p), diff --git a/dandi/validate_types.py b/dandi/validate_types.py index c09740d39..37e9bcfa0 100644 --- a/dandi/validate_types.py +++ b/dandi/validate_types.py @@ -1,21 +1,126 @@ from __future__ import annotations -from dataclasses import dataclass -from enum import Enum +from enum import Enum, IntEnum, auto, unique from pathlib import Path +from typing import Any +from pydantic import BaseModel, Field -@dataclass -class ValidationOrigin: - name: str - version: str - bids_version: str | None = None +import dandi +from dandi.utils import StrEnum -class Severity(Enum): - HINT = 1 - WARNING = 2 - ERROR = 3 +@unique +class Standard(StrEnum): + """Standards to validate against""" + + BIDS = auto() + DANDI_LAYOUT = "DANDI-LAYOUT" + DANDI_SCHEMA = "DANDI-SCHEMA" + HED = auto() + NWB = auto() + OME_ZARR = "OME-ZARR" + ZARR = auto() + + # File formats (For denoting validation failures in file format level) + JSON = auto() + TSV = auto() + YAML = auto() + + +@unique +class Validator(StrEnum): + """Validators that are used to do validation""" + + bids_validator_deno = "bids-validator-deno" + bidsschematools = auto() + dandi = auto() + dandi_zarr = "dandi.zarr" + dandischema = auto() + hed_python_validator = "hed-python-validator" + nwbinspector = auto() + pynwb = auto() + zarr = auto() + + +class OriginType(StrEnum): + """Types of validation result origins""" + + INTERNAL = auto() + """ + Validation result is originated from the validator but not necessarily relating + to validation of the data""" + + VALIDATION = auto() + """Validation result is originated from validation of the data""" + + +class Origin(BaseModel): + """ + Origin of the validation result + """ + + type: OriginType + + validator: Validator + """The validator conducting the validation""" + + validator_version: str + """The version of the validator""" + + standard: Standard | None = None + """Standard being validated against""" + + standard_version: str | None = None + """Version of the standard""" + + +# Some commonly used `Origin` instances +ORIGIN_VALIDATION_DANDI = Origin( + type=OriginType.VALIDATION, + validator=Validator.dandi, + validator_version=dandi.__version__, +) +ORIGIN_VALIDATION_DANDI_LAYOUT = Origin( + type=OriginType.VALIDATION, + validator=Validator.dandi, + validator_version=dandi.__version__, + standard=Standard.DANDI_LAYOUT, +) +ORIGIN_VALIDATION_DANDI_ZARR = Origin( + type=OriginType.VALIDATION, + validator=Validator.dandi_zarr, + validator_version=dandi.__version__, +) +ORIGIN_INTERNAL_DANDI = Origin( + type=OriginType.INTERNAL, + validator=Validator.dandi, + validator_version=dandi.__version__, +) + + +class Severity(IntEnum): + """Severity levels for validation results""" + + INFO = 10 + """Not an indication of problem but information of status or confirmation""" + + HINT = 20 + """Data is valid but could be improved""" + + WARNING = 30 + """Data is not recognized as valid. Changes are needed to ensure validity""" + + ERROR = 40 + """Data is recognized as invalid""" + + CRITICAL = 50 + """ + A serious invalidity in data. + E.g., an invalidity that prevents validation of other aspects of the data such + as when validating against the BIDS standard, the data is without a `BIDSVersion` + field or has an invalid `BIDSVersion` field. + """ class Scope(Enum): @@ -25,11 +130,20 @@ class Scope(Enum): DATASET = "dataset" -@dataclass -class ValidationResult: +class ValidationResult(BaseModel): id: str - origin: ValidationOrigin + + origin: Origin + """Origin of the validation result as validator and standard used in producing it""" + scope: Scope + + origin_result: Any | None = Field(None, exclude=True) + """ + The representation of the validation result produced by the used validator, + `self.origin.validator`, unchanged + """ + severity: Severity | None = None # asset_paths, if not populated, assumes [.path], but could be smth like # {"path": "task-broken_bold.json", diff --git a/setup.cfg b/setup.cfg index da9f03d3f..27bba32eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,8 @@ install_requires = click-didyoumean dandischema >= 0.11.0, < 0.12.0 etelemetry >= 0.2.2 + # For pydantic to be able to use type annotations like `X | None` + eval_type_backport; python_version < "3.10" fasteners fscacher >= 0.3.0 # 3.14.4: https://github.com/hdmf-dev/hdmf/issues/1186