Repository: hdmf-dev/hdmf (2022-01-07 ~ 2022-08-04)

problem_statement: LinkSpec without name cannot be written
## Description

When a spec yaml contains:
```yaml
groups:
- neurodata_type_def: A
  doc: ...
  links:
  - target_type: Device
    doc: Cameras used to record the videos.
    quantity: '*'  # this fails with any quantity ('?', '+', 1, '*'), but no name is often associated with quantity '*'
```
And a user provides a device, the following error is raised:
```
AttributeError: 'LinkSpec' object has no attribute 'data_type_def'
..\hdmf\src\hdmf\spec\spec.py:1014: AttributeError
```
## Environment

<!--Please describe your environment according to the following bullet points.-->

    Python Executable: Conda
    Python Version: Python 3.9
    Operating System: Windows
    HDMF Version: dev

## Checklist

- [x] Have you ensured the bug was not already [reported](https://github.com/hdmf-dev/hdmf/issues) ?
- [x] Have you included a brief and descriptive title?
- [x] Have you included a clear description of the problem you are trying to solve?
- [x] Have you included a minimal code snippet that reproduces the issue you are encountering?
- [x] Have you checked our [Contributing](https://github.com/hdmf-dev/hdmf/blob/dev/docs/CONTRIBUTING.rst) document?

date: 2022-01-07
patch: diff --git a/.circleci/config.yml b/.circleci/config.yml
index 1967c09..8badbaf 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -14,6 +14,7 @@ references:
   initialize-venv: &initialize-venv
     name: Initialize Virtual Environment
     command: |
+      pip install --upgrade pip
       python -m virtualenv ../venv || python -m venv ../venv
       . ../venv/bin/activate
 
@@ -106,10 +107,16 @@ references:
       - run:
           name: Configure conda
           command: |
+            pip install --upgrade pip
+            conda update -n base -c defaults conda
             conda config --set always_yes yes --set changeps1 no
             conda config --add channels conda-forge
             conda install python=$CONDA_PYTHON_VER
-            conda install virtualenv
+            if [[ "${TEST_TOX_ENV}" == *"py37"* ]]; then
+              conda install "virtualenv<20.11"
+            else
+              conda install virtualenv
+            fi
             conda install tox
       - run:
           name: Run the tests
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f408db4..db09555 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,10 +1,12 @@
 # pinned dependencies to reproduce an entire development environment to use HDMF, run HDMF tests, check code style,
 # compute coverage, and create test environments
-codecov==2.1.11
-coverage==5.5
-flake8==3.9.2
+codecov==2.1.12
+coverage==6.2
+flake8==4.0.1
 flake8-debugger==4.0.0
 flake8-print==4.0.0
-importlib-metadata==4.6.1
+importlib-metadata==4.2.0
+pytest==6.2.5
+pytest-cov==3.0.0
 python-dateutil==2.8.2
-tox==3.24.0
+tox==3.24.5
diff --git a/src/hdmf/build/manager.py b/src/hdmf/build/manager.py
index c9bbd16..823c9fa 100644
--- a/src/hdmf/build/manager.py
+++ b/src/hdmf/build/manager.py
@@ -644,6 +644,7 @@ class TypeMap:
         spec, builder = getargs('spec', 'builder', kwargs)
         if isinstance(builder, LinkBuilder):
             builder_type = type(builder.builder)
+            # TODO consider checking against spec.get_link
         else:
             builder_type = type(builder)
         if issubclass(builder_type, DatasetBuilder):
@@ -663,6 +664,9 @@ class TypeMap:
                     subspec = spec.get_data_type(t)
                     if subspec is not None:
                         break
+                    subspec = spec.get_target_type(t)
+                    if subspec is not None:
+                        break
         return subspec
 
     def get_container_ns_dt(self, obj):
@@ -672,7 +676,7 @@ class TypeMap:
 
     def get_container_cls_dt(self, cls):
         def_ret = (None, None)
-        for _cls in cls.__mro__:
+        for _cls in cls.__mro__:  # pragma: no branch
             ret = self.__data_types.get(_cls, def_ret)
             if ret is not def_ret:
                 return ret
diff --git a/src/hdmf/build/objectmapper.py b/src/hdmf/build/objectmapper.py
index 070d018..cee31b0 100644
--- a/src/hdmf/build/objectmapper.py
+++ b/src/hdmf/build/objectmapper.py
@@ -15,7 +15,7 @@ from .warnings import MissingRequiredBuildWarning, DtypeConversionWarning, Incor
 from ..container import AbstractContainer, Data, DataRegion
 from ..data_utils import DataIO, AbstractDataChunkIterator
 from ..query import ReferenceResolver
-from ..spec import Spec, AttributeSpec, DatasetSpec, GroupSpec, LinkSpec, NAME_WILDCARD, RefSpec
+from ..spec import Spec, AttributeSpec, DatasetSpec, GroupSpec, LinkSpec, RefSpec
 from ..spec.spec import BaseStorageSpec
 from ..utils import docval, getargs, ExtenderMeta, get_docval
 
@@ -1259,7 +1259,7 @@ class ObjectMapper(metaclass=ExtenderMeta):
     def get_builder_name(self, **kwargs):
         '''Get the name of a Builder that represents a AbstractContainer'''
         container = getargs('container', kwargs)
-        if self.__spec.name not in (NAME_WILDCARD, None):
+        if self.__spec.name is not None:
             ret = self.__spec.name
         else:
             ret = container.name
diff --git a/src/hdmf/spec/spec.py b/src/hdmf/spec/spec.py
index c892cab..e5e2e09 100644
--- a/src/hdmf/spec/spec.py
+++ b/src/hdmf/spec/spec.py
@@ -6,7 +6,7 @@ from warnings import warn
 
 from ..utils import docval, getargs, popargs, get_docval, fmt_docval_args
 
-NAME_WILDCARD = None
+NAME_WILDCARD = None  # this is no longer used, but kept for backward compatibility
 ZERO_OR_ONE = '?'
 ZERO_OR_MANY = '*'
 ONE_OR_MANY = '+'
@@ -311,7 +311,7 @@ class BaseStorageSpec(Spec):
     def __init__(self, **kwargs):
         name, doc, quantity, attributes, linkable, data_type_def, data_type_inc = \
             getargs('name', 'doc', 'quantity', 'attributes', 'linkable', 'data_type_def', 'data_type_inc', kwargs)
-        if name == NAME_WILDCARD and data_type_def is None and data_type_inc is None:
+        if name is None and data_type_def is None and data_type_inc is None:
             raise ValueError("Cannot create Group or Dataset spec with no name "
                              "without specifying '%s' and/or '%s'." % (self.def_key(), self.inc_key()))
         super().__init__(doc, name=name)
@@ -323,7 +323,7 @@ class BaseStorageSpec(Spec):
                 self['default_name'] = default_name
         self.__attributes = dict()
         if quantity in (ONE_OR_MANY, ZERO_OR_MANY):
-            if name != NAME_WILDCARD:
+            if name is not None:
                 raise ValueError("Cannot give specific name to something that can "
                                  "exist multiple times: name='%s', quantity='%s'" % (name, quantity))
         if quantity != DEF_QUANTITY:
@@ -349,6 +349,7 @@ class BaseStorageSpec(Spec):
         # self.__new_attributes: set of attribute names that do not exist in the parent type
         # self.__overridden_attributes: set of attribute names that exist in this spec and the parent type
         # self.__new_attributes and self.__overridden_attributes are only set properly if resolve = True
+        # add all attributes described in this spec
         for attribute in attributes:
             self.set_attribute(attribute)
         self.__new_attributes = set(self.__attributes.keys())
@@ -729,7 +730,7 @@ class DatasetSpec(BaseStorageSpec):
             for dt in self.dtype:
                 name = dt['name']
                 if name in order:
-                    # verify that the exension has supplied
+                    # verify that the extension has supplied
                     # a valid subtyping of existing type
                     orig = order[name].dtype
                     new = dt.dtype
@@ -760,10 +761,6 @@ class DatasetSpec(BaseStorageSpec):
         '''The default value of the dataset or None if not specified'''
         return self.get('default_value', None)
 
-    @classmethod
-    def __check_dim(cls, dim, data):
-        return True
-
     @classmethod
     def dtype_spec_cls(cls):
         ''' The class to use when constructing DtypeSpec objects
@@ -873,7 +870,8 @@ class GroupSpec(BaseStorageSpec):
     @docval(*_group_args)
     def __init__(self, **kwargs):
         doc, groups, datasets, links = popargs('doc', 'groups', 'datasets', 'links', kwargs)
-        self.__data_types = dict()
+        self.__data_types = dict()  # for GroupSpec/DatasetSpec data_type_def/inc
+        self.__target_types = dict()  # for LinkSpec target_types
         self.__groups = dict()
         for group in groups:
             self.set_group(group)
@@ -884,6 +882,7 @@ class GroupSpec(BaseStorageSpec):
         for link in links:
             self.set_link(link)
         self.__new_data_types = set(self.__data_types.keys())
+        self.__new_target_types = set(self.__target_types.keys())
         self.__new_datasets = set(self.__datasets.keys())
         self.__overridden_datasets = set()
         self.__new_links = set(self.__links.keys())
@@ -896,21 +895,23 @@ class GroupSpec(BaseStorageSpec):
     def resolve_spec(self, **kwargs):
         inc_spec = getargs('inc_spec', kwargs)
         data_types = list()
+        target_types = list()
         # resolve inherited datasets
         for dataset in inc_spec.datasets:
-            # if not (dataset.data_type_def is None and dataset.data_type_inc is None):
             if dataset.name is None:
                 data_types.append(dataset)
                 continue
             self.__new_datasets.discard(dataset.name)
             if dataset.name in self.__datasets:
+                # if the included dataset spec was added earlier during resolution, don't add it again
+                # but resolve the spec using the included dataset spec - the included spec may contain
+                # properties not specified in the version of this spec added earlier during resolution
                 self.__datasets[dataset.name].resolve_spec(dataset)
                 self.__overridden_datasets.add(dataset.name)
             else:
                 self.set_dataset(dataset)
         # resolve inherited groups
         for group in inc_spec.groups:
-            # if not (group.data_type_def is None and group.data_type_inc is None):
             if group.name is None:
                 data_types.append(group)
                 continue
@@ -923,7 +924,8 @@ class GroupSpec(BaseStorageSpec):
         # resolve inherited links
         for link in inc_spec.links:
             if link.name is None:
-                data_types.append(link)
+                target_types.append(link)
+                continue
             self.__new_links.discard(link.name)
             if link.name in self.__links:
                 self.__overridden_links.add(link.name)
@@ -931,23 +933,27 @@ class GroupSpec(BaseStorageSpec):
                 self.set_link(link)
         # resolve inherited data_types
         for dt_spec in data_types:
-            if isinstance(dt_spec, LinkSpec):
-                dt = dt_spec.target_type
-            else:
-                dt = dt_spec.data_type_def
-                if dt is None:
-                    dt = dt_spec.data_type_inc
+            dt = dt_spec.data_type_def
+            if dt is None:
+                dt = dt_spec.data_type_inc
             self.__new_data_types.discard(dt)
             existing_dt_spec = self.get_data_type(dt)
-            if existing_dt_spec is None or \
-                    ((isinstance(existing_dt_spec, list) or existing_dt_spec.name is not None)) and \
-                    dt_spec.name is None:
+            if (existing_dt_spec is None or
+                    ((isinstance(existing_dt_spec, list) or existing_dt_spec.name is not None) and
+                     dt_spec.name is None)):
                 if isinstance(dt_spec, DatasetSpec):
                     self.set_dataset(dt_spec)
-                elif isinstance(dt_spec, GroupSpec):
-                    self.set_group(dt_spec)
                 else:
-                    self.set_link(dt_spec)
+                    self.set_group(dt_spec)
+        # resolve inherited target_types
+        for link_spec in target_types:
+            dt = link_spec.target_type
+            self.__new_target_types.discard(dt)
+            existing_dt_spec = self.get_target_type(dt)
+            if (existing_dt_spec is None or
+                    (isinstance(existing_dt_spec, list) or existing_dt_spec.name is not None) and
+                    link_spec.name is None):
+                self.set_link(link_spec)
         super().resolve_spec(inc_spec)
 
     @docval({'name': 'name', 'type': str, 'doc': 'the name of the dataset'},
@@ -1010,13 +1016,15 @@ class GroupSpec(BaseStorageSpec):
         spec = getargs('spec', kwargs)
         if isinstance(spec, Spec):
             name = spec.name
-            if name is None:
+            if name is None and hasattr(spec, 'data_type_def'):
                 name = spec.data_type_def
-            if name is None:
+            if name is None:  # NOTE: this will return the target type for LinkSpecs
                 name = spec.data_type_inc
-            if name is None:
+            if name is None:  # pragma: no cover
+                # this should not be possible
                 raise ValueError('received Spec with wildcard name but no data_type_inc or data_type_def')
             spec = name
+        # if the spec has a name, it will be found in __links/__groups/__datasets before __data_types/__target_types
         if spec in self.__links:
             return self.is_inherited_link(spec)
         elif spec in self.__groups:
@@ -1024,7 +1032,10 @@ class GroupSpec(BaseStorageSpec):
         elif spec in self.__datasets:
             return self.is_inherited_dataset(spec)
         elif spec in self.__data_types:
+            # NOTE: the same data type can be both an unnamed data type and an unnamed target type
             return self.is_inherited_type(spec)
+        elif spec in self.__target_types:
+            return self.is_inherited_target_type(spec)
         else:
             if super().is_inherited_spec(spec):
                 return True
@@ -1040,20 +1051,24 @@ class GroupSpec(BaseStorageSpec):
         return False
 
     @docval({'name': 'spec', 'type': (Spec, str), 'doc': 'the specification to check'})
-    def is_overridden_spec(self, **kwargs):
-        ''' Returns 'True' if specification was inherited from a parent type '''
+    def is_overridden_spec(self, **kwargs):  # noqa: C901
+        ''' Returns 'True' if specification overrides a specification from the parent type '''
         spec = getargs('spec', kwargs)
         if isinstance(spec, Spec):
             name = spec.name
             if name is None:
+                if isinstance(spec, LinkSpec):  # unnamed LinkSpec cannot be overridden
+                    return False
                 if spec.is_many():  # this is a wildcard spec, so it cannot be overridden
                     return False
                 name = spec.data_type_def
-            if name is None:
+            if name is None:  # NOTE: this will return the target type for LinkSpecs
                 name = spec.data_type_inc
-            if name is None:
+            if name is None:  # pragma: no cover
+                # this should not happen
                 raise ValueError('received Spec with wildcard name but no data_type_inc or data_type_def')
             spec = name
+        # if the spec has a name, it will be found in __links/__groups/__datasets before __data_types/__target_types
         if spec in self.__links:
             return self.is_overridden_link(spec)
         elif spec in self.__groups:
@@ -1078,10 +1093,10 @@ class GroupSpec(BaseStorageSpec):
 
     @docval({'name': 'spec', 'type': (BaseStorageSpec, str), 'doc': 'the specification to check'})
     def is_inherited_type(self, **kwargs):
-        ''' Returns True if `spec` represents a spec that was inherited from an included data_type '''
+        ''' Returns True if `spec` represents a data type that was inherited '''
         spec = getargs('spec', kwargs)
         if isinstance(spec, BaseStorageSpec):
-            if spec.data_type_def is None:
+            if spec.data_type_def is None:  # why not also check data_type_inc?
                 raise ValueError('cannot check if something was inherited if it does not have a %s' % self.def_key())
             spec = spec.data_type_def
         return spec not in self.__new_data_types
@@ -1089,56 +1104,146 @@ class GroupSpec(BaseStorageSpec):
     @docval({'name': 'spec', 'type': (BaseStorageSpec, str), 'doc': 'the specification to check'},
             raises="ValueError, if 'name' is not part of this spec")
     def is_overridden_type(self, **kwargs):
-        ''' Returns True if `spec` represents a spec that was overriden by the subtype'''
+        ''' Returns True if `spec` represents a data type that overrides a specification from a parent type '''
+        return self.is_inherited_type(**kwargs)
+
+    @docval({'name': 'spec', 'type': (LinkSpec, str), 'doc': 'the specification to check'})
+    def is_inherited_target_type(self, **kwargs):
+        ''' Returns True if `spec` represents a target type that was inherited '''
         spec = getargs('spec', kwargs)
-        if isinstance(spec, BaseStorageSpec):
-            if spec.data_type_def is None:
-                raise ValueError('cannot check if something was inherited if it does not have a %s' % self.def_key())
-            spec = spec.data_type_def
-        return spec not in self.__new_data_types
+        if isinstance(spec, LinkSpec):
+            spec = spec.target_type
+        return spec not in self.__new_target_types
+
+    @docval({'name': 'spec', 'type': (LinkSpec, str), 'doc': 'the specification to check'},
+            raises="ValueError, if 'name' is not part of this spec")
+    def is_overridden_target_type(self, **kwargs):
+        ''' Returns True if `spec` represents a target type that overrides a specification from a parent type '''
+        return self.is_inherited_target_type(**kwargs)
 
     def __add_data_type_inc(self, spec):
+        # update the __data_types dict with the given groupspec/datasetspec so that:
+        # - if there is only one spec for a given data type, then it is stored in __data_types regardless of
+        #   whether it is named
+        # - if there are multiple specs for a given data type and they are all named, then they are all stored in
+        #   __data_types
+        # - if there are multiple specs for a given data type and only one is unnamed, then the unnamed spec is
+        #   stored in __data_types
+        # it is not allowed to have multiple specs for a given data type and multiple are unnamed
         dt = None
         if hasattr(spec, 'data_type_def') and spec.data_type_def is not None:
             dt = spec.data_type_def
         elif hasattr(spec, 'data_type_inc') and spec.data_type_inc is not None:
             dt = spec.data_type_inc
-        if not dt:
+        if not dt:  # pragma: no cover
+            # this should not be possible
             raise TypeError("spec does not have '%s' or '%s' defined" % (self.def_key(), self.inc_key()))
         if dt in self.__data_types:
             curr = self.__data_types[dt]
-            if curr is spec:
+            if curr is spec:  # happens only if the same spec is added twice
                 return
             if spec.name is None:
                 if isinstance(curr, list):
+                    # replace the list of named specs associated with the data_type with this unnamed spec
+                    # the named specs can be retrieved by name
                     self.__data_types[dt] = spec
                 else:
                     if curr.name is None:
-                        raise TypeError('Cannot have multiple data types of the same type without specifying name')
+                        # neither the spec already associated with the data_type nor the given spec have a name
+                        msg = "Cannot have multiple groups/datasets with the same data type without specifying name"
+                        raise ValueError(msg)
                     else:
-                        # unnamed data types will be stored as data_types
+                        # replace the named spec associated with the data_type with this unnamed spec
+                        # the named spec can be retrieved by name
                         self.__data_types[dt] = spec
             else:
                 if isinstance(curr, list):
+                    # add this named spec to the list of named current specs associated with the data_type
                     self.__data_types[dt].append(spec)
                 else:
                     if curr.name is None:
+                        # the spec associated with the data_type has no name and the given spec has a name
                         # leave the existing data type as is, since the new one can be retrieved by name
                         return
                     else:
+                        # both the spec associated with the data_type and the given spec have a name
                         # store both specific instances of a data type
                         self.__data_types[dt] = [curr, spec]
         else:
             self.__data_types[dt] = spec
 
+    def __add_target_type(self, spec):
+        # update the __target_types dict with the given linkspec so that:
+        # - if there is only one linkspec for a given target type, then it is stored in __target_types regardless of
+        #   whether it is named
+        # - if there are multiple linkspecs for a given target type and they are all named, then they are all stored in
+        #   __target_types
+        # - if there are multiple linkspecs for a given target type and only one is unnamed, then the unnamed spec is
+        #   stored in __target_types
+        # it is not allowed to have multiple linkspecs for a given target type and multiple are unnamed
+        dt = spec.target_type
+        if dt in self.__target_types:
+            curr = self.__target_types[dt]
+            if curr is spec:  # happens only if the same spec is added twice
+                return
+            if spec.name is None:
+                if isinstance(curr, list):
+                    # replace the list of named specs associated with the target_type with this unnamed spec
+                    # the named specs can be retrieved by name
+                    self.__target_types[dt] = spec
+                else:
+                    if curr.name is None:
+                        # neither the spec already associated with the target_type nor the given spec have a name
+                        msg = "Cannot have multiple links with the same target type without specifying name"
+                        raise ValueError(msg)
+                    else:
+                        # replace the named spec associated with the target_type with this unnamed spec
+                        # the named spec can be retrieved by name
+                        self.__target_types[dt] = spec
+            else:
+                if isinstance(curr, list):
+                    # add this named spec to the list of named current specs associated with the target_type
+                    self.__target_types[dt].append(spec)
+                else:
+                    if curr.name is None:
+                        # the spec associated with the target_type has no name and the given spec has a name
+                        # leave the existing data type as is, since the new one can be retrieved by name
+                        return
+                    else:
+                        # both the spec associated with the target_type and the given spec have a name
+                        # store both specific instances of a data type
+                        self.__target_types[dt] = [curr, spec]
+        else:
+            self.__target_types[dt] = spec
+
     @docval({'name': 'data_type', 'type': str, 'doc': 'the data_type to retrieve'})
     def get_data_type(self, **kwargs):
-        '''
-        Get a specification by "data_type"
+        ''' Get a specification by "data_type"
+
+        NOTE: If there is only one spec for a given data type, then it is returned.
+        If there are multiple specs for a given data type and they are all named, then they are returned in a list.
+        If there are multiple specs for a given data type and only one is unnamed, then the unnamed spec is returned.
+        The other named specs can be returned using get_group or get_dataset.
+
+        NOTE: this method looks for an exact match of the data type and does not consider the type hierarchy.
         '''
         ndt = getargs('data_type', kwargs)
         return self.__data_types.get(ndt, None)
 
+    @docval({'name': 'target_type', 'type': str, 'doc': 'the target_type to retrieve'})
+    def get_target_type(self, **kwargs):
+        ''' Get a specification by "target_type"
+
+        NOTE: If there is only one spec for a given target type, then it is returned.
+        If there are multiple specs for a given target type and they are all named, then they are returned in a list.
+        If there are multiple specs for a given target type and only one is unnamed, then the unnamed spec is returned.
+        The other named specs can be returned using get_link.
+
+        NOTE: this method looks for an exact match of the target type and does not consider the type hierarchy.
+        '''
+        ndt = getargs('target_type', kwargs)
+        return self.__target_types.get(ndt, None)
+
     @property
     def groups(self):
         ''' The groups specificed in this GroupSpec '''
@@ -1157,8 +1262,7 @@ class GroupSpec(BaseStorageSpec):
     @docval(*_group_args)
     def add_group(self, **kwargs):
         ''' Add a new specification for a subgroup to this group specification '''
-        doc = kwargs.pop('doc')
-        spec = self.__class__(doc, **kwargs)
+        spec = self.__class__(**kwargs)
         self.set_group(spec)
         return spec
 
@@ -1168,12 +1272,14 @@ class GroupSpec(BaseStorageSpec):
         spec = getargs('spec', kwargs)
         if spec.parent is not None:
             spec = self.build_spec(spec)
-        if spec.name == NAME_WILDCARD:
+        if spec.name is None:
             if spec.data_type_inc is not None or spec.data_type_def is not None:
                 self.__add_data_type_inc(spec)
-            else:
+            else:  # pragma: no cover
+                # this should not be possible
                 raise TypeError("must specify 'name' or 'data_type_inc' in Group spec")
         else:
+            # NOTE named specs can be present in both __datasets and __data_types
             if spec.data_type_inc is not None or spec.data_type_def is not None:
                 self.__add_data_type_inc(spec)
             self.__groups[spec.name] = spec
@@ -1189,8 +1295,7 @@ class GroupSpec(BaseStorageSpec):
     @docval(*_dataset_args)
     def add_dataset(self, **kwargs):
         ''' Add a new specification for a dataset to this group specification '''
-        doc = kwargs.pop('doc')
-        spec = self.dataset_spec_cls()(doc, **kwargs)
+        spec = self.dataset_spec_cls()(**kwargs)
         self.set_dataset(spec)
         return spec
 
@@ -1200,12 +1305,14 @@ class GroupSpec(BaseStorageSpec):
         spec = getargs('spec', kwargs)
         if spec.parent is not None:
             spec = self.dataset_spec_cls().build_spec(spec)
-        if spec.name == NAME_WILDCARD:
+        if spec.name is None:
             if spec.data_type_inc is not None or spec.data_type_def is not None:
                 self.__add_data_type_inc(spec)
-            else:
+            else:  # pragma: no cover
+                # this should not be possible
                 raise TypeError("must specify 'name' or 'data_type_inc' in Dataset spec")
         else:
+            # NOTE named specs can be present in both __datasets and __data_types
             if spec.data_type_inc is not None or spec.data_type_def is not None:
                 self.__add_data_type_inc(spec)
             self.__datasets[spec.name] = spec
@@ -1221,8 +1328,7 @@ class GroupSpec(BaseStorageSpec):
     @docval(*_link_args)
     def add_link(self, **kwargs):
         ''' Add a new specification for a link to this group specification '''
-        doc, target_type = popargs('doc', _target_type_key, kwargs)
-        spec = self.link_spec_cls()(doc, target_type, **kwargs)
+        spec = self.link_spec_cls()(**kwargs)
         self.set_link(spec)
         return spec
 
@@ -1232,7 +1338,9 @@ class GroupSpec(BaseStorageSpec):
         spec = getargs('spec', kwargs)
         if spec.parent is not None:
             spec = self.link_spec_cls().build_spec(spec)
-        if spec.name != NAME_WILDCARD:
+        # NOTE named specs can be present in both __links and __target_types
+        self.__add_target_type(spec)
+        if spec.name is not None:
             self.__links[spec.name] = spec
         self.setdefault('links', list()).append(spec)
         spec.parent = self
diff --git a/tox.ini b/tox.ini
index 53089b0..c43de0f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,6 +5,7 @@
 
 [tox]
 envlist = py37, py38, py39, py310
+requires = pip >= 22.0
 
 [testenv]
 usedevelop = True

test_patch: diff --git a/tests/unit/build_tests/test_io_map.py b/tests/unit/build_tests/test_io_map.py
index 1cd1b83..8a04c95 100644
--- a/tests/unit/build_tests/test_io_map.py
+++ b/tests/unit/build_tests/test_io_map.py
@@ -1,16 +1,17 @@
 import unittest
 from abc import ABCMeta, abstractmethod
 
-from hdmf import Container
+from hdmf import Container, Data
 from hdmf.backends.hdf5 import H5DataIO
 from hdmf.build import (GroupBuilder, DatasetBuilder, ObjectMapper, BuildManager, TypeMap, LinkBuilder,
                         ReferenceBuilder, MissingRequiredBuildWarning, OrphanContainerBuildError,
                         ContainerConfigurationError)
-from hdmf.spec import GroupSpec, AttributeSpec, DatasetSpec, SpecCatalog, SpecNamespace, NamespaceCatalog, RefSpec
+from hdmf.spec import (GroupSpec, AttributeSpec, DatasetSpec, SpecCatalog, SpecNamespace, NamespaceCatalog, RefSpec,
+                       LinkSpec)
 from hdmf.testing import TestCase
 from hdmf.utils import docval, getargs
 
-from tests.unit.utils import CORE_NAMESPACE
+from tests.unit.utils import CORE_NAMESPACE, create_test_type_map
 
 
 class Bar(Container):
@@ -69,6 +70,10 @@ class Bar(Container):
             self._remove_child(self.__foo)
 
 
+class SubBar(Bar):
+    pass
+
+
 class Foo(Container):
 
     @property
@@ -76,37 +81,189 @@ class Foo(Container):
         return 'Foo'
 
 
+class FooData(Data):
+
+    @property
+    def data_type(self):
+        return 'FooData'
+
+
 class TestGetSubSpec(TestCase):
 
     def setUp(self):
-        self.bar_spec = GroupSpec('A test group specification with a data type', data_type_def='Bar')
-        spec_catalog = SpecCatalog()
-        spec_catalog.register_spec(self.bar_spec, 'test.yaml')
-        namespace = SpecNamespace('a test namespace', CORE_NAMESPACE, [{'source': 'test.yaml'}],
-                                  version='0.1.0',
-                                  catalog=spec_catalog)
-        namespace_catalog = NamespaceCatalog()
-        namespace_catalog.add_namespace(CORE_NAMESPACE, namespace)
-        self.type_map = TypeMap(namespace_catalog)
-        self.type_map.register_container_type(CORE_NAMESPACE, 'Bar', Bar)
+        self.bar_spec = GroupSpec(doc='A test group specification with a data type', data_type_def='Bar')
+        self.sub_bar_spec = GroupSpec(
+            doc='A test group specification with a data type',
+            data_type_def='SubBar',
+            data_type_inc='Bar'
+        )
+        self.type_map = create_test_type_map([self.bar_spec, self.sub_bar_spec], {'Bar': Bar, 'SubBar': SubBar})
+
+    def test_bad_name(self):
+        """Test get_subspec on a builder that doesn't map to the spec."""
+        parent_spec = GroupSpec(doc='Empty group', name='bar_bucket')
+        sub_builder = GroupBuilder(
+            name='my_bar',
+            attributes={
+                'data_type': 'Bar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        GroupBuilder(name='bar_bucket', groups={'my_bar': sub_builder})  # add sub_builder as a child to bar_bucket
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIsNone(result)
 
-    def test_get_subspec_data_type_noname(self):
-        parent_spec = GroupSpec('Something to hold a Bar', 'bar_bucket', groups=[self.bar_spec])
-        sub_builder = GroupBuilder('my_bar', attributes={'data_type': 'Bar', 'namespace': CORE_NAMESPACE,
-                                                         'object_id': -1})
-        GroupBuilder('bar_bucket', groups={'my_bar': sub_builder})
+    def test_bad_name_no_data_type(self):
+        """Test get_subspec on a builder without a data type that doesn't map to the spec."""
+        parent_spec = GroupSpec(doc='Empty group', name='bar_bucket')
+        sub_builder = GroupBuilder(name='my_bar')
+        GroupBuilder(name='bar_bucket', groups={'my_bar': sub_builder})  # add sub_builder as a child to bar_bucket
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIsNone(result)
+
+    def test_unnamed_group_data_type_def(self):
+        """Test get_subspec on a builder that maps to an unnamed subgroup of the given spec using data_type_def."""
+        parent_spec = GroupSpec(
+            doc='Something to hold a Bar',
+            name='bar_bucket',
+            groups=[self.bar_spec]  # using data_type_def, no name
+        )
+        sub_builder = GroupBuilder(
+            name='my_bar',
+            attributes={
+                'data_type': 'Bar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        GroupBuilder(name='bar_bucket', groups={'my_bar': sub_builder})  # add sub_builder as a child to bar_bucket
         result = self.type_map.get_subspec(parent_spec, sub_builder)
         self.assertIs(result, self.bar_spec)
 
-    def test_get_subspec_named(self):
-        child_spec = GroupSpec('A test group specification with a data type', 'my_subgroup')
-        parent_spec = GroupSpec('Something to hold a Bar', 'my_group', groups=[child_spec])
-        sub_builder = GroupBuilder('my_subgroup', attributes={'data_type': 'Bar', 'namespace': CORE_NAMESPACE,
-                                                              'object_id': -1})
-        GroupBuilder('my_group', groups={'my_bar': sub_builder})
+    def test_unnamed_group_data_type_inc(self):
+        """Test get_subspec on a builder that maps to an unnamed subgroup of the given spec using data_type_inc."""
+        inc_spec = GroupSpec(
+            doc='This Bar',
+            data_type_inc='Bar'
+        )
+        parent_spec = GroupSpec(
+            doc='Something to hold a Bar',
+            name='bar_bucket',
+            groups=[inc_spec]  # using data_type_inc
+        )
+        sub_builder = GroupBuilder(
+            name='my_bar',
+            attributes={
+                'data_type': 'Bar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        GroupBuilder(name='bar_bucket', groups={'my_bar': sub_builder})  # add sub_builder as a child to bar_bucket
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIs(result, inc_spec)
+
+    def test_named_group(self):
+        """Test get_subspec on a builder that maps to a named subgroup of the given spec."""
+        # NOTE this works despite the fact that child_spec has no data type but the builder has a data type because
+        # get_subspec acts on the name and not necessarily the data type
+        child_spec = GroupSpec(doc='A test group specification', name='my_subgroup')
+        parent_spec = GroupSpec(doc='Something to hold a Bar', name='my_group', groups=[child_spec])
+        sub_builder = GroupBuilder(
+            name='my_subgroup',
+            attributes={
+                'data_type': 'Bar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        GroupBuilder(name='my_group', groups={'my_bar': sub_builder})  # add sub_builder as a child to my_group
         result = self.type_map.get_subspec(parent_spec, sub_builder)
         self.assertIs(result, child_spec)
 
+    def test_named_dataset(self):
+        """Test get_subspec on a builder that maps to a named dataset of the given spec."""
+        # NOTE this works despite the fact that child_spec has no data type but the builder has a data type because
+        # get_subspec acts on the name and not necessarily the data type
+        child_spec = DatasetSpec(doc='A test dataset specification', name='my_dataset')
+        parent_spec = GroupSpec(doc='Something to hold a Bar', name='my_group', datasets=[child_spec])
+        sub_builder = DatasetBuilder(
+            name='my_dataset',
+            data=[],
+            attributes={
+                'data_type': 'FooData',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        GroupBuilder(name='my_group', datasets={'my_dataset': sub_builder})  # add sub_builder as a child to my_group
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIs(result, child_spec)
+
+    def test_unnamed_link_data_type_inc(self):
+        """Test get_subspec on a builder that maps to an unnamed link."""
+        link_spec = LinkSpec(doc='This Bar', target_type='Bar')
+        parent_spec = GroupSpec(
+            doc='Something to hold a Bar',
+            name='bar_bucket',
+            links=[link_spec]
+        )
+        bar_builder = GroupBuilder(
+            name='my_bar',
+            attributes={
+                'data_type': 'Bar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        sub_builder = LinkBuilder(builder=bar_builder, name='my_link')
+        GroupBuilder(name='bar_bucket', links={'my_bar': sub_builder})
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIs(result, link_spec)
+
+    def test_named_link_data_type_inc(self):
+        """Test get_subspec on a builder that maps to an named link."""
+        link_spec = LinkSpec(doc='This Bar', target_type='Bar', name='bar_link')
+        parent_spec = GroupSpec(
+            doc='Something to hold a Bar',
+            name='bar_bucket',
+            links=[link_spec]
+        )
+        bar_builder = GroupBuilder(
+            name='my_bar',
+            attributes={
+                'data_type': 'Bar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        sub_builder = LinkBuilder(builder=bar_builder, name='bar_link')
+        GroupBuilder(name='bar_bucket', links={'my_bar': sub_builder})
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIs(result, link_spec)
+
+    def test_named_link_hierarchy_data_type_inc(self):
+        """Test get_subspec on a builder that maps to an named link."""
+        link_spec = LinkSpec(doc='This Bar', target_type='Bar', name='bar_link')
+        parent_spec = GroupSpec(
+            doc='Something to hold a Bar',
+            name='bar_bucket',
+            links=[link_spec]
+        )
+        bar_builder = GroupBuilder(
+            name='my_bar',
+            attributes={
+                'data_type': 'SubBar',
+                'namespace': CORE_NAMESPACE,
+                'object_id': -1
+            }
+        )
+        sub_builder = LinkBuilder(builder=bar_builder, name='bar_link')
+        GroupBuilder(name='bar_bucket', links={'my_bar': sub_builder})
+        result = self.type_map.get_subspec(parent_spec, sub_builder)
+        self.assertIs(result, link_spec)
+
 
 class TestTypeMap(TestCase):
 
diff --git a/tests/unit/spec_tests/test_group_spec.py b/tests/unit/spec_tests/test_group_spec.py
index b8316c3..9c117fa 100644
--- a/tests/unit/spec_tests/test_group_spec.py
+++ b/tests/unit/spec_tests/test_group_spec.py
@@ -1,6 +1,6 @@
 import json
 
-from hdmf.spec import GroupSpec, DatasetSpec, AttributeSpec
+from hdmf.spec import GroupSpec, DatasetSpec, AttributeSpec, LinkSpec
 from hdmf.testing import TestCase
 
 
@@ -43,9 +43,6 @@ class GroupSpecTests(TestCase):
                       name='subgroup2',
                       linkable=False)
         ]
-        self.ndt_attr_spec = AttributeSpec('data_type', 'the data type of this object', 'text', value='EphysData')
-        self.ns_attr_spec = AttributeSpec('namespace', 'the namespace for the data type of this object',
-                                          'text', required=False)
 
     def test_constructor(self):
         spec = GroupSpec('A test group',
@@ -101,6 +98,32 @@ class GroupSpecTests(TestCase):
         spec.set_dataset(self.datasets[0])
         self.assertIs(spec, self.datasets[0].parent)
 
+    def test_set_link(self):
+        group = GroupSpec(
+            doc='A test group',
+            name='root'
+        )
+        link = LinkSpec(
+            doc='A test link',
+            target_type='LinkTarget',
+            name='link_name'
+        )
+        group.set_link(link)
+        self.assertIs(group, link.parent)
+        self.assertIs(group.get_link('link_name'), link)
+
+    def test_add_link(self):
+        group = GroupSpec(
+            doc='A test group',
+            name='root'
+        )
+        group.add_link(
+            'A test link',
+            'LinkTarget',
+            name='link_name'
+        )
+        self.assertIsInstance(group.get_link('link_name'), LinkSpec)
+
     def test_set_group(self):
         spec = GroupSpec('A test group',
                          name='root_test_set_group',
@@ -113,6 +136,17 @@ class GroupSpecTests(TestCase):
         self.assertIs(spec, self.subgroups[1].parent)
         json.dumps(spec)
 
+    def test_add_group(self):
+        group = GroupSpec(
+            doc='A test group',
+            name='root'
+        )
+        group.add_group(
+            'A test group',
+            name='subgroup'
+        )
+        self.assertIsInstance(group.get_group('subgroup'), GroupSpec)
+
     def test_type_extension(self):
         spec = GroupSpec('A test group',
                          name='parent_type',
@@ -154,10 +188,6 @@ class GroupSpecTests(TestCase):
         self.assertEqual(str(ext_dset2), str(self.datasets[1]))
         self.assertAttributesEqual(ext_dset2, self.datasets[1])
 
-        # self.ns_attr_spec
-        ndt_attr_spec = AttributeSpec('data_type', 'the data type of this object',  # noqa: F841
-                                      'text', value='SpikeData')
-
         res_attrs = ext.attributes
         self.assertDictEqual(res_attrs[0], ext_attributes[0])
         self.assertDictEqual(res_attrs[1], self.attributes[0])
@@ -373,3 +403,327 @@ class TestResolveAttrs(TestCase):
         self.assertFalse(self.inc_group_spec.is_overridden_attribute('attribute3'))
         with self.assertRaisesWith(ValueError, "Attribute 'attribute4' not found"):
             self.inc_group_spec.is_overridden_attribute('attribute4')
+
+
+class GroupSpecWithLinksTest(TestCase):
+
+    def test_constructor(self):
+        link0 = LinkSpec(doc='Link 0', target_type='TargetType0')
+        link1 = LinkSpec(doc='Link 1', target_type='TargetType1')
+        links = [link0, link1]
+        spec = GroupSpec(
+            doc='A test group',
+            name='root',
+            links=links
+        )
+        self.assertIs(spec, links[0].parent)
+        self.assertIs(spec, links[1].parent)
+        json.dumps(spec)
+
+    def test_extension_no_overwrite(self):
+        link0 = LinkSpec(doc='Link 0', target_type='TargetType0')  # test unnamed
+        link1 = LinkSpec(doc='Link 1', target_type='TargetType1', name='MyType1')  # test named
+        link2 = LinkSpec(doc='Link 2', target_type='TargetType2', quantity='*')  # test named, multiple
+        links = [link0, link1, link2]
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=links,
+            data_type_def='ParentType'
+        )
+        child_spec = GroupSpec(
+            doc='A test group',
+            name='child',
+            data_type_inc=parent_spec,
+            data_type_def='ChildType'
+        )
+
+        for link in links:
+            with self.subTest(link_target_type=link.target_type):
+                self.assertTrue(child_spec.is_inherited_spec(link))
+                self.assertFalse(child_spec.is_overridden_spec(link))
+
+    def test_extension_overwrite(self):
+        link0 = LinkSpec(doc='Link 0', target_type='TargetType0', name='MyType0')
+        link1 = LinkSpec(doc='Link 1', target_type='TargetType1', name='MyType1')
+        # NOTE overwriting unnamed LinkSpec is not allowed
+        # NOTE overwriting spec with quantity that could be >1 is not allowed
+        links = [link0, link1]
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=links,
+            data_type_def='ParentType'
+        )
+
+        link0_overwrite = LinkSpec(doc='New link 0', target_type='TargetType0', name='MyType0')
+        link1_overwrite = LinkSpec(doc='New link 1', target_type='TargetType1Child', name='MyType1')
+        overwritten_links = [link0_overwrite, link1_overwrite]
+        child_spec = GroupSpec(
+            doc='A test group',
+            name='child',
+            links=overwritten_links,
+            data_type_inc=parent_spec,
+            data_type_def='ChildType'
+        )
+
+        for link in overwritten_links:
+            with self.subTest(link_target_type=link.target_type):
+                self.assertTrue(child_spec.is_inherited_spec(link))
+                self.assertTrue(child_spec.is_overridden_spec(link))
+
+
+class SpecWithDupsTest(TestCase):
+
+    def test_two_unnamed_group_same_type(self):
+        """Test creating a group contains multiple unnamed groups with type X."""
+        child0 = GroupSpec(doc='Group 0', data_type_inc='Type0')
+        child1 = GroupSpec(doc='Group 1', data_type_inc='Type0')
+        msg = "Cannot have multiple groups/datasets with the same data type without specifying name"
+        with self.assertRaisesWith(ValueError, msg):
+            GroupSpec(
+                doc='A test group',
+                name='parent',
+                groups=[child0, child1],
+                data_type_def='ParentType'
+            )
+
+    def test_named_unnamed_group_with_def_same_type(self):
+        """Test get_data_type when a group contains both a named and unnamed group with type X."""
+        child0 = GroupSpec(doc='Group 0', data_type_def='Type0', name='type0')
+        child1 = GroupSpec(doc='Group 1', data_type_inc='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            groups=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child1)
+
+    def test_named_unnamed_group_same_type(self):
+        """Test get_data_type when a group contains both a named and unnamed group with type X."""
+        child0 = GroupSpec(doc='Group 0', data_type_inc='Type0', name='type0')
+        child1 = GroupSpec(doc='Group 1', data_type_inc='Type0', name='type1')
+        child2 = GroupSpec(doc='Group 2', data_type_inc='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            groups=[child0, child1, child2],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child2)
+
+    def test_unnamed_named_group_same_type(self):
+        """Test get_data_type when a group contains both an unnamed and named group with type X."""
+        child0 = GroupSpec(doc='Group 0', data_type_inc='Type0')
+        child1 = GroupSpec(doc='Group 1', data_type_inc='Type0', name='type1')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            groups=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child0)
+
+    def test_two_named_group_same_type(self):
+        """Test get_data_type when a group contains multiple named groups with type X."""
+        child0 = GroupSpec(doc='Group 0', data_type_inc='Type0', name='group0')
+        child1 = GroupSpec(doc='Group 1', data_type_inc='Type0', name='group1')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            groups=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertEqual(parent_spec.get_data_type('Type0'), [child0, child1])
+
+    def test_two_unnamed_datasets_same_type(self):
+        """Test creating a group contains multiple unnamed datasets with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_inc='Type0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0')
+        msg = "Cannot have multiple groups/datasets with the same data type without specifying name"
+        with self.assertRaisesWith(ValueError, msg):
+            GroupSpec(
+                doc='A test group',
+                name='parent',
+                datasets=[child0, child1],
+                data_type_def='ParentType'
+            )
+
+    def test_named_unnamed_dataset_with_def_same_type(self):
+        """Test get_data_type when a group contains both a named and unnamed dataset with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_def='Type0', name='type0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child1)
+
+    def test_named_unnamed_dataset_same_type(self):
+        """Test get_data_type when a group contains both a named and unnamed dataset with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_inc='Type0', name='type0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child1)
+
+    def test_two_named_unnamed_dataset_same_type(self):
+        """Test get_data_type when a group contains two named and one unnamed dataset with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_inc='Type0', name='type0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0', name='type1')
+        child2 = DatasetSpec(doc='Group 2', data_type_inc='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child0, child1, child2],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child2)
+
+    def test_unnamed_named_dataset_same_type(self):
+        """Test get_data_type when a group contains both an unnamed and named dataset with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_inc='Type0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0', name='type1')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child0)
+
+    def test_two_named_datasets_same_type(self):
+        """Test get_data_type when a group contains multiple named datasets with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_inc='Type0', name='group0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0', name='group1')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertEqual(parent_spec.get_data_type('Type0'), [child0, child1])
+
+    def test_three_named_datasets_same_type(self):
+        """Test get_target_type when a group contains three named links with type X."""
+        child0 = DatasetSpec(doc='Group 0', data_type_inc='Type0', name='group0')
+        child1 = DatasetSpec(doc='Group 1', data_type_inc='Type0', name='group1')
+        child2 = DatasetSpec(doc='Group 2', data_type_inc='Type0', name='group2')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child0, child1, child2],
+            data_type_def='ParentType'
+        )
+        self.assertEqual(parent_spec.get_data_type('Type0'), [child0, child1, child2])
+
+    def test_two_unnamed_links_same_type(self):
+        """Test creating a group contains multiple unnamed links with type X."""
+        child0 = LinkSpec(doc='Group 0', target_type='Type0')
+        child1 = LinkSpec(doc='Group 1', target_type='Type0')
+        msg = "Cannot have multiple links with the same target type without specifying name"
+        with self.assertRaisesWith(ValueError, msg):
+            GroupSpec(
+                doc='A test group',
+                name='parent',
+                links=[child0, child1],
+                data_type_def='ParentType'
+            )
+
+    def test_named_unnamed_link_same_type(self):
+        """Test get_target_type when a group contains both a named and unnamed link with type X."""
+        child0 = LinkSpec(doc='Group 0', target_type='Type0', name='type0')
+        child1 = LinkSpec(doc='Group 1', target_type='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_target_type('Type0'), child1)
+
+    def test_two_named_unnamed_link_same_type(self):
+        """Test get_target_type when a group contains two named and one unnamed link with type X."""
+        child0 = LinkSpec(doc='Group 0', target_type='Type0', name='type0')
+        child1 = LinkSpec(doc='Group 1', target_type='Type0', name='type1')
+        child2 = LinkSpec(doc='Group 2', target_type='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=[child0, child1, child2],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_target_type('Type0'), child2)
+
+    def test_unnamed_named_link_same_type(self):
+        """Test get_target_type when a group contains both an unnamed and named link with type X."""
+        child0 = LinkSpec(doc='Group 0', target_type='Type0')
+        child1 = LinkSpec(doc='Group 1', target_type='Type0', name='type1')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_target_type('Type0'), child0)
+
+    def test_two_named_links_same_type(self):
+        """Test get_target_type when a group contains multiple named links with type X."""
+        child0 = LinkSpec(doc='Group 0', target_type='Type0', name='group0')
+        child1 = LinkSpec(doc='Group 1', target_type='Type0', name='group1')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=[child0, child1],
+            data_type_def='ParentType'
+        )
+        self.assertEqual(parent_spec.get_target_type('Type0'), [child0, child1])
+
+    def test_three_named_links_same_type(self):
+        """Test get_target_type when a group contains three named links with type X."""
+        child0 = LinkSpec(doc='Group 0', target_type='Type0', name='type0')
+        child1 = LinkSpec(doc='Group 1', target_type='Type0', name='type1')
+        child2 = LinkSpec(doc='Group 2', target_type='Type0', name='type2')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            links=[child0, child1, child2],
+            data_type_def='ParentType'
+        )
+        self.assertEqual(parent_spec.get_target_type('Type0'), [child0, child1, child2])
+
+
+class SpecWithGroupsLinksTest(TestCase):
+
+    def test_unnamed_group_link_same_type(self):
+        child = GroupSpec(doc='Group 0', data_type_inc='Type0')
+        link = LinkSpec(doc='Link 0', target_type='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            groups=[child],
+            links=[link],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child)
+        self.assertIs(parent_spec.get_target_type('Type0'), link)
+
+    def test_unnamed_dataset_link_same_type(self):
+        child = DatasetSpec(doc='Dataset 0', data_type_inc='Type0')
+        link = LinkSpec(doc='Link 0', target_type='Type0')
+        parent_spec = GroupSpec(
+            doc='A test group',
+            name='parent',
+            datasets=[child],
+            links=[link],
+            data_type_def='ParentType'
+        )
+        self.assertIs(parent_spec.get_data_type('Type0'), child)
+        self.assertIs(parent_spec.get_target_type('Type0'), link)


----

problem_statement: [Feature]: Differentiate between calling Container __init__ by user vs during object construction
### What would you like to see added to HDMF?

In PyNWB/HDMF, we often want to check that input parameters to an object's __init__ method are compliant with the schema. Over time, w may add new checks in __init__ that raise errors when a noncompliant set of parameters is provided. However, if this noncompliant set of parameters were allowed to be written by a previous version of the API, then files exist in the wild with this noncompliant set of parameters. Reading these files involves constructing the object using the object's constructor, which now raises an error. Even though these files are technically noncompliant, they still need to be readable by the current API. 


### Is your feature request related to a problem?

_No response_

### What solution would you like?

Provide two different paths or a branching path in __init__ where calling __init__ by a user would raise an error if non-compliant data are provided but calling __init__ by the object construction process would raise a warning if non-compliant data are provided.

### Do you have any interest in helping implement the feature?

Yes.

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/hdmf-dev/hdmf/blob/dev/.github/CODE_OF_CONDUCT.md)
- [X] Have you checked the [Contributing](https://github.com/hdmf-dev/hdmf/blob/dev/docs/CONTRIBUTING.rst) document?
- [X] Have you ensured this change was not already [requested](https://github.com/hdmf-dev/hdmf/issues)?
date: 2022-07-21
patch: diff --git a/docs/gallery/multicontainerinterface.py b/docs/gallery/multicontainerinterface.py
--- a/docs/gallery/multicontainerinterface.py
+++ b/docs/gallery/multicontainerinterface.py
@@ -1,4 +1,5 @@
 """
+# noop comment for filter
 MultiContainerInterface
 ========================
 

test_patch: diff --git a/tests/unit/test_container.py b/tests/unit/test_container.py
index f32897e..a6d4529 100644
--- a/tests/unit/test_container.py
+++ b/tests/unit/test_container.py
@@ -1,4 +1,6 @@
 import numpy as np
+from uuid import uuid4, UUID
+
 from hdmf.container import AbstractContainer, Container, Data
 from hdmf.testing import TestCase
 from hdmf.utils import docval
@@ -10,22 +12,47 @@ class Subcontainer(Container):
 
 class TestContainer(TestCase):
 
-    def test_constructor(self):
-        """Test that constructor properly sets parent and both child and parent have an object_id
+    def test_new(self):
+        """Test that __new__ properly sets parent and other fields.
         """
         parent_obj = Container('obj1')
-        child_obj = Container.__new__(Container, parent=parent_obj)
+        child_object_id = str(uuid4())
+        child_obj = Container.__new__(Container, parent=parent_obj, object_id=child_object_id,
+                                      container_source="test_source")
         self.assertIs(child_obj.parent, parent_obj)
         self.assertIs(parent_obj.children[0], child_obj)
-        self.assertIsNotNone(parent_obj.object_id)
-        self.assertIsNotNone(child_obj.object_id)
+        self.assertEqual(child_obj.object_id, child_object_id)
+        self.assertFalse(child_obj._in_construct_mode)
+        self.assertTrue(child_obj.modified)
 
-    def test_constructor_object_id_none(self):
-        """Test that setting object_id to None in __new__ is OK and the object ID is set on get
+    def test_new_object_id_none(self):
+        """Test that passing object_id=None to __new__ is OK and results in a non-None object ID being assigned.
         """
         parent_obj = Container('obj1')
         child_obj = Container.__new__(Container, parent=parent_obj, object_id=None)
         self.assertIsNotNone(child_obj.object_id)
+        UUID(child_obj.object_id, version=4)  # raises ValueError if invalid
+
+    def test_new_construct_mode(self):
+        """Test that passing in_construct_mode to __new__ sets _in_construct_mode and _in_construct_mode can be reset.
+        """
+        parent_obj = Container('obj1')
+        child_obj = Container.__new__(Container, parent=parent_obj, object_id=None, in_construct_mode=True)
+        self.assertTrue(child_obj._in_construct_mode)
+        child_obj._in_construct_mode = False
+        self.assertFalse(child_obj._in_construct_mode)
+
+    def test_init(self):
+        """Test that __init__ properly sets object ID and other fields.
+        """
+        obj = Container('obj1')
+        self.assertIsNotNone(obj.object_id)
+        UUID(obj.object_id, version=4)  # raises ValueError if invalid
+        self.assertFalse(obj._in_construct_mode)
+        self.assertTrue(obj.modified)
+        self.assertEqual(obj.children, tuple())
+        self.assertIsNone(obj.parent)
+        self.assertEqual(obj.name, 'obj1')
 
     def test_set_parent(self):
         """Test that parent setter properly sets parent
diff --git a/tests/unit/test_io_hdf5_h5tools.py b/tests/unit/test_io_hdf5_h5tools.py
index 100d969..74342eb 100644
--- a/tests/unit/test_io_hdf5_h5tools.py
+++ b/tests/unit/test_io_hdf5_h5tools.py
@@ -1053,7 +1053,7 @@ class HDF5IOMultiFileTest(TestCase):
         # Close all the files
         for i in self.io:
             i.close()
-            del(i)
+            del i
         self.io = None
         self.f = None
         # Make sure the files have been deleted
@@ -1282,7 +1282,7 @@ class HDF5IOInitFileExistsTest(TestCase):
     def tearDown(self):
         if self.io is not None:
             self.io.close()
-            del(self.io)
+            del self.io
         if os.path.exists(self.path):
             os.remove(self.path)
 
@@ -1316,7 +1316,7 @@ class HDF5IOReadNoDataTest(TestCase):
     def tearDown(self):
         if self.io is not None:
             self.io.close()
-            del(self.io)
+            del self.io
 
         if os.path.exists(self.path):
             os.remove(self.path)
@@ -1358,7 +1358,7 @@ class HDF5IOReadData(TestCase):
     def tearDown(self):
         if self.io is not None:
             self.io.close()
-            del(self.io)
+            del self.io
         if os.path.exists(self.path):
             os.remove(self.path)
 
@@ -1390,7 +1390,7 @@ class HDF5IOReadBuilderClosed(TestCase):
     def tearDown(self):
         if self.io is not None:
             self.io.close()
-            del(self.io)
+            del self.io
 
         if os.path.exists(self.path):
             os.remove(self.path)
@@ -1459,7 +1459,7 @@ class HDF5IOWriteFileExists(TestCase):
     def tearDown(self):
         if self.io is not None:
             self.io.close()
-            del(self.io)
+            del self.io
         if os.path.exists(self.path):
             os.remove(self.path)
 


----

problem_statement: [Bug]: Change behavior of call_docval_func
### What happened?

Any code that uses `call_docval_func` on a Container's `__init__` class often results in warnings that using positional arguments will be deprecated soon. This is beavuse `call_docval_func` takes in a function and keyword arguments to pass to the function, splits the arguments into positional arguments and keyword arguments based on whether the docval on the function says that the arguments is required or has a default value, and then calls the function, passing positional and keyword arguments. 

I see no reason to take keyword arguments being passed to a function and remove the keyword before calling the function. 

### Steps to Reproduce

```shell
n/a
```


### Traceback

```shell
n/a
```


### Operating System

Windows

### Python Executable

Python

### Python Version

3.9

### Package Versions

_No response_

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/hdmf-dev/hdmf/blob/dev/.github/CODE_OF_CONDUCT.md)
- [X] Have you checked the [Contributing](https://github.com/hdmf-dev/hdmf/blob/dev/docs/CONTRIBUTING.rst) document?
- [X] Have you ensured this bug was not already [reported](https://github.com/hdmf-dev/hdmf/issues)?
date: 2022-08-04
patch: diff --git a/docs/gallery/multicontainerinterface.py b/docs/gallery/multicontainerinterface.py
--- a/docs/gallery/multicontainerinterface.py
+++ b/docs/gallery/multicontainerinterface.py
@@ -1,4 +1,5 @@
 """
+# noop comment for filter
 # noop comment for filter
 MultiContainerInterface
 ========================