Repository: valohai/valohai-yaml

Issue:
Support for parameter UI widgets (YAML)
Support in YAML to ask for a custom UI widget for parameter editing like SQL or DatumAlias.

Example UI:
![Image](https://user-images.githubusercontent.com/1525350/215443518-b12bb253-977e-4fca-a87f-ba6785135408.png)

Example YAML:
```
 - step:
     name: snowflake-query
     image: valohai/dbconnectors
     command: python query.py {parameters}
     icon: https://valohai.com/img/orig/SNOW-35164165.png
     category: Database query
     parameters:
     - name: SQLquery
       type: string
       default: SELECT * FROM table
       widget: sql
     - name: datum_alias
       type: string
       widget: datumalias
```

Comments:

Patch:
diff --git a/examples/example1.yaml b/examples/example1.yaml
index e66c1a4..bf77bb2 100644
--- a/examples/example1.yaml
+++ b/examples/example1.yaml
@@ -57,3 +57,16 @@
         description: List of decoding function types
         type: string
         default: gauss
+
+      - name: sql-query
+        type: string
+        default: SELECT * FROM mytable
+        widget: sql
+
+      - name: output-alias
+        type: string
+        default: my-alias
+        widget:
+          type: DatumAlias
+          settings:
+            width: 123
diff --git a/valohai_yaml/objs/parameter.py b/valohai_yaml/objs/parameter.py
index a0d12ed..a1f64e7 100644
--- a/valohai_yaml/objs/parameter.py
+++ b/valohai_yaml/objs/parameter.py
@@ -4,6 +4,7 @@ from typing import Any, Iterable, List, Optional, Union
 from valohai_yaml.excs import InvalidType, ValidationErrors
 from valohai_yaml.lint import LintResult
 from valohai_yaml.objs.base import Item
+from valohai_yaml.objs.parameter_widget import ParameterWidget
 from valohai_yaml.types import LintContext, SerializedDict
 from valohai_yaml.utils import listify
 
@@ -48,7 +49,8 @@ class Parameter(Item):
         pass_false_as: Optional[str] = None,
         choices: Optional[Iterable[ValueAtomType]] = None,
         multiple: Optional[Union[str, MultipleMode]] = None,
-        multiple_separator: str = ','
+        multiple_separator: str = ',',
+        widget: Optional[ParameterWidget] = None,
     ) -> None:
         self.name = name
         self.type = type
@@ -62,6 +64,8 @@ class Parameter(Item):
         self.choices = (list(choices) if choices else None)
         self.multiple = MultipleMode.cast(multiple)
         self.multiple_separator = str(multiple_separator or ',')
+        self.widget = widget
+
         self.default = (listify(default) if self.multiple else default)
         if self.type == 'flag':
             self.optional = True
@@ -194,6 +198,13 @@ class Parameter(Item):
 
         raise NotImplementedError(f'unknown multiple type {self.multiple!r}')
 
+    @classmethod
+    def parse(cls, data: SerializedDict) -> 'Parameter':
+        kwargs = data.copy()
+        if 'widget' in data:
+            kwargs['widget'] = ParameterWidget.parse(data['widget'])
+        return super().parse(kwargs)
+
     def lint(
         self,
         lint_result: LintResult,
diff --git a/valohai_yaml/objs/parameter_widget.py b/valohai_yaml/objs/parameter_widget.py
new file mode 100644
index 0000000..0530b97
--- /dev/null
+++ b/valohai_yaml/objs/parameter_widget.py
@@ -0,0 +1,38 @@
+from typing import Optional, Union
+
+from valohai_yaml.objs.base import Item
+from valohai_yaml.types import SerializedDict
+
+
+class ParameterWidget(Item):
+    """An UI widget for editing parameter values."""
+
+    def __init__(
+        self,
+        *,
+        type: str,
+        settings: Optional[dict] = None,
+    ) -> None:
+        self.type = str(type).lower()
+        self.settings = dict(settings or {})
+
+    def serialize(self) -> Optional[Union[SerializedDict, str]]:
+        if self.settings:
+            return {
+                'type': self.type,
+                'settings': self.settings,
+            }
+        return self.type
+
+    @classmethod
+    def parse(cls, data: Union['ParameterWidget', dict, str]) -> 'ParameterWidget':
+        if isinstance(data, dict):
+            return cls(
+                type=data['type'],
+                settings=data.get('settings'),
+            )
+
+        if isinstance(data, cls):
+            return data
+
+        return cls(type=str(data))
diff --git a/valohai_yaml/schema/param-item.yaml b/valohai_yaml/schema/param-item.yaml
index 25c2334..e315907 100644
--- a/valohai_yaml/schema/param-item.yaml
+++ b/valohai_yaml/schema/param-item.yaml
@@ -78,3 +78,16 @@ properties:
     description: |
       The separator to use when interpolating multiple values into a command parameter.
       Defaults to a comma.
+  widget:
+    oneOf:
+      - type: object
+        required:
+        - type
+        properties:
+          type:
+            type: string
+          settings:
+            type: object
+      - type: string
+    description: |
+      UI widget used for editing parameter values


Test patch:
diff --git a/tests/test_step_parsing.py b/tests/test_step_parsing.py
index 4eb3bdf..406889e 100644
--- a/tests/test_step_parsing.py
+++ b/tests/test_step_parsing.py
@@ -26,7 +26,7 @@ def test_parse(example1_config):
 
     # test that we parsed all params
     parameters = step.parameters
-    assert len(parameters) == 7
+    assert len(parameters) == 9
     # test that we can access them by name
     assert parameters['seed'].default == 1
     assert parameters['decoder-spec'].default == 'gauss'
@@ -39,6 +39,8 @@ def test_parse(example1_config):
         'encoder-layers',
         'denoising-cost-x',
         'decoder-spec',
+        'sql-query',
+        'output-alias',
     ]
 
     # test that `get_step_by` works
@@ -154,3 +156,12 @@ def test_timeouts(timeouts_config):
 def test_bling(example1_config: Config) -> None:
     assert example1_config.steps['run training'].category == "Training"
     assert example1_config.steps['run training'].icon == "https://valohai.com/assets/img/valohai-logo.svg"
+
+
+def test_widget(example1_config: Config) -> None:
+    parameters = example1_config.steps['run training'].parameters
+    assert parameters['sql-query'].widget and parameters['sql-query'].widget.type == "sql"
+    widget = parameters['output-alias'].widget
+    assert widget and widget.type == "datumalias"
+    assert widget and widget.settings and widget.settings["width"] == 123
+    assert parameters['decoder-spec'].widget is None