magicgui icon indicating copy to clipboard operation
magicgui copied to clipboard

UIFields and dataclasses: near-term roadmap for magicgui

Open tlambert03 opened this issue 2 years ago • 6 comments

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 UiFields

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 the UiField stuff instead of MagicSignature, etc
  • [ ] dramatically simplify documentation (#527). Here are the things I would emphasize:
    1. magicgui has an abstraction for various widgets (SpinBox,Slider, etc..))
    2. magicgui maps python types to widget types (e.g. int -> SpinBox)
    3. 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
    4. that collection of UiFields can be converted to a Container widget... which has parallels to a dataclass API
    5. finally, the direct Widget API could be used

tlambert03 avatar Oct 23 '22 12:10 tlambert03

cc @hanjinliu, @Czaki, @brisvag, @dstansby, @jni, @gselzer .... lemme know if you have any broad thoughts here

tlambert03 avatar Oct 23 '22 12:10 tlambert03

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!

brisvag avatar Oct 24 '22 13:10 brisvag

It looks like something that will allow replacing part of the old PartSeg code. I like it.

Czaki avatar Oct 24 '22 21:10 Czaki

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.

hanjinliu avatar Oct 25 '22 10:10 hanjinliu

class Person:
    button = SomeField(widget_type=PushButton)

Yeah, this feels odd... My intuition would want something like this:

class Person:
	@button
   def callback(self): ...

brisvag avatar Oct 25 '22 13:10 brisvag

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

tlambert03 avatar Oct 25 '22 15:10 tlambert03