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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# HDMF Changelog

## HDMF Unreleased

### Enhancements
- Made `HERDManager` an abstract interface (ABC) with an abstract `external_resources` property. Subclasses must now declare `external_resources` in their `__fields__` to satisfy the interface. Updated `__gather_fields` to allow auto-generated properties to override inherited abstract properties. Added `link_resources` and `get_external_resources` methods to `HERDManager` for managing a linked HERD separate from the primary one. @rly [#1431](https://github.com/hdmf-dev/hdmf/pull/1431)

## HDMF 5.0.1 (March 16, 2026)

### Enhancements
Expand Down
5 changes: 5 additions & 0 deletions docs/gallery/plot_external_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@

# Class to represent a file
class HERDManagerContainer(Container, HERDManager):

__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)

def __init__(self, **kwargs):
kwargs['name'] = 'HERDManagerContainer'
super().__init__(**kwargs)
Expand Down
74 changes: 64 additions & 10 deletions src/hdmf/container.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import types
from abc import ABC, abstractmethod
from collections import OrderedDict
from copy import deepcopy
from uuid import uuid4
Expand Down Expand Up @@ -31,20 +32,64 @@ def _exp_warn_msg(cls):
return msg


class HERDManager:
"""
When this class is used as a mixin for a Container, it enables setting and getting an instance of HERD.
class HERDManager(ABC):
"""An abstract mixin interface for containers that support external resources.

Container subclasses that inherit from this mixin must declare
``external_resources`` in their ``__fields__`` (or equivalent, e.g.,
``__nwbfields__``) with ``'child': True`` so that the auto-generated
property satisfies the abstract interface. The subclass must also create
an ObjectMapper mapping so that the ``external_resources`` value is
written to a file on write and populated from a file on read.

Example::

class MyFile(HERDManager, Container):
__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)

This class serves as a marker for ``isinstance`` checks throughout
hdmf (e.g., in HERD and HDMFIO) to identify containers that support
external resources.
"""

def __init__(self, **kwargs):
super().__init__(**kwargs)
self._linked_external_resources = None

@property
@abstractmethod
def external_resources(self):
return self._herd if hasattr(self, "_herd") else None
pass

@external_resources.setter
def external_resources(self, herd):
if hasattr(self, "_herd"):
warn("Reassigning external_resources may lead to unexpected behavior.")
self._herd = herd
@abstractmethod
def external_resources(self, val):
pass

def link_resources(self, external_resources):
"""Link an external HERD object as the external resources for this container.

The linked HERD will not be written on export; the original HERD
(if any) is preserved in the exported file. Use
``get_external_resources(linked=True)`` to access the linked HERD.
"""
self._linked_external_resources = external_resources

def get_external_resources(self, linked=False):
"""Return the HERD external resources object for this container.

Parameters
----------
linked : bool, optional
If True, return the linked HERD set via ``link_resources``.
If False (default), return the HERD set via ``__init__`` or the
``external_resources`` attribute.
"""
if linked:
return self._linked_external_resources
return self.external_resources


class AbstractContainer(metaclass=ExtenderMeta):
Expand Down Expand Up @@ -256,15 +301,24 @@ def __gather_fields(cls, name, bases, classdict):
base_fields_conf_to_add.append(pconf)
all_fields_conf[0:0] = base_fields_conf_to_add

# create getter and setter if attribute does not already exist
# create getter and setter if attribute does not already exist or is abstract
# if 'doc' not specified in __fields__, use doc from docval of __init__
docs = {dv['name']: dv['doc'] for dv in get_docval(cls.__init__)}
abstracts_overridden = set()
for field_conf in all_fields_conf:
pname = field_conf['name']
field_conf.setdefault('doc', docs.get(pname))
if not hasattr(cls, pname):
existing = getattr(cls, pname, None)
if existing is None or getattr(existing, '__isabstractmethod__', False):
if getattr(existing, '__isabstractmethod__', False):
abstracts_overridden.add(pname)
setattr(cls, pname, property(cls._getter(field_conf), cls._setter(field_conf)))

# update __abstractmethods__ to remove any that were satisfied by auto-generated properties
if abstracts_overridden:
remaining = getattr(cls, '__abstractmethods__', frozenset()) - abstracts_overridden
cls.__abstractmethods__ = remaining

cls._set_fields(tuple(field_conf['name'] for field_conf in all_fields_conf))
cls.__fieldsconf = tuple(all_fields_conf)

Expand Down
4 changes: 3 additions & 1 deletion src/hdmf/testing/make_test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
class _HERDManagerContainer(SimpleMultiContainer, HERDManager):
"""A SimpleMultiContainer that also implements HERDManager, for testing."""

pass
__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)


def make_herd_1_8_0_file(outdir):
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/common/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@


class HERDManagerContainer(Container, HERDManager):

__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)

def __init__(self, **kwargs):
kwargs['name'] = 'HERDManagerContainer'
super().__init__(**kwargs)
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/helpers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ class FooFile(Container, HERDManager):
and should be reset to 'root' when use is finished to avoid potential cross-talk between tests.
"""

