From adaf939497d0ecb2fb938f03cb622bd243f3ed83 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Mon, 9 Oct 2023 20:13:42 -0700 Subject: [PATCH] classes adapter --- docs/index.md | 2 + docs/todo.md | 8 + nwb_linkml/src/nwb_linkml/adapters/classes.py | 40 ++-- .../test_adapters/test_adapter_classes.py | 187 +++++++++++++++++- 4 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 docs/todo.md diff --git a/docs/index.md b/docs/index.md index 579adb3..14df85f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,10 @@ :maxdepth: 3 api/index +todo changelog + ``` diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..2bb3886 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,8 @@ +# TODO + +Important things that are not implemented yet! + +- {meth}`nwb_linkml.adapters.classes.ClassAdapter.handle_dtype` does not yet handle compound dtypes, + leaving them as `AnyType` instead. This is fine for a first draft since they are used rarely within + NWB, but we will need to handle them by making slots for each of the dtypes since they typically + represent table-like data. \ No newline at end of file diff --git a/nwb_linkml/src/nwb_linkml/adapters/classes.py b/nwb_linkml/src/nwb_linkml/adapters/classes.py index e48d886..ef4fde5 100644 --- a/nwb_linkml/src/nwb_linkml/adapters/classes.py +++ b/nwb_linkml/src/nwb_linkml/adapters/classes.py @@ -10,8 +10,6 @@ from nwb_linkml.maps import QUANTITY_MAP from nwb_linkml.maps.naming import camel_to_snake - - class ClassAdapter(Adapter): """ Abstract adapter to class-like things in linkml, holds methods common to @@ -33,10 +31,24 @@ class ClassAdapter(Adapter): """ Build the basic class and attributes before adding any specific modifications for groups or datasets. + + The main distinction in behavior for this method is whether this class has a parent class - + ie this is one of the anonymous nested child datasets or groups within another group. + + If the class has no parent, then... + + * Its name is inferred from its `neurodata_type_def`, fixed name, or `neurodata_type_inc` in that order + * It is just built as normal class! + * It will be indicated as a ``tree_root`` (which will primarily be used to invert the translation for write operations) + + If the class has a parent, then... + + * If it has a `neurodata_type_def` or `inc`, that will be used as its name, otherwise concatenate `parent__child`, + eg. ``TimeSeries__TimeSeriesData`` + * A slot will also be made and returned with the BuildResult, which the parent will then have as one of its attributes. """ # Build this class - #name = self._get_full_name() kwargs = {} if self.parent is not None: kwargs['name'] = self._get_full_name() @@ -105,7 +117,6 @@ class ClassAdapter(Adapter): else: raise ValueError('Not sure what our name is!') - return name def _get_attr_name(self) -> str: @@ -113,19 +124,13 @@ class ClassAdapter(Adapter): Get the name to use as the attribute name, again distinct from the actual name of the instantiated object """ - # return self._get_full_name() - name = None - if self.cls.neurodata_type_def: - # name = camel_to_snake(self.cls.neurodata_type_def) + if self.cls.neurodata_type_def is not None: name = self.cls.neurodata_type_def elif self.cls.name is not None: - # we do have a unique name name = self.cls.name - elif self.cls.neurodata_type_inc: - # name = camel_to_snake(self.cls.neurodata_type_inc) + elif self.cls.neurodata_type_inc is not None: name = self.cls.neurodata_type_inc - - if name is None: + else: raise ValueError(f'Class has no name!: {self.cls}') return name @@ -136,19 +141,13 @@ class ClassAdapter(Adapter): used to dodge name overlaps by snake-casing! again distinct from the actual name of the instantiated object """ - # return self._get_full_name() - name = None if self.cls.neurodata_type_def: name = camel_to_snake(self.cls.neurodata_type_def) - # name = self.cls.neurodata_type_def elif self.cls.name is not None: - # we do have a unique name name = self.cls.name elif self.cls.neurodata_type_inc: name = camel_to_snake(self.cls.neurodata_type_inc) - # name = self.cls.neurodata_type_inc - - if name is None: + else: raise ValueError(f'Class has no name!: {self.cls}') return name @@ -167,7 +166,6 @@ class ClassAdapter(Adapter): # so we'll... uh... treat them as slots. # TODO return 'AnyType' - #raise NotImplementedError('got distracted, need to implement') else: # flat dtype diff --git a/nwb_linkml/tests/test_adapters/test_adapter_classes.py b/nwb_linkml/tests/test_adapters/test_adapter_classes.py index 85bb582..dc97a7e 100644 --- a/nwb_linkml/tests/test_adapters/test_adapter_classes.py +++ b/nwb_linkml/tests/test_adapters/test_adapter_classes.py @@ -4,11 +4,190 @@ import pytest from ..fixtures import linkml_schema_bare, linkml_schema, nwb_schema -from nwb_linkml.adapters import DatasetAdapter, ClassAdapter +from linkml_runtime.linkml_model import SlotDefinition +from nwb_linkml.adapters import DatasetAdapter, ClassAdapter, GroupAdapter +from nwb_schema_language import Group, Dataset, ReferenceDtype, CompoundDtype def test_build_base(nwb_schema): - # simplest case, nothing special here + # simplest case, nothing special here. Should be same behavior between dataset and group dset = DatasetAdapter(cls=nwb_schema.datasets['image']) + base = dset.build_base() + assert len(base.slots) == 0 + assert len(base.classes) == 1 + img = base.classes[0] + assert img.name == "Image" + # no parent class, tree_root shoudl be true + assert img.tree_root + assert len(img.attributes) == 3 + + # now with parent class + groups = GroupAdapter(cls=nwb_schema.groups['images']) + dset.parent = groups + base = dset.build_base() + # we made a self-slot (will be tested elsewhere) + assert len(base.slots) == 1 + assert len(base.classes) == 1 + img = base.classes[0] + assert not img.tree_root + assert len(img.attributes) == 3 + + # now try adding an extra attribute + slot = SlotDefinition(name="newslot", range="string") + # should coerce single slot to a list within the method + base = dset.build_base(extra_attrs=slot) + assert len(base.slots) == 1 + assert len(base.classes) == 1 + img = base.classes[0] + assert len(img.attributes) == 4 + assert img.attributes['newslot'] is slot + +def test_get_attr_name(): + """Name method used by parentless classes""" + cls = Dataset(neurodata_type_def='MyClass', doc='a class') + adapter = DatasetAdapter(cls=cls) + # type_defs get their original name + assert adapter._get_attr_name() == 'MyClass' + + # explicit names get that name, but only if there is no type_def + adapter.cls.name = 'MyClassName' + assert adapter._get_attr_name() == 'MyClass' + adapter.cls.neurodata_type_def = None + assert adapter._get_attr_name() == 'MyClassName' + + # if neither, use the type inc + adapter.cls.neurodata_type_inc = 'MyThirdName' + assert adapter._get_attr_name() == 'MyClassName' + adapter.cls.name = None + assert adapter._get_attr_name() == 'MyThirdName' + + # if none are present, raise a value error + adapter.cls.neurodata_type_inc = None + with pytest.raises(ValueError): + adapter._get_attr_name() + +def test_get_full_name(): + """Name used by child classes""" + cls = Dataset(neurodata_type_def='Child', doc='a class') + parent = GroupAdapter(cls=Group(neurodata_type_def='Parent', doc='a class')) + adapter = DatasetAdapter(cls=cls, parent=parent) + + # if child has its own type_def, use that + assert adapter._get_full_name() == 'Child' + + # same thing with type_inc + adapter.cls.neurodata_type_def = None + adapter.cls.neurodata_type_inc = 'ChildInc' + assert adapter._get_full_name() == 'ChildInc' + + # if it just has a name, it gets concatenated with its parents + adapter.cls.neurodata_type_inc = None + adapter.cls.name = 'ChildName' + assert adapter._get_full_name() == 'Parent__ChildName' + + # this should work at any depth of nesting if the parent is not an independently defined class + grandparent = GroupAdapter(cls=Group(neurodata_type_def='Grandparent', doc='a class')) + parent.cls.neurodata_type_def = None + parent.cls.name = 'ParentName' + parent.parent = grandparent + assert adapter._get_full_name() == 'Grandparent__ParentName__ChildName' + + # if it has none, raise value error + adapter.cls.name = None + with pytest.raises(ValueError): + adapter._get_full_name() + +def test_self_slot(): + """ + Slot that represents ourselves to our parent + + Quantity map is tested elsewhere + """ + cls = Dataset(neurodata_type_def='ChildClass', doc='a class', quantity='?') + parent = GroupAdapter(cls=Group(neurodata_type_def='Parent', doc='a class')) + adapter = DatasetAdapter(cls=cls, parent=parent) + + # base case - snake case a type def + slot = adapter.build_self_slot() + assert slot.name == 'child_class' + assert slot.range == 'ChildClass' == adapter._get_full_name() + + # this should be the slot that gets build with the build_base method + base = adapter.build_base() + assert len(base.slots) == 1 + assert base.slots[0] == slot + + # if class has a unique name, use that without changing, but only if no type_def + + adapter.cls.name = "FixedName" + slot = adapter.build_self_slot() + assert slot.name == 'child_class' + adapter.cls.neurodata_type_def = None + slot = adapter.build_self_slot() + assert slot.name == 'FixedName' + assert slot.range == adapter._get_full_name() + + # type_inc works the same as type_def, but only if name and type_def are None + adapter.cls.neurodata_type_inc = 'IncName' + slot = adapter.build_self_slot() + assert slot.name == 'FixedName' + adapter.cls.name = None + slot = adapter.build_self_slot() + assert slot.name == 'inc_name' + assert slot.range == adapter._get_full_name() + + # if we have nothing, raise value error + adapter.cls.neurodata_type_inc = None + with pytest.raises(ValueError): + adapter.build_self_slot() + + +def test_name_slot(): + """Classes with a fixed name should name slot with a fixed value""" + # no name + cls = DatasetAdapter(cls=Dataset(neurodata_type_def='MyClass', doc='a class')) + slot = cls.build_name_slot() + assert slot.name == 'name' + assert slot.required + assert slot.range == 'string' + assert slot.identifier + assert slot.ifabsent is None + assert slot.equals_string is None + + cls.cls.name = 'FixedName' + slot = cls.build_name_slot() + assert slot.name == 'name' + assert slot.required + assert slot.range == 'string' + assert slot.identifier + assert slot.ifabsent == 'string(FixedName)' + assert slot.equals_string == 'FixedName' + + +def test_handle_dtype(nwb_schema): + """ + Dtypes should be translated from nwb schema language to linkml + + Dtypes are validated by the nwb_schema_language classes, so we don't do that here + """ + cls = DatasetAdapter(cls=Dataset(neurodata_type_def='MyClass', doc='a class')) + + reftype = ReferenceDtype(target_type='TargetClass', reftype='reference') + compoundtype = [ + CompoundDtype(name='field_a', doc='field a!', dtype='int32'), + CompoundDtype(name='field_b', doc='field b!', dtype='text'), + CompoundDtype(name="reference", doc="reference!", dtype=reftype) + ] + + + assert cls.handle_dtype(reftype) == 'TargetClass' + assert cls.handle_dtype(None) == 'AnyType' + assert cls.handle_dtype([]) == 'AnyType' + # handling compound types is currently TODO + assert cls.handle_dtype(compoundtype) == 'AnyType' + assert cls.handle_dtype('int32') == 'int32' + + + + + - #pdb.set_trace() - pass \ No newline at end of file