magicgui
magicgui copied to clipboard
UIFields and dataclasses: near-term roadmap for magicgui
Just wanted to lay out in writing some of the near-term plans that I have for magicgui:
Widget "fields" not "parameters"
I regret that function signatures were chosen as the most common way to instantiate and present a group of widgets in magicgui. I think it is one or two levels "too high". A think a better parallel would have been dataclasses. So many things fall into the general pattern of name: annotation = default value
beyond just functions, such as typing.NamedTuple
, typing.TypedDict
, dataclasses.dataclass
, pydantic.BaseModel
, attrs.define
... and, yes, function signatures.
Rather than framing a widget as having a relation to an inspect.Parameter
(see magicgui/signature.py), i think a better analogue would have been dataclasses.Field
. Currently, if one wants to make a compound widget in magicgui, you either manually construct it using the direct widget API with Container
and create_widget
, or you create a function and use @magicgui
(which then uses inspect.signature
and widgets are created for each Parameter
with MagicParameter.to_widget()
).
I've seen people do this:
@magicgui
def dummy_function(name: str, age: int = 0): ...
...just to create a widget. Which is a good indicator that the abstraction was too specific. There are plenty of use cases where one just wants to collect some data without necessarily passing them all to a specific function.
a collection of UiField
s
In upcoming PRs, I'll be creating a "parallel API" (probably under a v2
or datagui
namespace) that instead adds a magicgui.UiField
object, (akin to dataclasses.Field
, or pydantic.fields.ModelField
or attrs.Attribute
) that stores the metadata associated with a widget. This will still applicable to function signatures, but will make it much easier to construct a widget using a dataclass, or pydantic model, or attrs class, etc...:
# all of the representations we could easily support
@dataclass
class Person:
name: str
age: int = 0
@attrs.define
class Person:
name: str
age: int = 0
class Person(BaseModel):
name: str
age: int = 0
class Person(typing.NamedTuple):
name: str
age: int = 0
class Person(typing.TypedDict):
name: str
age: int
# functions are just a special case
def Person(name: str, age: int = 0):
...
# JSON Schema
Person = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "number", "default": 0},
}
}
create_widget(Person)
under the hood, each of these is reduced to a gui model that consists of a sequence of UIFields
:
model = [UiField(name='name', type=str), UiField(name='age', type=int, default=0)]
and then to make a widget:
container = Container(widgets=[field.create_widget() for field in model])
what about @magicgui
for functions
Functions are still easy: you can always connect a callback to be called with the current values of the widgets... but it should be much easier to create a widget representing a set of fields without having to associate them with some callback. So none of this needs to affect the use case for functions... it's more about how we conceive of the underlying model here.
JSON schema connection
I'd like UiField
to have a direct parallel with other schema languages, with JSON schema being the reference. So, all of the keywords in the validation vocabulary (things like maximum
, maxLength
, multipleOf
, etc...) would have direct parallels in the UiField
object (similar to a pydantic FieldInfo object). This would provide an easier connection with non-python representations of data schema, and would make it easier to do things in browsers (e.g. jupyter) with one of many javascript libraries that create UIs from schemas.
TODO:
- [x] cleanup forward reference resolution: #448
- [x] add a UiField class: #475
- [ ] create functions that convert dataclasses, pydantic objects, attrs classes, functions, etc... To some
UiModel
representation - [ ] cleanup the type -> widget_type mapping in
type_map.py
(it has become messy) - [x] review all of the parameter naming, and
__init__
signatures, possibly deprecate/rename things (mostly done in https://github.com/pyapp-kit/magicgui/pull/519) - [ ] convert internals of
@magicgui
to use theUiField
stuff instead ofMagicSignature
, etc - [ ] dramatically simplify documentation (#527). Here are the things I would emphasize:
- magicgui has an abstraction for various widgets (
SpinBox
,Slider
, etc..)) - magicgui maps python types to widget types (e.g.
int
->SpinBox
) - for any collection of names, types, & default values (e.g. dataclasses, models, function), magicgui can create a model of types and constraints as a set of UiFields
- that collection of UiFields can be converted to a
Container
widget... which has parallels to a dataclass API - finally, the direct Widget API could be used
- magicgui has an abstraction for various widgets (
cc @hanjinliu, @Czaki, @brisvag, @dstansby, @jni, @gselzer .... lemme know if you have any broad thoughts here
Love this! It came up before several times, and I'm one of the many who use magicgui in the way you described. Couldn't be more on board!
It looks like something that will allow replacing part of the old PartSeg code. I like it.
I totally agree with these updates!
One thing I concern is that users still have to rely on insert
for PushButton.
Of course one can do
class Person:
button = SomeField(widget_type=PushButton)
but,
- the 'button' attribute is completely redundant when class Person is used without GUI.
- push buttons need callback.
I think the direct widget API still needs more emphasis.
class Person: button = SomeField(widget_type=PushButton)
Yeah, this feels odd... My intuition would want something like this:
class Person:
@button
def callback(self): ...
Yeah, I recognize that buttons are still problematic. They are, of course, fundamentally a bit different than all of the other ValueWidgets in that they don't (really) contain a value. So, keeping with that, I don't see them as part of the dataclass & UiField abstraction per se... but rather something that would come on top of it / in addition to it.
I do like something along the lines of the @button
decorator in @brisvag's example above. We can definitely do something like that:
@dataclass
class Person:
name: str
age: int = 0
@button(label='Say Hi', order=0)
def _say_hi(self):
print(f"hi {self.name}!")
then:
model = build_model(Person)
# [
# UiField(name='_say_hi', label='Say Hi', widget='PushButton', on_click=Person._say_hi)
# UiField(name='name', type=str),
# UiField(name='age', type=int, default=0),
# ]
# ... or something like that
I think the direct widget API still needs more emphasis.
I agree with this, and that would be part of the docs update mentioned in the original post