__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)

ROOT_NAME = "root" # For HDF5 and Zarr this is the root. It should be set before use if different for the backend.

@docval(
Expand Down
66 changes: 59 additions & 7 deletions tests/unit/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,69 @@ def __init__(self, **kwargs):
self.field1 = kwargs['field1']


class ContainerWithHERD(HERDManager, Container):
"""A test Container subclass that uses the HERDManager mixin."""

__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)


class TestHERDManager(TestCase):

def test_get_and_set_resources(self):
em = HERDManager()
er = HERD()
def test_subclass_has_external_resources_field(self):
"""Test that a subclass declaring external_resources in __fields__ has it."""
self.assertIn('external_resources', ContainerWithHERD.__fields__)

em.external_resources = er
self.assertEqual(em.external_resources, er)
def test_mixin_external_resources_default_none(self):
"""Test that external_resources defaults to None when not set."""
container = ContainerWithHERD(name='test')
self.assertIsNone(container.external_resources)

er_get = em.external_resources
self.assertEqual(er, er_get)
def test_mixin_set_external_resources(self):
"""Test setting external_resources on a Container subclass with HERDManager."""
container = ContainerWithHERD(name='test')
er = HERD()
container.external_resources = er
self.assertIs(container.external_resources, er)

def test_mixin_external_resources_is_child(self):
"""Test that external_resources is registered as a child of the container."""
container = ContainerWithHERD(name='test')
er = HERD()
container.external_resources = er
self.assertIn(er, container.children)
self.assertIs(er.parent, container)

def test_link_resources(self):
"""Test linking an external HERD object."""
container = ContainerWithHERD(name='test')
linked_herd = HERD()
container.link_resources(linked_herd)
self.assertIs(container.get_external_resources(linked=True), linked_herd)

def test_get_external_resources_default(self):
"""Test get_external_resources returns the primary HERD by default."""
container = ContainerWithHERD(name='test')
er = HERD()
container.external_resources = er
self.assertIs(container.get_external_resources(), er)
self.assertIs(container.get_external_resources(linked=False), er)

def test_get_external_resources_linked_default_none(self):
"""Test get_external_resources(linked=True) returns None when no linked HERD is set."""
container = ContainerWithHERD(name='test')
self.assertIsNone(container.get_external_resources(linked=True))

def test_link_resources_does_not_affect_primary(self):
"""Test that linking a HERD does not overwrite the primary external_resources."""
container = ContainerWithHERD(name='test')
primary = HERD()
linked = HERD()
container.external_resources = primary
container.link_resources(linked)
self.assertIs(container.get_external_resources(), primary)
self.assertIs(container.get_external_resources(linked=True), linked)


class TestContainer(TestCase):
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/test_io_hdf5_h5tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,9 @@ def test_write_herd_as_child(self):
link from HERD to the data can be verified."""

class _HERDManagerContainer(SimpleMultiContainer, HERDManager):
pass
__fields__ = (
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
)

species = Data(name='species', data=['Homo sapiens'])
herd = HERD()
Expand Down
Loading