Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add base and multi controllers #255

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions nwbwidgets/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from .basecontroller import BaseController
from .genericcontroller import GenericController
from .group_and_sort_controllers import GroupAndSortController
from .misc import ProgressBar, make_trial_event_controller
from .multicontroller import MultiController
from .time_window_controllers import RangeController, StartAndDurationController
71 changes: 71 additions & 0 deletions nwbwidgets/controllers/basecontroller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Base class definition of all controllers.

Attempted to make the class abstract via `abc.ABC` to use `abc.abstractmethod` but ran into metaclass conflict
issues with `ipywidgets.Box`. Undefined methods instead raise NotImplementedErrors.
"""
from typing import Dict

import ipywidgets as widgets


class BaseController(widgets.Box):
CodyCBakerPhD marked this conversation as resolved.
Show resolved Hide resolved
"""
Base definition of all Controllers.

A Controller is a container of objects such as widgets, including other controllers, that exposes all important
components as non-private attributes at the outermost level for simplified reference.

This is in constrast to defining an ipywidget.Box of other Boxes, where the only way to reference a particular
sub-widget component is by navigating the children tree, knowing the set of levels and indices required to find
a particular child.
"""

def __init__(self, components: Dict[str, object]):
"""
Initialize this controller given the pre-initialized set of components.

To align the children vertically, set the `layout.flex_flow = "column"` after initializing.

Parameters
----------
components: dictionary
Used to map string names to widgets.
"""
super().__init__() # Setup Box properties
self.layout.display = "flex"
self.layout.align_items = "stretch"

self.setup_components(components=components)
self.setup_children()
self.setup_observers()

def setup_components(self, components: Dict[str, object]):
"""Define how to set the components given a dictionary of string mappings to arbitrary object types."""
raise NotImplementedError("This Controller has not defined how to setup its components!")

def setup_children(self):
"""Define how to set the children using the internal components."""
raise NotImplementedError("This Controller has not defined how to layout its children!")

def setup_observers(self):
"""
Define observation events specific to the interactions and values of components within the same Controller.
"""
# Instead of raising NotImplementedError or being an abstractmethod,
# a given widget may not need or want to use any local observers.
pass

def get_fields(self) -> Dict[str, object]:
"""
Return the custom attributes set at the outer level for this Controller.

Slightly more proper and better-looking than directly accessing the magic __dict__ attribute.

