diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d4b8591..47c9c2d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # HDMF Changelog +## HDMF 3.11.1 (Upcoming) + +### Minor Improvements +- Updated `__gather_columns` to ignore the order of bases when generating columns from the super class. @mavaylon1 [#991](https://github.com/hdmf-dev/hdmf/pull/991) + ## HDMF 3.11.0 (October 30, 2023) ### Enhancements diff --git a/src/hdmf/common/table.py b/src/hdmf/common/table.py index 58f0470e1..e6f492022 100644 --- a/src/hdmf/common/table.py +++ b/src/hdmf/common/table.py @@ -278,11 +278,17 @@ def __gather_columns(cls, name, bases, classdict): msg = "'__columns__' must be of type tuple, found %s" % type(cls.__columns__) raise TypeError(msg) - if (len(bases) and 'DynamicTable' in globals() and issubclass(bases[-1], Container) - and bases[-1].__columns__ is not cls.__columns__): - new_columns = list(cls.__columns__) - new_columns[0:0] = bases[-1].__columns__ # prepend superclass columns to new_columns - cls.__columns__ = tuple(new_columns) + if len(bases) and 'DynamicTable' in globals(): + for item in bases[::-1]: # look for __columns__ in the base classes, closest first + if issubclass(item, Container): + try: + if item.__columns__ is not cls.__columns__: + new_columns = list(cls.__columns__) + new_columns[0:0] = item.__columns__ # prepend superclass columns to new_columns + cls.__columns__ = tuple(new_columns) + break + except AttributeError: # raises error when "__columns__" is not an attr of item + continue @docval({'name': 'name', 'type': str, 'doc': 'the name of this table'}, # noqa: C901 {'name': 'description', 'type': str, 'doc': 'a description of what is in this table'}, diff --git a/src/hdmf/utils.py b/src/hdmf/utils.py index d85eb5c8c..e2686912a 100644 --- a/src/hdmf/utils.py +++ b/src/hdmf/utils.py @@ -837,6 +837,10 @@ class ExtenderMeta(ABCMeta): @classmethod def pre_init(cls, func): + """ + A decorator that sets a '__preinit' attribute on the target function and + then returns the function as a classmethod. + """ setattr(func, cls.__preinit, True) return classmethod(func) diff --git a/tests/unit/common/test_table.py b/tests/unit/common/test_table.py index 4f0ab6af5..1854fae22 100644 --- a/tests/unit/common/test_table.py +++ b/tests/unit/common/test_table.py @@ -15,7 +15,8 @@ from hdmf.utils import StrDataset from hdmf.data_utils import DataChunkIterator -from tests.unit.helpers.utils import get_temp_filepath +from tests.unit.helpers.utils import (get_temp_filepath, FooExtendDynamicTable0, + FooExtendDynamicTable1, FooExtendDynamicTable2) try: import linkml_runtime # noqa: F401 @@ -2676,3 +2677,19 @@ def test_list_prev_data_inc_precision_2steps(self): index.add_vector(list(range(65536 - 255))) self.assertEqual(index.data[0], 255) # make sure the 255 is upgraded self.assertEqual(type(index.data[0]), np.uint32) + + +class TestDynamicTableSubclassColumns(TestCase): + def setUp(self): + self.foo1 = FooExtendDynamicTable0() + self.foo2 = FooExtendDynamicTable1() + self.foo3 = FooExtendDynamicTable2() + + def test_columns(self): + self.assertEqual(self.foo1.__columns__, + ({'name': 'col1', 'description': '...'}, {'name': 'col2', 'description': '...'})) + self.assertEqual(self.foo2.__columns__, + ({'name': 'col1', 'description': '...'}, {'name': 'col2', 'description': '...'}, + {'name': 'col3', 'description': '...'}, {'name': 'col4', 'description': '...'}) +) + self.assertEqual(self.foo2.__columns__, self.foo3.__columns__) diff --git a/tests/unit/helpers/utils.py b/tests/unit/helpers/utils.py index 5d4bf16ec..0f4b3c4bf 100644 --- a/tests/unit/helpers/utils.py +++ b/tests/unit/helpers/utils.py @@ -3,7 +3,8 @@ from copy import copy, deepcopy from hdmf.build import BuildManager, ObjectMapper, TypeMap -from hdmf.container import Container, HERDManager, Data +from hdmf.common.table import DynamicTable +from hdmf.container import Container, HERDManager, Data, MultiContainerInterface from hdmf.spec import ( AttributeSpec, DatasetSpec, @@ -653,3 +654,50 @@ class CustomSpecNamespace(SpecNamespace): @classmethod def types_key(cls): return cls.__types_key + +class FooExtendDynamicTable0(DynamicTable): + """ + Within PyNWB, PlaneSegmentation extends DynamicTable and sets __columns__. This class is a helper + class for testing and is directly meant to test __gather_columns, i.e., class generation, downstream. + """ + __columns__ = ( + {'name': 'col1', 'description': '...'}, + {'name': 'col2', 'description': '...'}, + ) + + def __init__(self, **kwargs): + kwargs['name'] = 'foo0' + kwargs['description'] = '...' + super().__init__(**kwargs) + + +class FooExtendDynamicTable1(FooExtendDynamicTable0): + """ + In extensions, users can create new classes that inherit from classes that inherit from DynamicTable. + This is a helper class for testing and is directly meant to test __gather_columns, i.e., + class generation, downstream. + """ + __columns__ = ( + {'name': 'col3', 'description': '...'}, + {'name': 'col4', 'description': '...'}, + ) + + def __init__(self, **kwargs): + kwargs['name'] = 'foo1' + kwargs['description'] = '...' + super().__init__(**kwargs) + + +class FooExtendDynamicTable2(FooExtendDynamicTable1, MultiContainerInterface): + __clsconf__ = { + 'add': '...', + 'get': '...', + 'create': '...', + 'type': Container, + 'attr': '...' + } + + def __init__(self, **kwargs): + kwargs['name'] = 'foo2' + kwargs['description'] = '...' + super().__init__(**kwargs)