Building a custom Python API for an extension
Creating custom extensions is recommended if you want a stable API that can remain the same even as you make changes
to the internal data organization. The pynwb.core
module has various tools to make it easier to write
classes that behave like the rest of the PyNWB API.
The pynwb.core
defines two base classes that represent the primitive structures supported by
the schema. NWBData
represents datasets and NWBContainer
represents groups. Additionally, pynwb.core
offers subclasses of these two classes for
writing classes that come with more functionality.
Docval
docval is a library within PyNWB and HDMF that performs input validation and automatic documentation generation. Using
the docval
decorator is recommended for methods of custom API classes.
This decorator takes a list of dictionaries that specify the method parameters. These
dictionaries are used for enforcing type and building a Sphinx docstring.
The first arguments are dictionaries that specify the positional
arguments and keyword arguments of the decorated function. These dictionaries
must contain the following keys: 'name'
, 'type'
, and 'doc'
. This will define a
positional argument. To define a keyword argument, specify a default value
using the key 'default'
. To validate the dimensions of an input array
add the optional 'shape'
parameter.
The decorated method must take self
and **kwargs
as arguments.
When using this decorator, the functions getargs()
and
popargs()
can be used for easily extracting arguments from
kwargs.
The following code example demonstrates the use of this decorator:
@docval({'name': 'arg1':, 'type': str, 'doc': 'this is the first positional argument'},
{'name': 'arg2':, 'type': int, 'doc': 'this is the second positional argument'},
{'name': 'kwarg1':, 'type': (list, tuple), 'doc': 'this is a keyword argument', 'default': list()},
returns='foo object', rtype='Foo')
def foo(self, **kwargs):
arg1, arg2, kwarg1 = getargs('arg1', 'arg2', 'kwarg1', **kwargs)
...
The 'shape'
parameter is a tuple that follows the same logic as the shape parameter in the specification
language. It can take the form of a tuple
with integers or None
in each dimension. None
indicates that this dimension can take any value. For
instance, (3, None)
means the data must be a 2D matrix with a length of 3 and any width. 'shape'
can also
take a value that is a tuple of tuples, in which case any one of those tuples can match the spec. For instance,
"shape": ((3, 3), (4, 4, 4))
would indicate that the shape of this data could either be 3x3 or 4x4x4.
The 'type'
argument can take a class or a tuple of classes. We also define special strings that are macros which
encompass a number of similar types, and can be used in place of a class, on its own, or within a tuple. 'array_data'
allows the data to be of type np.ndarray
, list
, tuple
, or h5py.Dataset
; and 'scalar_data'
allows
the data to be str
, int
, float
, bytes
, or bool
.
Registering classes
When defining a class that represents a neurodata_type (i.e. anything that has a neurodata_type_def)
from your extension, you can tell PyNWB which neurodata_type it represents using the function
register_class()
. This class can be called on its own, or used as a class decorator. The
first argument should be the neurodata_type and the second argument should be the namespace name.
The following example demonstrates how to register a class as the Python class representation of the neurodata_type “MyContainer” from the namespace “my_ns”.
from pynwb import register_class
from pynwb.core import NWBContainer
class MyContainer(NWBContainer):
...
regitser_class('MyContainer', 'my_ns', MyContainer)
Alternatively, you can use register_class()
as a decorator.
from pynwb import register_class
from pynwb.core import NWBContainer
@regitser_class('MyContainer', 'my_ns')
class MyContainer(NWBContainer):
...
register_class()
is used with NWBData
the same way it is used with
NWBContainer
.
Nwbfields
When creating a new neurodata type, you need to define the new properties on your class, which is done by defining
them in the __nwbfields__
class property. This class property should be a tuple of strings that name the new
properties. Adding a property using this functionality will create a property than can be set only once. Any
new properties of the class should be defined here.
For example, the following class definition will create the MyContainer
class that has the properties foo
and bar
.
from pynwb import register_class
from pynwb.core import NWBContainer
class MyContainer(NWBContainer):
__nwbfields__ = ('foo', 'bar')
...
NWBContainer
NWBContainer
should be used to represent groups with a neurodata_type_def. This section
will discuss the available NWBContainer
subclasses for representing common group specifications.
NWBDataInterface
The NWB schema uses the neurodata type NWBDataInterface
for specifying containers that contain data that is not
considered metadata. For example, NWBDataInterface
is a parent neurodata type to ElectricalSeries
data,
but not a parent to ElectrodeGroup
.
There are no requirements for using NWBDataInterface
in addition to those inherited from
NWBContainer
.
MultiContainerInterface
Throughout the NWB schema, there are multiple NWBDataInterface
specifications that include one or more or zero
or more of a certain neurodata type. For example, the LFP
neurodata type contains one or more ElectricalSeries
.
If your extension follows this pattern, you can use MultiContainerInterface
for defining
the representative class.
MultiContainerInterface
provides a way of automatically generating setters, getters, and
properties for your class. These methods are autogenerated based on a configuration provided using the class property
__clsconf__
. __clsconf__
should be a dict or a list of dicts. A single dict should be used if your
specification contains a single neurodata type. A list of dicts should be used if your specification contains
multiple neurodata types that will exist as one or more or zero or more. The contents of the dict are described
in the following table.
Key |
Attribute |
Required? |
---|---|---|
|
the type of the Container |
Yes |
|
the property name that holds the Containers |
Yes |
|
the name of the method for adding a Container |
Yes |
|
the name of the method for creating a Container |
No |
|
the name of the method for getting a Container by name |
Yes |
The type
key provides a way for the setters to check for type. The property under the name given by the.
attr
key will be a LabelledDict
. If your class uses a single dict,
a __getitem__
method will be autogenerated for indexing into this LabelledDict
.
Finally, a constructor will also be autogenerated if you do not provide one in the class definition.
The following code block demonstrates using MultiContainerInterface
to build a class
that represents the neurodata type “MyDataInterface” from the namespace “my_ns”. It contains one or more containers
with neurodata type “MyContainer”.
from pynwb import register_class
from pynwb.core import MultiContainerInterface
@register_class("MyDataInterface", "my_ns")
class MyDataInterface(MultiContainerInterface):
__clsconf__ = {
'type': MyContainer,
'attr': 'containers',
'add': 'add_container',
'create': 'create_container',
'get': 'get_container',
}
...
This class will have the methods add_container
, create_container
, and get_container
. It will also have
the property containers
. The add_container
method will check to make sure that either an object of type
MyContainer
or a list/dict/tuple of objects of type MyContainer
is passed in. create_container
will
accept the exact same arguments that the MyContainer
class constructor accepts.
NWBData
NWBData
should be used to represent datasets with a neurodata_type_def. This section
will discuss the available NWBData
subclasses for representing common dataset specifications.
NWBTable
If your specification extension contains a table definition i.e. a dataset with a compound data type, you should use
the NWBTable
class to represent this specification. Since NWBTable
subclasses NWBData
, you can still use __nwbfields__
. In addition, you can use the
__columns__
class property to specify the columns of the table. __columns__
should be a list or a tuple of
docval()
-like dictionaries.
The following example demonstrates how to define a table with the columns foo
and bar
that are of type
str and int, respectively. We also register the class as the representation of the neurodata_type “MyTable”
from the namespace “my_ns”.
from pynwb import register_class
from pynwb.core import NWBTable
@register_class('MyTable', 'my_ns')
class MyTable(NWBTable):
__columns__ = [
{'name': 'foo', 'type': str, 'doc': 'the foo column'},
{'name': 'bar', 'type': int, 'doc': 'the bar column'},
]
...
NWBTableRegion
NWBTableRegion
should be used to represent datasets that store a region reference.
When subclassing this class, make sure you provide a way to pass in the required
arguments for the NWBTableRegion
constructor–the name of the dataset, the table that the region
applies to, and the region itself.
Custom data checks on __init__
When creating new instances of an API class, we commonly need to check that input parameters are valid.
As a common practice the individual checks are typically implemented as separate functions
named _check_....
on the class and then called in __init__
.
To support access to older file version (which may not have followed some new requirements)
while at the same time preventing the creation of new data that is invalid, PyNWB allows
us to detect in __init__
whether the object is being constructed by
the ObjectMapper
on read or directly by the user,
simply by checking if self._in_construct_mode
is True/False
. For convenience,
PyNWB provides the _error_on_new_warn_on_construct()
method,
which makes it easy to raise warnings on read and errors when creating new data.
ObjectMapper
Customizing the mapping between NWBContainer and the Spec
If your NWBContainer
extension requires custom mapping of the
NWBContainer
class for reading and writing, you will need to implement and register a custom
ObjectMapper
.
ObjectMapper
extensions are registered with the decorator
register_map()
.
from pynwb import register_map
from hdmf.build import ObjectMapper
@register_map(MyExtensionContainer)
class MyExtensionMapper(ObjectMapper)
...
register_map()
can also be used as a function.
from pynwb import register_map
from hdmf.build import ObjectMapper
class MyExtensionMapper(ObjectMapper)
...
register_map(MyExtensionContainer, MyExtensionMapper)
Tip
ObjectMappers allow you to customize how objects in the spec are mapped to attributes of your NWBContainer in
Python. This is useful, e.g., in cases where you want to customize the default mapping. For example in
TimeSeries
, the attribute unit
, which is defined on the dataset data
(i.e., data.unit
), would
by default be mapped to the attribute data__unit
on TimeSeries
. The ObjectMapper
TimeSeriesMap
then changes this mapping to map data.unit
to the attribute unit
on TimeSeries
. ObjectMappers also allow you to customize how constructor arguments
for your NWBContainer
are constructed. For example, in TimeSeries
instead of explicit timestamps
we
may only have a starting_time
and rate
. In the ObjectMapper, we could then construct timestamps
from this data on data load to always have timestamps
available for the user.
For an overview of the concepts of containers, spec, builders, and object mappers in PyNWB, see also
Software Architecture.