Returns
-------
fields: dict
The non-private attributes of this controller exposed at the outer-most level.
These can be widgets, other controllers, or even mutable references.
"""
return {k: v for k, v in self.__dict__.items() if not k.startswith("_") and k != "components"}
39 changes: 39 additions & 0 deletions nwbwidgets/controllers/genericcontroller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Dict

import ipywidgets

from .basecontroller import BaseController


class GenericController(BaseController):
"""Default all-purpose controller class."""

def _check_attribute_name_collision(self, name: str):
"""Enforce string name references to sub-widget or sub-controller objects to be unique."""
if hasattr(self, name):
raise KeyError(
f"This Controller already has an outer attribute with the name '{name}'! "
"Please adjust the reference key to be unique."
)

def setup_components(self, components: Dict[str, object]):
unpacked_components = dict()
for component_name, component in components.items():
if isinstance(component, BaseController):
raise ValueError(
f"Component '{component_name}' is a type of Controller! "
"Please use a MultiController to unpack its components."
)
elif isinstance(component, ipywidgets.Widget):
self._check_attribute_name_collision(name=component_name)
setattr(self, component_name, component)
unpacked_components.update({component_name: component})
else:
self._check_attribute_name_collision(name=component_name)
setattr(self, component_name, component)
self.components = unpacked_components # Maintain sub-component structure for provenance

def setup_children(self):
# A simple default rule for generating a box layout for this Controllers components.
# It is usually recommended to override with a custom layout for a specific visualization.
self.children = tuple(child for child in self.components.values())
46 changes: 46 additions & 0 deletions nwbwidgets/controllers/multicontroller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Dict, Union

import ipywidgets

from .basecontroller import BaseController
from .genericcontroller import GenericController


class MultiController(GenericController):
"""Extension of the default Controller class specifically designed to unpack nested Controllers."""

def _unpack_attributes(self, component):
"""If a component is a Controller, unpack its own attributes for outermost exposure."""
for field_name, field in component.get_fields().items():
self._check_attribute_name_collision(name=field_name)
setattr(self, field_name, field)

def setup_components(self, components: Dict[str, Union[ipywidgets.Widget]]):
unpacked_components = dict()
self._propagate_setup_observers = list()
for component_name, component in components.items():
if isinstance(component, BaseController):
self._unpack_attributes(component=component) # Unpack attributes to new outermost level
unpacked_components.update({component_name: component})
self._propagate_setup_observers.append(component.setup_observers)
elif isinstance(component, ipywidgets.Widget):
self._check_attribute_name_collision(name=component_name)
setattr(self, component_name, component)
unpacked_components.update({component_name: component})
else:
self._check_attribute_name_collision(name=component_name)
setattr(self, component_name, component)
self.components = unpacked_components # Maintain sub-component structure for provenance

def setup_children(self):
children = list()
for component in self.components.values():
if isinstance(component, BaseController):
children.append(component)
elif isinstance(component, ipywidgets.Widget):
children.append(component)
self.children = tuple(children)

def setup_observers(self):
for setup_observers in self._propagate_setup_observers:
setup_observers()
29 changes: 29 additions & 0 deletions test/test_controllers/test_base_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from hdmf.testing import TestCase

from nwbwidgets.controllers import BaseController


class ExampleSetupComponentsAbstractController(BaseController):
def setup_components(self, components):
pass


class ExampleSetupChildrenAbstractController(BaseController):
def setup_children(self, box_type):
pass


class TestBaseController(TestCase):
def test_setup_components_abstract(self):
with self.assertRaisesWith(
exc_type=NotImplementedError,
exc_msg="This Controller has not defined how to setup its components!",
):
ExampleSetupChildrenAbstractController(components=list())

def test_setup_children_abstract(self):
with self.assertRaisesWith(
exc_type=NotImplementedError,
exc_msg="This Controller has not defined how to layout its children!",
):
ExampleSetupComponentsAbstractController(components=list())
114 changes: 114 additions & 0 deletions test/test_controllers/test_generic_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from hdmf.testing import TestCase
from ipywidgets import Button, Checkbox

from nwbwidgets.controllers import GenericController


class ExampleGenericController(GenericController):
def __init__(self, components: dict):
self.custom_attribute = "abc"
super().__init__(components=components)


class ExampleControllerWithObservers(GenericController):
def __init__(self):
super().__init__(components=dict(button=Button(), check_box=Checkbox()))

def check_box_on_button_click(self, change):
if self.check_box.value is True:
self.check_box.value = False
elif self.check_box.value is False:
self.check_box.value = True

def setup_observers(self):
self.button.on_click(self.check_box_on_button_click)


class TestGenericController(TestCase):
def test_generic_controller_component_controller_assertion(self):
components = dict(other_controller=ExampleControllerWithObservers())

with self.assertRaisesWith(
exc_type=ValueError,
exc_msg=(
"Component 'other_controller' is a type of Controller! "
"Please use a MultiController to unpack its components."
),
):
ExampleGenericController(components=components)

def test_generic_controller_name_collision(self):
components = dict(button=Button(), check_box=Checkbox(), custom_attribute=123)

with self.assertRaisesWith(
exc_type=KeyError,
exc_msg=(
"\"This Controller already has an outer attribute with the name 'custom_attribute'! "
'Please adjust the reference key to be unique."'
),
):
ExampleGenericController(components=components)

def test_generic_controller_no_components(self):
controller = GenericController(components=dict())

self.assertDictEqual(d1=controller.components, d2=dict())
self.assertDictEqual(d1=controller.get_fields(), d2=dict())
self.assertTupleEqual(tuple1=controller.children, tuple2=())

def test_generic_controller_ipywidget_components(self):
button = Button()
check_box = Checkbox()
components = dict(button=button, check_box=check_box)
controller = GenericController(components=components)

expected_components = dict(button=button, check_box=check_box)
expected_fields = dict(button=button, check_box=check_box)
expected_children = (button, check_box)
self.assertDictEqual(d1=controller.components, d2=expected_components)
self.assertDictEqual(d1=controller.get_fields(), d2=expected_fields)
self.assertTupleEqual(tuple1=controller.children, tuple2=expected_children)

def test_generic_controller_standard_attributes(self):
"""Non-widget attributes were not included in the children of the box."""
button = Button()
check_box = Checkbox()
components = dict(
button=button,
check_box=check_box,
some_integer=1,
some_string="test",
some_float=1.23,
some_list=[1, 2, 3],
some_dict=dict(a=5, b=6, c=7),
)
Comment on lines +74 to +84
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand why GenericController contains non-widget components

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes it flexible for when you have multiple widget components that can mutually affect other properties - example from the original PR (not updated to this GenericController standard) is the RotationController: https://github.com/catalystneuro/nwbwidgets/blob/add_two_photon_color/nwbwidgets/controllers/image_controllers.py#L7-L29

Two buttons that affect a common value that is then used to determine some aspect of the final visualization.

Also, some attempts to use properties of widgets (an example being the fields of a DropDown) do not update in-place, so having a secondary attribute that is modifiable can be handy as well if you ever want interactions with those properties

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, but why throw all of these into one dictionary? I don't really understand the logic of this PR in several ways so let's talk about it when we meet tomorrow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, but why throw all of these into one dictionary?

For input, they naturally form key/value pairs as attributes. As far as why components itself is also saved as an attribute (which might seem redundant), yeah that's largely there for the MultiController which has the added behavior of more human-readable nesting of sub-controller components if someone ever wanted an easier direct reference to it as opposed to navigating lists of nested children. That is, some_multi_controller.components["SomeSubController"] instead of trying to find SomeSubController out of potentially several indices of some_multi_controller.children, which could get even worse when you start making MultiControllers out of MultiControllers.

I'd be OK with removing components as a stored attribute for the BaseController and the GenericController and only have it be present for the MultiController.

I don't really understand the logic of this PR in several ways so let's talk about it when we meet tomorrow.

I'd say it's better to ask as many questions as you can in text form so we have a permanent reference to look back on and also bring in @h-mayorquin and @luiztauffer who contributed a lot to early thoughts on designs for this kind of stuff

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why you would want to gather all of the widgets into a single dictionary. I think that's a good idea. But why also put state attributes in there instead of just making them standard attributes of the class instance?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good point. I can adjust it so that .components only stores ipywidgets object references. Other attributes can then be retrieved directly or via get_fields() (they convenience retrieval for seeing what attributes the class has instead of looking at it's magic __dict__)

controller = GenericController(components=components)

expected_components = dict(button=button, check_box=check_box)
expected_fields = dict(
button=button,
check_box=check_box,
some_integer=1,
some_string="test",
some_float=1.23,
some_list=[1, 2, 3],
some_dict=dict(a=5, b=6, c=7),
)
expected_children = (button, check_box)
self.assertDictEqual(d1=controller.components, d2=expected_components)
self.assertDictEqual(d1=controller.get_fields(), d2=expected_fields)
assert controller.children == expected_children
self.assertTupleEqual(tuple1=controller.children, tuple2=expected_children)

def test_generic_controller_with_observers(self):
controller = ExampleControllerWithObservers()

assert controller.check_box.value is False

controller.button.click()

assert controller.check_box.value is True

controller.button.click()

assert controller.check_box.value is False
Loading