fork-tongue / collagraph

Collagraph 📓 Reactive user interfaces
MIT License
30 stars 1 forks source link

Allow for specifying props on components #32

Open berendkleinhaneveld opened 2 years ago

berendkleinhaneveld commented 2 years ago

Using the props specifications, we can further improve the compilation of template expressions.

berendkleinhaneveld commented 2 years ago

Inspiring docs: https://vuejs.org/guide/components/props.html#prop-validation

berendkleinhaneveld commented 2 years ago

I propose a class attribute __props__, like in the following example:

class Component:
    __props__ = {"base": "Component", "foo": "foo"}

class Sub(Component):
    __props__ = {"base": "Sub", "bar": "bar"}

def all_props(cls):
    result = {}
    for cls in reversed(cls.__mro__):
        if props := getattr(cls, "__props__", None):
            result.update(props)
    return result

if __name__ == "__main__":
    props = all_props(Sub)
    assert "base" in props
    assert "foo" in props
    assert "bar" in props
    assert props["base"] == "Sub"

Which also shows a method to retrieve all the props for a subclass of the component.

berendkleinhaneveld commented 1 year ago

I've been thinking about this a little bit and wanted to share my thoughts. I've come up with another method (which might be a bit more Pythonic) that would work as follows:

class CounterA(Component):
    def __init__(self, *, prop_a=0, prop_b=None, **kwargs):
        super().__init__(prop_a=prop_a, prop_b=prop_b, **kwargs)

Here, the user expresses the existing props by specifying keyword arguments of the __init__ function.

The following code figures out all the names of props for a component type:

import inspect

def get_prop_names(component_type):
    return [
        par.name
        for sig in [
            inspect.signature(cls.__init__) for cls in inspect.getmro(component_type)
        ]
        for par in sig.parameters.values()
        if par.kind == par.KEYWORD_ONLY and not par.name.startswith("_")
    ]

These signatures have to be cached somewhere of course for easy/quick lookup, based on component type.

Signature of __init__ method of Component would need to look something like the following, in order to pass the parent component as argument:

class Component:
    def __init__(self, __parent=None, **props):
        ...

Letting the user express the props this way would be pretty pythonic, I think, but for me the main reason against it would be that it requires 'duplicating' the __init__ arguments into the call to super().__init__(), which might result in user errors if people forget to pass it through. Seems like a lot of boilerplate.

Another consideration is that the user would be responsible mostly for checking and validating props. And the user would only be able to do that on __init__. Might be that that is just fine though. For instance, to check for required props could be done as follows:

def __init__(self, *, value=None, **kwargs):
    if value is None:
        raise RuntimeError(f"Missing required prop: {value}")
    super().__init__(value=value, **kwargs)

I like that this solution makes use of Python's type system, but the verbosity and limitations are things to consider.

Korijn commented 1 year ago

A common approach is to use class attributes, like in a dataclass. It's less pythonic, but it does require less boiler plate. There are a few popular libraries as well that did this before it was introduced to the stdlib: