diff --git a/doc/how_to/dataclass/index.md b/doc/how_to/dataclass/index.md new file mode 100644 index 0000000000..9a51d531b3 --- /dev/null +++ b/doc/how_to/dataclass/index.md @@ -0,0 +1,33 @@ +# Interfacing with ipywidgets and Pydantic Models + +Panel components and APIs are primarily built on [Param](https://param.holoviz.org/), a data-class-like library that adds parameter validation and event listeners to your objects. + +When working with other frameworks like ipywidgets or Pydantic, which also provide dataclass-like objects, we offer various utilities to create Parameterized objects, `rx` expressions, and more for seamless integration with Panel. + +::::{grid} 2 3 3 5 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`book;2.5em;sd-mr-1 sd-animate-grow50` Interact with ipywidgets +:link: ipywidget +:link-type: doc + +How to interact with ipywidgets via familiar APIs like watch, bind, depends, and rx. +::: + +:::{grid-item-card} {octicon}`triangle-up;2.5em;sd-mr-1 sd-animate-grow50` Interact with Pydantic models +:link: pydantic +:link-type: doc + +How to interact with Pydantic Models via familiar APIs like watch, bind, depends, and rx. +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +ipywidget +pydantic +``` diff --git a/doc/how_to/dataclass/ipywidget.md b/doc/how_to/dataclass/ipywidget.md new file mode 100644 index 0000000000..2c88b398ae --- /dev/null +++ b/doc/how_to/dataclass/ipywidget.md @@ -0,0 +1,185 @@ +# Interact with ipywidgets + +This how-to guide demonstrates how to easily enable interaction with [ipywidgets](https://ipywidgets.readthedocs.io/en/stable/) using familiar APIs from Panel and [Param](https://param.holoviz.org/), such as `watch`, `bind`, `depends`, and `rx`. + +## Overview + +The `pn.dataclass` module provides functions to synchronize the *traits* of an ipywidget with a Parameterized object, a Panel widget, or an `rx` value. It also provides a `ModelViewer` class for creating a Layoutable Viewer that wraps an ipywidget Widget class or instance. + +### Terminology + +- **Traitlets**: A library for creating dataclass like models (`HasTraits`) with observable fields (called *traits*). +- **ipywidgets**: A library for creating interactive widgets for notebooks. Its base class `Widget` derives from `HasTraits`. +- **widget**: Refers to ipywidgets `Widget` classes or instances unless otherwise stated. +- **model**: Refers to Traitlets `HasTraits` and ipywidgets `Widget` classes or instances. +- **names**: Refers to the names of the traits/parameters to synchronize. Can be an iterable or a dictionary mapping from trait names to parameter names. + +### Classes + +- **`ModelParameterized`**: An abstract Parameterized base class for wrapping a Traitlets `HasTraits` class or instance. +- **`ModelViewer`**: An abstract base class for creating a Layoutable Viewer that wraps an ipywidget Widget class or instance. + +### Functions + +- **`to_rx`**: Creates `rx` values from traits of a model, each synced to a trait of the model. +- **`sync_with_parameterized`**: Syncs the traits of a model with the parameters of a Parameterized object. +- **`sync_with_widget`**: Syncs a trait of the model with the value of a Panel widget. +- **`sync_with_rx`**: Syncs a single trait of a model with an `rx` value. + +All synchronization is bidirectional. Only top-level traits/parameters are synchronized, not nested ones. + +## Synchronize Traits of an ipywidget with Panel Widgets + +Use `sync_with_widget` to synchronize a trait of an ipywidget with the `value` parameter of a Panel widget. + +```{pyodide} +import panel as pn +import ipyleaflet as ipyl + +pn.extension("ipywidgets") + +leaflet_map = ipyl.Map() + +zoom_widget = pn.widgets.FloatSlider(value=4.0, start=1.0, end=24.0, name="Zoom") +zoom_control_widget = pn.widgets.Checkbox(name="Show Zoom Control") + +pn.dataclass.sync_with_widget(leaflet_map, zoom_widget, name="zoom") +pn.dataclass.sync_with_widget(leaflet_map, zoom_control_widget, name="zoom_control") +pn.Column(leaflet_map, zoom_widget, zoom_control_widget).servable() +``` + +## Synchronize an ipywidget with a Parameterized Object + +Use `sync_with_parameterized` to synchronize an ipywidget with a Parameterized object. + +```{pyodide} +import panel as pn +import ipyleaflet as ipyl +import param + +pn.extension("ipywidgets") + +leaflet_map = ipyl.Map() + +class Map(param.Parameterized): + center = param.List(default=[52.204793, 360.121558]) + zoom = param.Number(default=4, bounds=(0, 24), step=1) + +parameterized = Map() + +pn.dataclass.sync_with_parameterized( + model=leaflet_map, parameterized=parameterized +) +pn.Column(leaflet_map, parameterized.param.zoom, parameterized.param.center).servable() +``` + +The `sync_with_parameterized` function synchronizes the shared traits/parameters `center` and `zoom` between the `leaflet_map` and `parameterized` objects. + +To specify a subset of traits/parameters to synchronize, use the `names` argument: + +```python +pn.dataclass.sync_with_parameterized( + model=leaflet_map, parameterized=parameterized, names=("center",) +) +``` + +The `names` argument can also be a dictionary mapping trait names to parameter names: + +```{pyodide} +import panel as pn +import ipyleaflet as ipyl +import param + +pn.extension("ipywidgets") + +leaflet_map = ipyl.Map() + +class Map(param.Parameterized): + zoom_level = param.Number(default=4, bounds=(0, 24), step=1) + +parameterized = Map() + +pn.dataclass.sync_with_parameterized( + model=leaflet_map, parameterized=parameterized, names={"zoom": "zoom_level"} +) +pn.Column(leaflet_map, parameterized.param.zoom_level).servable() +``` + +## Create a Viewer from an ipywidget Instance + +To create a `Viewer` object from a ipywidget instance, use the `ModelViewer` class: + +```{pyodide} +import panel as pn +import ipyleaflet as ipyl + +pn.extension("ipywidgets") + +leaflet_map = ipyl.Map(zoom=4) + +viewer = pn.dataclass.ModelViewer(model=leaflet_map, sizing_mode="stretch_both") +pn.Row(pn.Column(viewer.param, scroll=True), viewer, height=400).servable() +``` + +Check out the parameters to the left of the map, there you will find all the traits of the `leaflet_map` instance. Try changing some. + +To specify a subset of traits/parameters to synchronize, use the `names` argument: + +```python +viewer = pn.dataclass.ModelViewer( + model=leaflet_map, names=("center",), sizing_mode="stretch_both" +) +``` + +The `names` argument can also be a dictionary mapping trait names to parameter names. + +## Create a Viewer from an ipywidget Class + +To create a `Viewer` class from an ipywidget class, use the `ModelViewer` class: + +```{pyodide} +import panel as pn +import ipyleaflet as ipyl +import param + +pn.extension("ipywidgets") + +class MapViewer(pn.dataclass.ModelViewer): + _model_class = ipyl.Map + _model_names = ["center", "zoom"] + + zoom = param.Number(4, bounds=(0, 24), step=1) + +viewer = MapViewer(sizing_mode="stretch_both") + +pn.Row(pn.Column(viewer.param, scroll=True), viewer, height=400).servable() +``` + +The `_model_names` attribute is an optional iterable or dictionary. It specifies which traits to synchronize to which parameters. + +## Create Reactive Values from the Traits of an ipywidget + +Use `to_rx` to create reactive values from the traits of an ipywidget. + +```{pyodide} +import panel as pn +import ipyleaflet as ipyl + +pn.extension("ipywidgets") + +leaflet_map = ipyl.Map(zoom=4) +zoom, zoom_control = pn.dataclass.to_rx( + leaflet_map, "zoom", "zoom_control" +) + +pn.Column( + leaflet_map, + zoom.rx.pipe( + lambda x: f"**Value**: {x}, **Zoom Control**: " + zoom_control.rx.pipe(str) + ), +).servable() +``` + +## References + +- [IPyWidget Pane Reference](../../reference/panes/IPyWidget.ipynb) diff --git a/doc/how_to/dataclass/pydantic.md b/doc/how_to/dataclass/pydantic.md new file mode 100644 index 0000000000..28352f17ef --- /dev/null +++ b/doc/how_to/dataclass/pydantic.md @@ -0,0 +1,301 @@ +# Interact with Pydantic + +This how-to guide demonstrates how to easily enable interaction with [Pydantic](https://docs.pydantic.dev/latest/) using familiar APIs from Panel and [Param](https://param.holoviz.org/), such as `watch`, `bind`, `depends`, and `rx`. + +## Overview + +The `pn.dataclass` module provides functions to synchronize the fields of Pydantic `BaseModel` with a Parameterized object, a Panel widget, or an `rx` value. It also provides a `ModelViewer` class for creating a Layoutable Viewer that wraps an Pydantic `BaseModel` class or instance. + +### Terminology + +- **Pydantic**: A library for creating dataclass like models (`BaseModel`) with non-observable fields. +- **model**: Refers to Pydantic `BaseModel` classes and instances. +- **names**: Refers to the names of the fields/parameters to synchronize. Can be an iterable or a dictionary mapping from field names to parameter names. + +### Classes + +- **`ModelParameterized`**: An abstract Parameterized base class that can wrap a Pydantic `BaseModel` class or instance. +- **`ModelViewer`**: An abstract base class for creating a Layoutable Viewer that can wrap a Pydantic `BaseModel` class or instance. + +### Functions + +- **`to_rx`**: Can create `rx` values from fields of a model, synced to the fields of the model. +- **`sync_with_parameterized`**: Syncs the fields of a model with the parameters of a Parameterized object. +- **`sync_with_widget`**: Syncs a field of the model with the value of a Panel widget. +- **`sync_with_rx`**: Syncs a field of a model with an `rx` value. + +All synchronization is from Parameterized to Pydantic model. The other way is currently not supported. + +Only top-level fields/parameters are synchronized, not nested ones. + +## Synchronize a Field of a model with a Panel Widget + +Use `sync_with_widget` to synchronize a field of a model with the `value` parameter of a Panel widget. + +```{pyodide} +import panel as pn +from datetime import datetime +from pydantic import BaseModel +import json + +pn.extension() + + +class DeliveryModel(BaseModel): + timestamp: datetime = datetime(2021, 1, 1, 1, 1, 1) + +model = DeliveryModel() + +widget = pn.widgets.DatetimeInput(name="Timestamp") +pn.dataclass.sync_with_widget(model, widget, "timestamp") + +def view_model(*args): + return json.loads(model.json()) + +pn.Column( + widget, + pn.pane.JSON(pn.bind(view_model, widget)), +).servable() +``` + +## Synchronize a model with a Parameterized Object + +Use `sync_with_parameterized` to synchronize a model with a Parameterized object. + +```{pyodide} +import panel as pn +from datetime import datetime +from pydantic import BaseModel +import json +import param + +pn.extension() + + +class DeliveryModel(BaseModel): + timestamp: datetime = datetime(2021, 1, 1, 1, 1, 1) + dimensions: tuple[int, int] = (10, 10) + + +class DeliveryParameterized(param.Parameterized): + timestamp = param.Date(datetime(2021, 2, 2, 2, 2, 2)) + dimensions = param.Tuple((20, 20)) + + +model = DeliveryModel() +parameterized = DeliveryParameterized() + +pn.dataclass.sync_with_parameterized(model, parameterized) + + +def view_model(*args): + return json.loads(model.json()) + + +pn.Column( + parameterized.param, + pn.pane.JSON( + pn.bind( + view_model, parameterized.param.timestamp, parameterized.param.dimensions + ), + depth=2, + ), +).servable() +``` + +The `sync_with_parameterized` function synchronizes the shared fields/parameters `timestamp` and `dimensions` between the `model` and `parameterized` objects. + +To specify a subset of fields/parameters to synchronize, use the `names` argument: + +```python +pn.dataclass.sync_with_parameterized( + model=model, parameterized=parameterized, names=("timestamp",) +) +``` + +The `names` argument can also be a dictionary mapping field names to parameter names: + +```{pyodide} +import panel as pn +from datetime import datetime +from pydantic import BaseModel +import json +import param + +pn.extension() + +class DeliveryModel(BaseModel): + timestamp: datetime = datetime(2021, 1, 1, 1, 1, 1) + dimensions: tuple[int, int] = (10, 10) + +class DeliveryParameterized(param.Parameterized): + timestamp_value = param.Date(datetime(2021, 2, 2, 2, 2, 2)) + dimensions_value = param.Tuple((20, 20)) + +model = DeliveryModel() +parameterized = DeliveryParameterized() + +pn.dataclass.sync_with_parameterized( + model=model, + parameterized=parameterized, + names={"timestamp": "timestamp_value", "dimensions": "dimensions_value"}, +) + +def view_model(*args): + return json.loads(model.json()) + +pn.Column( + parameterized.param, + pn.pane.JSON( + pn.bind( + view_model, + parameterized.param.timestamp_value, + parameterized.param.dimensions_value, + ), + depth=2, + ), +).servable() +``` + +## Create a Viewer from a model Instance + +To create a `Viewer` object from a model instance, use the `ModelViewer` class: + +```{pyodide} +import panel as pn +from datetime import datetime +from pydantic import BaseModel + +pn.extension() + +class DeliveryModel(BaseModel): + timestamp: datetime = datetime(2021, 1, 1, 1, 1, 1) + dimensions: tuple[int, int] = (10, 10) + +model = DeliveryModel() + +viewer = pn.dataclass.ModelViewer(model=model, sizing_mode="stretch_both") +pn.Row(pn.Column(viewer.param, scroll=True), viewer, height=400).servable() +``` + +Check out the parameters to the left of the JSON pane, there you will find all the fields of the `DeliveryModel` instance. Try changing some the `dimensions`. + +To specify a subset of fields/parameters to synchronize, use the `names` argument: + +```python +viewer = pn.dataclass.ModelViewer( + model=model, names=("dimensions",), sizing_mode="stretch_both" +) +``` + +The `names` argument can also be a dictionary mapping field names to parameter names. + +## Create a Viewer from a model Class + +To create a `Viewer` class from a model class, use the `ModelViewer` class: + +```{pyodide} +import panel as pn +from datetime import datetime +from pydantic import BaseModel +import param + +pn.extension() + + +class DeliveryModel(BaseModel): + timestamp: datetime = datetime(2021, 1, 1, 1, 1, 1) + dimensions: tuple[int, int] = (10, 10) + +class DeliveryViewer(pn.dataclass.ModelViewer): + _model_class = DeliveryModel + _model_names = ["timestamp", "dimensions"] + + timestamp = param.Date(datetime(2021, 2, 2, 2, 2, 2)) + dimensions = param.Tuple((20, 20)) + +viewer = DeliveryViewer(dimensions=(30,30), sizing_mode="stretch_both") +pn.Row(pn.Column(viewer.param, scroll=True), viewer, height=400).servable() +``` + +The `_model_names` attribute is an optional iterable or dictionary. It specifies which fields to synchronize to which parameters. + +## Create a Reactive Value from the Field of a model + +Use `to_rx` to create a reactive value from the field of a model. + +```{pyodide} +import panel as pn +from datetime import datetime +from pydantic import BaseModel +import json + +pn.extension() + + +class DeliveryModel(BaseModel): + timestamp: datetime = datetime(2021, 1, 1, 1, 1, 1) + dimensions: tuple[int, int] = (10, 10) + +model = DeliveryModel() + +timestamp, dimensions = pn.dataclass.to_rx(model, "timestamp", "dimensions") + +timestamp_input = pn.widgets.DatetimeInput(name="Timestamp", value=model.timestamp) + +def update_timestamp(value): + timestamp.rx.value = value + + return json.loads(model.json()) + + +pn.Row( + pn.bind(update_timestamp, timestamp_input), + timestamp_input, timestamp, + height=400).servable() +``` + +## Create a Form from a Pydantic Model + +Use the `ModelForm` class to create a form to collect user input. + +```python +import panel as pn + +from pydantic import BaseModel + +pn.extension() + +class ExampleModel(BaseModel): + some_text: str + some_number: int + some_boolean: bool + +form = pn.dataclass.ModelForm( + model_class=ExampleModel, button_kwargs=dict(name="Run"), show_name=False, sort=True +) + +pn.Column(form, pn.pane.JSON(form.value_as_dict)).servable() +``` + +If you want to update the model automatically without having to click the button then hide the +button by setting `visible=False`. + +```python +import panel as pn + +from pydantic import BaseModel + +pn.extension() + +class ExampleModel(BaseModel): + some_text: str + some_number: int + some_boolean: bool + +form = pn.dataclass.ModelForm( + model_class=ExampleModel, button_kwargs=dict(visible=False), show_name=False, sort=True +) + +pn.Column(form, pn.pane.JSON(form.value_as_dict)).servable() +``` diff --git a/doc/how_to/index.md b/doc/how_to/index.md index b10256724a..5365687a4e 100644 --- a/doc/how_to/index.md +++ b/doc/how_to/index.md @@ -98,6 +98,13 @@ How to use Parameterized classes with Panel to generate UIs without writing GUI How to link the parameters of Panel components in Python and Javascript. ::: +:::{grid-item-card} {octicon}`paper-airplane;2.5em;sd-mr-1 sd-animate-grow50` Interact with ipywidgets and pydantic models +:link: dataclass/index +:link-type: doc + +How to interact with ipywidgets and pydantic models via familiar APIs like watch, bind, depends, and rx. +::: + :::: ## Manage session tasks diff --git a/doc/how_to/use_specialized_uis.md b/doc/how_to/use_specialized_uis.md index 025b4e2ecd..f32d229511 100644 --- a/doc/how_to/use_specialized_uis.md +++ b/doc/how_to/use_specialized_uis.md @@ -14,4 +14,6 @@ Build a sequential UI Build custom components Explicitly link parameters (Callbacks API) Generate UIs from declared parameters (Declarative API) +Interact with ipywidgets +Interact with Pydantic Models ``` diff --git a/examples/reference/panes/IPyWidget.ipynb b/examples/reference/panes/IPyWidget.ipynb index 346c332cb6..83ba277396 100644 --- a/examples/reference/panes/IPyWidget.ipynb +++ b/examples/reference/panes/IPyWidget.ipynb @@ -17,19 +17,35 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The ``IPyWidget`` pane renders any ipywidgets model both in the notebook and in a deployed server. This makes it possible to leverage this growing ecosystem directly from within Panel simply by wrapping the component in a Pane or Panel.\n", + "The `IPyWidget` pane renders most [ipywidgets](https://ipywidgets.readthedocs.io/en/latest/) (also known as *Jupyter widgets*) both in the notebook and in a deployed server. This allows leveraging the growing ecosystem directly within Panel by simply wrapping the component in the `IPyWidget` pane. For a list of `ipywidgets`, check out [best-of-jupyter](https://github.com/ml-tooling/best-of-jupyter#interactive-widgets--visualization).\n", "\n", - "In the notebook this is not necessary since Panel simply uses the regular notebook ipywidget renderer. Particularly in JupyterLab importing the ipywidgets extension in this way may interfere with the UI and render the JupyterLab UI unusable, so enable the extension with care.\n", + "Panel works especially well with `ipywidgets` built on top of [`AnyWidget`](https://anywidget.dev/en/getting-started/). See the [`AnyWidget` Community Page](https://anywidget.dev/en/community/) for a gallery of widgets you can use with Panel.\n", + "\n", + "#### Prerequisites\n", + "\n", + "To use `ipywidgets` with Panel in a server context, you must install the [`ipywidgets_bokeh`](https://github.com/bokeh/ipywidgets_bokeh) package:\n", + "\n", + "```bash\n", + "pip install ipywidgets_bokeh\n", + "```\n", + "\n", + "and import the `ipywidgets` extension:\n", + "\n", + "```python\n", + "pn.extension(\"ipywidgets\")\n", + "```\n", + "\n", + "In a notebook, this is not necessary since Panel uses the regular notebook ipywidget renderer.\n", "\n", "#### Parameters:\n", "\n", - "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "For details on other options for customizing the component, see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", - "* **``object``** (object): The ipywidget object being displayed\n", + "* **`object`** (object): The ipywidget object being displayed.\n", "\n", "##### Display\n", "\n", - "* **``default_layout``** (pn.layout.Panel, default=Row): Layout to wrap the plot and widgets in\n", + "* **`default_layout`** (pn.layout.Panel, default=Row): Layout to wrap the plot and widgets in.\n", "\n", "___" ] @@ -38,7 +54,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The `panel` function will automatically convert any ``ipywidgets`` object into a displayable panel, while keeping all of its interactive features:" + "The `IPyWidget` pane will automatically display the `ipywidget` object while keeping all its interactive features:" ] }, { @@ -53,6 +69,23 @@ "\n", "layout = ipw.HBox(children=[date, slider, play])\n", "\n", + "ipywidget_pane = pn.pane.IPyWidget(layout)\n", + "ipywidget_pane" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can use `pn.panel` as an alternative to `pn.pane.IPyWidget`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ "pn.panel(layout)" ] }, @@ -60,14 +93,102 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Interactivity and callbacks" + "## Updates\n", + "\n", + "You can update the `.object` of the `IPyWidget` pane:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipyleaflet as ipyl\n", + "\n", + "cities = {\n", + " \"London\": (51.5074, 0.1278),\n", + " \"Paris\": (48.8566, 2.3522),\n", + " \"New York\": (40.7128, -74.0060)\n", + "}\n", + "\n", + "city = pn.widgets.Select(name=\"City\", options=list(cities))\n", + "container = pn.pane.IPyWidget(width=500)\n", + "\n", + "def update_container(city, container=container):\n", + " container.object = ipyl.Map(zoom=4, center=cities[city])\n", + "city.rx.watch(update_container)\n", + "update_container(city.value)\n", + " \n", + "pn.Column(city, container)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Any ipywidget with a `value` parameter can also be used in a `pn.depends` decorated callback, e.g. here we declare a function that depends on the value of a `FloatSlider`:" + "## Efficient Updates\n", + "\n", + "In the previous section, you may have noticed the screen *flicker* when selecting another city in the dropdown. This happens because we create and display the map from scratch on user interactions.\n", + "\n", + "To avoid screen *flicker* and update more efficiently, **you should update the existing `ipywidget` *in-place* whenever possible**:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipyleaflet as ipyl\n", + "\n", + "cities = {\n", + " \"London\": (51.5074, 0.1278),\n", + " \"Paris\": (48.8566, 2.3522),\n", + " \"New York\": (40.7128, -74.0060)\n", + "}\n", + "\n", + "city = pn.widgets.Select(name=\"City\", options=list(cities))\n", + "leaflet_map = ipyl.Map(zoom=4, center=cities[city.value])\n", + "container = pn.pane.IPyWidget(leaflet_map, width=500)\n", + "\n", + "def update_container(city, leaflet_map=leaflet_map):\n", + " leaflet_map.center = cities[city]\n", + "city.rx.watch(update_container)\n", + " \n", + "pn.Column(city, container)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Try selecting another city and watch the map update with a nice, smooth transition." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Respond to User Input\n", + "\n", + "You may respond to user input using `bind`, `sync_with_widget`, event callbacks, the [traitlets observer pattern](https://traitlets.readthedocs.io/en/stable/using_traitlets.html#observe) and more.\n", + "\n", + "We highly recommend checking out the how-to guide [Interact with ipywidgets](../../how_to/dataclass/ipywidget.md) for much more functionality, inspiration and detail." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `pn.bind`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Any ipywidget with a `value` trait can be used with `pn.bind`. For example, here we declare a function that binds to the value of a `FloatSlider`:" ] }, { @@ -90,7 +211,104 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "If instead you want to write a callback yourself you can also use the `traitlets` `observe` method as you would usually. To read more about this see the [Widget Events](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Events.html) section of the ipywidgets documentation." + "### `sync_with_widget`\n", + "\n", + "You can synchronize an ipywidget with a Panel widget using the `sync_with_widget` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipyleaflet as ipyl\n", + "import panel as pn\n", + "\n", + "pn.extension(\"ipywidgets\")\n", + "\n", + "cities = {\n", + " \"London\": (51.5074, 0.1278),\n", + " \"Paris\": (48.8566, 2.3522),\n", + " \"New York\": (40.7128, -74.0060)\n", + "}\n", + "\n", + "city = pn.widgets.Select(name=\"City\", options=list(cities))\n", + "leaflet_map = ipyl.Map(zoom=4, center=cities[city.value])\n", + "\n", + "zoom_widget = pn.widgets.FloatSlider(value=2.0, start=1.0, end=24.0, name=\"Zoom\")\n", + "zoom_control_widget = pn.widgets.Checkbox(value=True, name=\"Show Zoom Control\")\n", + "\n", + "pn.dataclass.sync_with_widget(leaflet_map, zoom_widget, \"zoom\")\n", + "pn.dataclass.sync_with_widget(leaflet_map, zoom_control_widget, \"zoom_control\")\n", + "pn.Column(leaflet_map, zoom_widget, zoom_control_widget).servable()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This example only scratches the surface of what's available in the `panel.dataclass` namespace. **Check out out the how-to guide [Interact with ipywidgets](../../how_to/dataclass/ipywidget.md) for much more inspiration and detail**." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Event Callbacks\n", + "\n", + "Sometimes, you may want to capture user interaction that isn’t available through a widget trait. For example, `ipyleaflet.CircleMarker` has an `.on_click()` method that allows you to execute a callback when a marker is clicked. In this case, you may want to define a callback that handles the event." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipyleaflet as ipyl\n", + "\n", + "import panel as pn\n", + "\n", + "pn.extension(\"ipywidgets\")\n", + "\n", + "london = (51.5, 359.9)\n", + "\n", + "# Stores the number of clicks\n", + "n_clicks = pn.rx(0)\n", + "\n", + "\n", + "# A click callback that updates the reactive value\n", + "def on_click(**kwargs):\n", + " n_clicks.rx.value += 1\n", + "\n", + "\n", + "# Create the map, add the CircleMarker, and register the map with Shiny\n", + "marker = ipyl.CircleMarker(location=london)\n", + "marker.on_click(on_click)\n", + "map_ = ipyl.Map(center=london, zoom=7)\n", + "map_.add_layer(marker)\n", + "\n", + "clicks_text = pn.rx(\"**Number of clicks: {clicks}**\").format(clicks=n_clicks)\n", + "pn.Column(map_, pn.pane.Markdown(clicks_text))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Click the blue circle and observe how the text below the map updates.\n", + "\n", + "To read more about event callbacks, see the [Widget Events](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Events.html) section of the ipywidgets documentation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `traitlets` `observe` method\n", + "\n", + "If you are already familiar with the [traitlets observer pattern](https://traitlets.readthedocs.io/en/stable/using_traitlets.html#observe) you may also use this API." ] }, { @@ -116,14 +334,33 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### External widget libraries" + "## Sizing\n", + "\n", + "You might need to experiment with the the ipywidget `.layout.height` and `.layout.width` parameters and the `IPyWidget` pane `.height`, `width`, and `sizing_mode` parameters to get our use case correctly sized.\n", + "\n", + "Let's start with a simple example to illustrate the challenge: Nothing is visible in the first output cell below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipyleaflet as ipyl\n", + "import panel as pn\n", + "\n", + "pn.extension(\"ipywidgets\")\n", + "\n", + "map = ipyl.Map(zoom=4)\n", + "pn.pane.IPyWidget(map, height=200)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The `IPyWidget` panel is also not restricted to simple widgets, ipywidget libraries such as [`ipyvolume`](https://ipyvolume.readthedocs.io/en/latest/index.html) and [`ipyleaflet`](https://ipyleaflet.readthedocs.io/en/latest/) are also supported." + "Let's add a border to see what happens." ] }, { @@ -132,13 +369,17 @@ "metadata": {}, "outputs": [], "source": [ - "import ipyvolume as ipv\n", - "x, y, z, u, v = ipv.examples.klein_bottle(draw=False)\n", - "fig = ipv.figure()\n", - "m = ipv.plot_mesh(x, y, z, wireframe=False)\n", - "ipv.squarelim()\n", + "map = ipyl.Map(zoom=4)\n", + "pn.pane.IPyWidget(map, height=200, styles={\"border\": \"1px solid black\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that the width is close to zero.\n", "\n", - "pn.panel(fig)" + "We can solve this problem by setting the `ipywidgets` width and height to `\"100%\"` and then controlling the `height`, `width` and/ or `sizing_mode` of the `IPyWidget` pane." ] }, { @@ -147,18 +388,45 @@ "metadata": {}, "outputs": [], "source": [ - "from ipyleaflet import Map, VideoOverlay\n", - "\n", - "m = Map(center=(25, -115), zoom=4)\n", - "\n", - "video = VideoOverlay(\n", - " url=\"https://www.mapbox.com/bites/00188/patricia_nasa.webm\",\n", - " bounds=((13, -130), (32, -100))\n", - ")\n", + "map = ipyl.Map(zoom=4)\n", + "map.layout.width = map.layout.height = \"100%\"\n", + "pn.pane.IPyWidget(map, height=200, width=200, styles={\"border\": \"1px solid black\"})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "map = ipyl.Map(zoom=4)\n", + "map.layout.width = map.layout.height = \"100%\"\n", + "pn.pane.IPyWidget(map, height=300, sizing_mode=\"stretch_width\", styles={\"border\": \"1px solid black\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example: LonBoard" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For a larger example, check out how Panel works with [`lonboard`](https://developmentseed.org/lonboard/latest/) [here](https://developmentseed.org/lonboard/latest/ecosystem/panel/).\n", "\n", - "m.add(video);\n", + "[![Panel lonboard example](https://assets.holoviz.org/panel/examples/panel-lonboard-application.gif)](https://developmentseed.org/lonboard/latest/ecosystem/panel/)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## More Examples\n", "\n", - "pn.panel(m)" + "You can find specific examples by searching our [Discourse](https://discourse.holoviz.org/) site. Remember to share your examples too. Thanks." ] }, { @@ -167,7 +435,18 @@ "source": [ "## Limitations\n", "\n", - "The ipywidgets support has some limitations because it is integrating two very distinct ecosystems. In particular it is not yet possible to set up JS-linking between a Panel and an ipywidget object or support for embedding. These limitations are not fundamental technical limitations and may be solved in future." + "The ipywidgets support has some limitations because it integrates two very distinct ecosystems. In particular, it is not yet possible to set up JS-linking between a Panel and an ipywidget object or support embedding. These limitations are not fundamental technical limitations and may be solved in the future." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## For Developers\n", + "\n", + "If you want to integrate your ipywidget better with Panel, we highly recommend using the [`AnyWidget`](https://anywidget.dev/) framework to develop that ipywidget.\n", + "\n", + "If you want to convert your `ipywidget` to a Panel native widget, you can do so with Panel's [`AnyWidgetComponent`](../custom_components/AnyWidgetComponent.ipynb), [`JSComponent`](../custom_components/JSComponent.ipynb), or [`ReactComponent`](../custom_components/ReactComponent.ipynb)." ] } ], diff --git a/panel/__init__.py b/panel/__init__.py index 0d9006e8c8..503800ae81 100644 --- a/panel/__init__.py +++ b/panel/__init__.py @@ -47,6 +47,8 @@ """ from param import rx +from . import \ + dataclass # noqa # Todo: Figure out how to move up without problems from . import layout # noqa from . import links # noqa from . import pane # noqa diff --git a/panel/_dataclasses/__init__.py b/panel/_dataclasses/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/panel/_dataclasses/base.py b/panel/_dataclasses/base.py new file mode 100644 index 0000000000..e143a716fd --- /dev/null +++ b/panel/_dataclasses/base.py @@ -0,0 +1,245 @@ +""" +Shared base classes and utilities for working with dataclasses like +models including ipywidgets and Pydantic. +""" +from inspect import isclass +from typing import Any, Iterable + +import param + +from param import Parameterized + + +def _to_tuple( + bases: None | Parameterized | Iterable[Parameterized], +) -> tuple[Parameterized]: + if not bases: + bases = () + if isclass(bases) and issubclass(bases, Parameterized): + bases = (bases,) + return tuple(bases) + + +class ModelUtils: + """An abstract base class""" + can_observe_field: bool = False + + supports_constant_fields = True + + _model_cache: dict[Any, Parameterized] = {} + + @classmethod + def get_public_and_relevant_field_names(cls, model) -> tuple[str]: + return tuple( + name + for name in cls.get_field_names(model) + if cls.is_relevant_field_name(name) + ) + + @classmethod + def ensure_dict(cls, names: Iterable[str] | dict[str, str] = ()) -> dict[str, str]: + if isinstance(names, dict): + return names + return dict(zip(names, names)) + + @classmethod + def ensure_names_exists( + cls, model, parameterized, names: dict[str, str] + ) -> dict[str, str]: + return { + field: parameter + for field, parameter in names.items() + if field in cls.get_field_names(model) and parameter in parameterized.param + } + + @classmethod + def clean_names( + cls, model, parameterized, names: Iterable[str] | dict[str, str] + ) -> dict[str, str]: + if isinstance(names, str): + names=(names,) + if not names: + names = cls.get_public_and_relevant_field_names(model) + names = cls.ensure_dict(names) + return cls.ensure_names_exists(model, parameterized, names) + + @classmethod + def get_field_names(cls, model) -> Iterable[str]: + raise NotImplementedError() + + @classmethod + def is_relevant_field_name(cls, name: str): + if name.startswith("_"): + return False + return True + + @classmethod + def sync_from_field_to_parameter( + cls, + model, + field: str, + parameterized: Parameterized, + parameter: str, + ): + pass + + @classmethod + def observe_field( + cls, + model, + field: str, + handle_change, + ): + raise NotImplementedError() + + @classmethod + def create_parameterized( + cls, + model, + names, + bases, + ): + if not names: + names = cls.get_public_and_relevant_field_names(model) + names = cls.ensure_dict(names) + bases = _to_tuple(bases) + if not any(issubclass(base, Parameterized) for base in bases): + bases += (Parameterized,) + name = type(model).__name__ + key = (name, tuple(names.items()), bases) + if name in cls._model_cache: + parameterized = cls._model_cache[key] + else: + existing_params = () + for base in bases: + if issubclass(base, Parameterized): + existing_params += tuple(base.param) + params = { + name: cls.create_parameter(model, field) + for field, name in names.items() + if name not in existing_params + } + parameterized = param.parameterized_class(name, params=params, bases=bases) + parameterized._model__initialized = True + cls._model_cache[key] = parameterized + return parameterized + + @classmethod + def create_parameter( + cls, + model, + field, + ) -> param.Parameter: + return param.Parameter() + + @classmethod + def sync_with_parameterized( + cls, + model, + parameterized: Parameterized, + names: Iterable[str] | dict[str, str] = (), + ): + names = cls.clean_names(model, parameterized, names) + parameters = [] + mapping = {} + + for field, parameter in names.items(): + if parameter not in mapping: + mapping[parameter] = [] + mapping[parameter].append(field) + parameters.append(parameter) + + field_value = getattr(model, field) + parameter_value = getattr(parameterized, parameter) + + if parameter_value is not None and parameter != 'name': + try: + setattr(model, field, parameter_value) + except Exception: + with param.edit_constant(parameterized): + setattr(parameterized, parameter, field_value) + else: + with param.edit_constant(parameterized): + setattr(parameterized, parameter, field_value) + + def _handle_field_change( + _, + model=model, + field=field, + parameterized=parameterized, + parameter=parameter, + ): + with param.edit_constant(parameterized): + setattr(parameterized, parameter, getattr(model, field)) + cls.observe_field(model, field, _handle_field_change) + + def _handle_parameter_change(*events, read_only_fields: set[str] = set()): + for e in events: + fields = mapping[e.name] + for field in fields: + if field in read_only_fields: + continue + try: + setattr(model, field, e.new) + except Exception: + read_only_fields.add(field) + parameterized.param._watch(_handle_parameter_change, parameters, precedence=-1) + + @classmethod + def get_layout(cls, model, self, layout_params): + raise NotImplementedError() + + @classmethod + def adjust_sizing(cls, self): + pass + + @classmethod + def get_required_defaults(cls, model_class): + """Returns the default values of the fields that are required""" + raise NotImplementedError() + + @classmethod + def get_instance(cls, model_instance_or_class): + if not isinstance(model_instance_or_class, type): + return model_instance_or_class + + model_class = model_instance_or_class + default_values = cls.get_required_defaults(model_class) + + return model_class(**default_values) + + +class VariableLengthTuple(param.Parameter): + """ + A non-fixed length Tuple parameter + + See https://github.com/holoviz/param/issues/955 + """ + + def __init__(self, default=None, allow_None=True, **params): + super().__init__(default=default, allow_None=allow_None, **params) + self._validate(default) + + def _validate_value(self, val, allow_None): + if val is None and allow_None: + return + if not isinstance(val, tuple): + raise ValueError( + f'VariableLengthTuple parameter {self.name!r} only takes ' + f'tuple values, not values of not {type(val)!r}.' + ) + + def _validate(self, val): + self._validate_value(val, self.allow_None) + + @classmethod + def serialize(cls, value): + if value is None: + return 'null' + return list(value) if isinstance(value, tuple) else value + + @classmethod + def deserialize(cls, value): + if value == 'null': + return None + return tuple(value) if isinstance(value, list) else value diff --git a/panel/_dataclasses/ipywidget.py b/panel/_dataclasses/ipywidget.py new file mode 100644 index 0000000000..428c67c48e --- /dev/null +++ b/panel/_dataclasses/ipywidget.py @@ -0,0 +1,138 @@ +"""Functionality to enable easy interaction with Traitlets models and ipywidgets via familiar APIs from Param like watch, bind, depends, and rx. + +## Terminology + +- **Traitlets**: A library for creating classes (`HasTraits`) with observable attributes (called *traits*). +- **Param**: A library similar to Traitlets, for creating classes (`Parameterized`) with watchable attributes (called *parameters*). +- **ipywidgets**: Builds on Traitlets to create interactive widgets for Jupyter notebooks. +- **widget**: Refers to ipywidgets. +- **model**: Refers to Traitlets classes including ipywidgets. + +## Classes + +- `ModelParameterized`: An abstract Parameterized base class for wrapping a traitlets HasTraits class or instance. +- `ModelViewer`: An abstract base class for creating a Layoutable Viewer that wraps an ipywidget Widget class or instance. + +## Functions + +- `create_rx`: Creates `rx` values from traits of a model, each synced to a trait of the model. +- `sync_with_widget`: Syncs the named trait of the model with the value of a Panel widget. +- `sync_with_parameterized`: Syncs the traits of a model with the parameters of a Parameterized object. +- `sync_with_rx`: Syncs a single trait of a model with an `rx` value. + +All synchronization is bidirectional. We only synchronize the top-level traits/ parameters and not the nested ones. +""" + +# I've tried to implement this in a way that would generalize to similar APIs for other *dataclass like* libraries like dataclasses, Pydantic, attrs etc. + +from typing import TYPE_CHECKING, Any, Iterable + +import param + +from ..pane.ipywidget import IPyWidget +from ..util import classproperty +from .base import ModelUtils, VariableLengthTuple + +if TYPE_CHECKING: + try: + from traitlets import HasTraits + except ModuleNotFoundError: + HasTraits = Any + + try: + from ipywidgets import Widget + except ModuleNotFoundError: + Widget = Any +else: + HasTraits = Any + Widget = Any + + +class TraitletsUtils(ModelUtils): + + can_observe_field = True + + @classmethod + def get_field_names(cls, model: HasTraits) -> Iterable[str]: + try: + return model.traits() + except TypeError: + return (trait for trait in model._traits if not trait.startswith("_")) + + + @classmethod + def observe_field( + cls, + model, + field: str, + handle_change, + ): + # We don't know if this is possible + model.observe(handle_change, names=field) + + @classproperty + def parameter_map(cls): + import traitlets + return { + traitlets.Bool: param.Boolean, + traitlets.Bytes: param.Bytes, + traitlets.Callable: param.Callable, + traitlets.Dict: param.Dict, + traitlets.Enum: param.Selector, + traitlets.Float: param.Number, + traitlets.Int: param.Integer, + traitlets.List: param.List, + traitlets.Tuple: VariableLengthTuple, + traitlets.Unicode: param.String, + } + + @classmethod + def create_parameter( + cls, + model, + field, + ) -> param.Parameter: + trait_type = model.__class__ + trait = getattr(trait_type, field) + ptype = cls.parameter_map.get(type(trait), param.Parameter) + extras = {} + if ptype is param.Selector: + extras = {'objects': trait.values} + return ptype( + default=getattr(model, field), + allow_None=trait.allow_none, + constant=trait.read_only, + doc=trait.help, + **extras + ) + + @classmethod + def is_relevant_field_name(cls, name: str): + if name in {"comm", "tabbable", "keys", "log", "layout"}: + return False + return super().is_relevant_field_name(name) + + @classmethod + def get_layout(cls, model, self, layout_params): + if hasattr(model, "layout"): + model.layout.height = "100%" + model.layout.width = "100%" + + return IPyWidget(model, **layout_params) + + @classmethod + def adjust_sizing(cls, parameterized): + if not parameterized.width and parameterized.sizing_mode not in ["stretch_width", "stretch_both"]: + parameterized.width = 300 + + if not parameterized.height and parameterized.sizing_mode not in [ + "stretch_height", + "stretch_both", + ]: + parameterized.height = 300 + + + @classmethod + def get_required_defaults(cls, model_class): + """Returns the default values of the fields that are required""" + return {} diff --git a/panel/_dataclasses/pydantic.py b/panel/_dataclasses/pydantic.py new file mode 100644 index 0000000000..8c886121ec --- /dev/null +++ b/panel/_dataclasses/pydantic.py @@ -0,0 +1,110 @@ +import datetime as dt +import json + +from collections.abc import Callable +from typing import ( + TYPE_CHECKING, Any, Iterable, Literal, +) + +import param + +from param.reactive import bind + +from ..pane.markup import JSON +from ..util import classproperty +from .base import ModelUtils, VariableLengthTuple + +if TYPE_CHECKING: + try: + from pydantic import BaseModel + except ModuleNotFoundError: + BaseModel = Any +else: + BaseModel = Any + +def _default_serializer(obj): + if isinstance(obj, (dt.datetime, dt.date)): + return obj.isoformat() + if isinstance(obj, bytes): + return obj.decode(encoding='utf-8') + if isinstance(obj, Callable): + return str(obj) + raise TypeError(f"Cannot serialize {obj!r} (type {type(obj)})") + + +class PydanticUtils(ModelUtils): + + can_observe_field = False + + supports_constant_fields = False + + @classmethod + def get_field_names(cls, model: BaseModel) -> Iterable[str]: + return tuple(model.model_fields) + + @classmethod + def observe_field( + cls, + model, + field: str, + handle_change, + ): + # We don't know if this is possible + # Maybe solution can be found in + # https://github.com/pydantic/pydantic/discussions/7127 or + # https://psygnal.readthedocs.io/en/latest/API/model/ + pass + + @classproperty + def parameter_map(cls): + return { + bool: param.Boolean, + bytes: param.Bytes, + Callable: param.Callable, + dict: param.Dict, + Literal: param.Selector, + float: param.Number, + int: param.Integer, + list: param.List, + str: param.String, + tuple: VariableLengthTuple, + } + + @classmethod + def create_parameter(cls, model, field: str)->param.Parameter: + field_info = model.model_fields[field] + field_type = field_info.annotation + kwargs={"doc": field_info.description} + + if hasattr(field_type, '__origin__'): + ptype = cls.parameter_map.get(field_type.__origin__, param.Parameter) + if ptype is param.Selector and hasattr(field_type, '__args__'): + kwargs["objects"] = list(field_type.__args__) + else: + ptype = cls.parameter_map.get(field_type, param.Parameter) + + if hasattr(model, field): + kwargs['default'] = getattr(model, field) + + return ptype( + **kwargs + ) + + @classmethod + def get_layout(cls, model, self, layout_params): + def view_model(*args): + if hasattr(model, 'model_dump'): + return model.model_dump() + else: + return json.loads(model.json()) + + return JSON( + bind(view_model, *self.param.objects().values()), + default=_default_serializer, + depth=2, + **layout_params + ) + + @classmethod + def get_required_defaults(cls, model_class): + return {field: cls.create_parameter(model_class, field).default for field, info in model_class.model_fields.items() if info.is_required()} diff --git a/panel/dataclass.py b/panel/dataclass.py new file mode 100644 index 0000000000..688ebd8b3b --- /dev/null +++ b/panel/dataclass.py @@ -0,0 +1,507 @@ +""" +Functionality to enable easy interaction with dataclass like instances +or classes via familiar APIs from Param like watch, bind, depends, and +rx. + +Libraries Supported +------------------- + +- **ipywidgets**: A library for creating interactive widgets for notebooks. Its base class `Widget` derives from traitlets `HasTraitlets`. +- **Pydantic**: A library for creating dataclass like models with very fast validation and serialization. +- **Traitlets**: A library for creating dataclass like models (`HasTraits`) with observable fields (called *traits*). + +In the future dataclasses, attrs or other dataclass like libraries may be supported. + +Terminology +------------ + +- **Param**: A library for creating dataclass like models (`Parameterized`) with watchable attributes (called *parameters*). +- **model**: A subclass of one of the libraries listed above. +- **fields**: The attributes of the model class or instance. Derives from `dataclass.field()`. +- **names**: The names of the model attributes/ Parameterized parameters. + +Classes +------- + +- `ModelParameterized`: An abstract Parameterized base class for observing a model class or instance. +- `ModelViewer`: An abstract Layoutable Viewer base class for observing a model class or instance. + +Functions +--------- + +- `to_rx`: Creates `rx` values from fields of a model. +- `to_parameterized`: Creates a Parameterized instance that mirrors the fields on a model. +- `to_viewer`: Creates a Viewer instance that mirrors the fields on a model and displays the object. +- `sync_with_parameterized`: Syncs the fields of a model with the parameters of a Parameterized object. +- `sync_with_widget`: Syncs an iterable or dictionary of named fields of a model with the named parameters of a Parameterized object. +- `sync_with_rx`: Syncs a single field of a model with an `rx` value. + +Support +------- + +| Library | Sync Parameterized -> Model | Sync Model -> Parameterized | `to_rx` | `sync_with_parameterized` | `sync_with_widget` | `sync_with_rx` | `ModelParameterized` | `ModelViewer` | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| ipywidgets | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Pydantic | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Traitlets | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | + +""" # noqa: E501 + +from typing import Iterable + +import param + +from param import Parameterized + +from ._dataclasses.base import ModelUtils, _to_tuple +from ._dataclasses.ipywidget import HasTraits, TraitletsUtils +from ._dataclasses.pydantic import BaseModel, PydanticUtils +from .layout import Column +from .param import Param +from .viewable import Layoutable, Viewer +from .widgets import Button, Widget + +DataClassLike = HasTraits | BaseModel + + +def _get_utils(model: DataClassLike) -> type[ModelUtils]: + if hasattr(model, "model_fields"): + return PydanticUtils + return TraitletsUtils + + +class ModelParameterized(Parameterized): + """ + An abstract Parameterized base class for wrapping a dataclass like class or instance. + """ + + model: DataClassLike = param.Parameter(allow_None=False, constant=True) + + _model_names: Iterable[str] | dict[str, str] = () + + _model__initialized = False + + def __new__(cls, **params): + if cls._model__initialized: + return super().__new__(cls) + if "model" not in params and hasattr(cls, "_model_class"): + model = cls._model_class() + else: + model = params["model"] + names = params.pop("names", cls._model_names) + utils = _get_utils(model) + parameterized = utils.create_parameterized(model, names=names, bases=(cls,)) + return super().__new__(parameterized) + + def __init__(self, **params): + if "model" not in params and hasattr(self, "_model_class"): + params["model"] = self._model_class() + + model = params["model"] + names = params.pop("names", self._model_names) + utils = _get_utils(model) + if not names: + names = utils.get_public_and_relevant_field_names(model) + names = utils.ensure_dict(names) + self._model_names = names + super().__init__(**params) + utils.sync_with_parameterized(self.model, self, names=names) + + +class ModelViewer(Layoutable, Viewer, ModelParameterized): + """ + An abstract base class for creating a Layoutable Viewer that wraps + a dataclass model such as an IPyWidget or Pydantic Model. + + Examples + -------- + + To wrap an ipywidgets instance: + + ```python + import panel as pn + import ipyleaflet as ipyl + + pn.extension("ipywidgets") + + leaflet_map = ipyl.Map() + + viewer = pn.dataclass.ModelViewer( + model=leaflet_map, sizing_mode="stretch_both" + ) + pn.Row(pn.Column(viewer.param, scroll=True), viewer, height=400).servable() + ``` + + To wrap an ipywidgets class: + + ```python + import panel as pn + import ipyleaflet as ipyl + import param + + pn.extension("ipywidgets") + + class MapViewer(pn.dataclass.ModelViewer): + _model_class = ipyl.Map + _model_names = ["center", "zoom"] + + zoom = param.Number(4, bounds=(0, 24), step=1) + + viewer = MapViewer(sizing_mode="stretch_both") + + pn.Row(pn.Column(viewer.param, scroll=True), viewer, height=400).servable() + ``` + + The `_model_names` is optional and can be used to specify which fields/ parameters to synchronize. + """ + + model: DataClassLike = param.Parameter(allow_None=False, constant=True) + + def __init__(self, **params): + super().__init__(**params) + + layout_params = {name: self.param[name] for name in Layoutable.param} + model = self.model + utils = _get_utils(self.model) + + self._layout = utils.get_layout(model, self, layout_params) + utils.adjust_sizing(self) + + def __panel__(self): + return self._layout + + +def sync_with_parameterized( + model: DataClassLike, + parameterized: Parameterized, + names: Iterable[str] | dict[str, str] = (), +): + """ + Syncs the fields of the model with the parameters of the Parameterized object. + + Arguments + --------- + model: DataClassLike + The model to synchronize. + parameterized: param.Parameterized + The Parameterized object to synchronize. + names: Iterable[str] | dict[str, str] + The names of the fields/parameters to synchronize. If none are + specified, all public and relevant fields of the model will be + synced. If a dict is specified it maps from field names to + parameter names. + + Example + ------- + + ```python + import panel as pn + import ipyleaflet as ipyl + import param + + pn.extension("ipywidgets") + + leaflet_map = ipyl.Map() + + class Map(param.Parameterized): + center = param.List(default=[52.204793, 360.121558]) + zoom = param.Number(default=4, bounds=(0, 24), step=1) + + parameterized = Map() + + pn.dataclass.sync_with_parameterized( + model=leaflet_map, parameterized=parameterized + ) + pn.Column(leaflet_map, parameterized.param.zoom, parameterized.param.center).servable() + ``` + + """ + _get_utils(model).sync_with_parameterized(model, parameterized, names) + + +def sync_with_widget( + model: DataClassLike, + widget: Widget, + name: str = "value", +): + """ + Syncs the named field of the model with value parameter of the widget. + + Argsuments + ----------- + model: DataClassLike + The model to synchronize. + widget: panel.widgets.Widget + The Panel widget to synchronize. + name: str + The name of the field to synchronize. Default is 'value'. + + Example + ------- + + ```python + import panel as pn + import ipyleaflet as ipyl + + pn.extension("ipywidgets") + + leaflet_map = ipyl.Map() + + zoom_widget = pn.widgets.FloatSlider(value=2.0, start=1.0, end=24.0, name="Zoom") + zoom_control_widget = pn.widgets.Checkbox(name="Show Zoom Control") + + pn.dataclass.sync_with_widget(leaflet_map, zoom_widget, name="zoom") + pn.dataclass.sync_with_widget(leaflet_map, zoom_control_widget, name="zoom_control") + pn.Column(leaflet_map, zoom_widget, zoom_control_widget).servable() + ``` + """ + sync_with_parameterized(model, widget, names={name: "value"}) + + +def to_parameterized( + model: DataClassLike, + names: Iterable[str] | dict[str, str] = (), + bases: Iterable[Parameterized] | Parameterized | None = None, + **kwargs, +) -> Parameterized: + """ + Returns a Parameterized instance from a model with names synced + to the model's fields. + + Arguments + --------- + model: DataClassLike + The model to create the Viewer from. + names: Iterable[str] | dict[str, str] + The names of the parameters to add to the Viewer and to sync. + If no names are specified, all public and relevant fields on the model will be added and synced. + bases: tuple[type] + Additional base classes to add to the base `ModelViewer` class. + kwargs: dict[str, Any] + Additional keyword arguments to pass to the Parameterized constructor. + + Returns + ------- + The Parameterized instance created from the supplied model. + """ + bases = _to_tuple(bases) + if not any(issubclass(base, ModelParameterized) for base in bases): + bases += (ModelParameterized,) + parameterized = _get_utils(model).create_parameterized( + model=model, names=names, bases=bases + ) + instance = parameterized(model=model, names=names, **kwargs) + + # Hack: For some unknown reason the instance parameters are not set correctly + for name in parameterized.param: + instance.param[name].constant = parameterized.param[name].constant + return instance + + +def to_viewer( + model: DataClassLike, + names: Iterable[str] | dict[str, str] = (), + bases: Iterable[Parameterized] | Parameterized | None = None, + **kwargs, +) -> ModelViewer: + """ + Returns a Viewer object from a model with parameters synced + bidirectionally to the model's fields and displaying the model + when rendered. + + Arguments + --------- + model: DataClassLike + The model to create the Viewer from. + names: Iterable[str] | dict[str, str] + The names of the parameters to add to the Viewer and to sync bidirectionally. + If no names are specified, all public and relevant fields on the model will be added and synced. + bases: tuple[type] + Additional base classes to add to the base `ModelViewer` class. + kwargs: dict[str, Any] + Additional keyword arguments to pass to the Parameterized constructor. + + Returns + ------- + A ModelViewer instance wrapping the supplied model. + """ + bases = _to_tuple(bases) + (ModelViewer,) + return to_parameterized(model, names, bases=bases, **kwargs) + + +def sync_with_rx(model: HasTraits, name: str, rx: param.rx): + """ + Syncs a single field of a model with an `rx` value. + + Arguments + --------- + model: HasTraits | pydantic.Model + The model to sync with. + name: str + The name of the field to sync the `rx` value with. + rx: param.rx + The `rx` value to sync with the field. + """ + rx.rx.value = getattr(model, name) + + def set_value(event, rx=rx): + rx.rx.value = event["new"] + + _get_utils(model).observe_field(model, name, set_value) + + def set_name(value, element=model, name=name): + setattr(element, name, value) + + rx.rx.watch(set_name) + + +def to_rx(model: HasTraits, *names) -> param.rx | tuple[param.rx]: + """ + Returns `rx` values from fields of a model, each synced to a field of the model bidirectionally. + + Arguments + --------- + model: HasTraits | pydantic.BaseModel + The model to create the `rx` parameters from. + names: Iterable[str] + The fields to create `rx` values from. + If a single parameter is specified, a single reactive parameter is returned. + If no names are specified, all public and relevant fields of the model will be used. + + Returns + ------- + One or more `rx` objects corresponding to the fields of the object. + + Example + ------- + + ```python + import panel as pn + import ipyleaflet as ipyl + + pn.extension("ipywidgets") + + leaflet_map = ipyl.Map(zoom=4) + zoom, zoom_control = pn.dataclass.to_rx( + leaflet_map, "zoom", "zoom_control" + ) + + pn.Column( + leaflet_map, + pn.rx("**Value**: {zoom}, **Zoom Control**: {zoom_control}").format( + zoom=zoom, zoom_control=zoom_control + ), + ).servable() + ``` + """ + rx_values = [] + for name in names: + rx = param.rx() + sync_with_rx(model, name, rx) + rx_values.append(rx) + if len(rx_values) == 1: + return rx_values[0] + return tuple(rx_values) + + +class ModelForm(Viewer): + """The ModelForm creates a form for a dataclass like model. + + The form is a Columnar layout consisting of a Param pane and a submit button. + + When you click the submit button the value of the form is updated to the current value of the model. + + If you set `visible=False` on the submit button, the value will be updated whenever a parameter changes. + + Args: + model_class: The class to create the form from. + button_kwargs: An optional dictionary of keyword arguments to pass to the Button constructor. + param_kwargs: Optional keyword arguments to pass to the Param constructor. + + Example: + + ```python + class ExampleModel(BaseModel): + some_text: str + some_number: int + some_boolean: bool + + form = ModelForm(model_class=ExampleModel, button_kwargs=dict(name="Run"), show_name=False, sort=True) + ``` + """ + + value = param.Parameter(allow_None=True) + + def __init__(self, model_class, button_kwargs: dict | None = None, **param_kwargs): + super().__init__() + + utils = self._utils = _get_utils(model_class) + + self._model_class = model_class + + model = self._model = utils.get_instance(model_class) + + default_parameters = [ + field + for field in utils.get_field_names(model_class) + if utils.is_relevant_field_name(field) + ] + parameters = param_kwargs["parameters"] = param_kwargs.get( + "parameters", default_parameters + ) + parameterized = self._parameterized = to_parameterized(model) + parameterized.param.watch(self._update_value_on_parameter_change, parameters) + + submit_button_kwargs = { + "name": "Submit", + "button_type": "primary", + "visible": True, + "on_click": self._update_value, + } + submit_button_kwargs.update(button_kwargs) + + self._submit_button = Button(**submit_button_kwargs) + self._param_pane = Param(parameterized, **param_kwargs) + + self._layout = Column( + self._param_pane, + self._submit_button, + ) + + def _update_value(self, *args): + values = { + field: value + for field, value in self._parameterized.param.values().items() + if ( + field in self._param_pane.parameters + and not self._parameterized.param[field].constant + ) + } + self.value = self._model_class(**values) + + def _update_value_on_parameter_change(self, *args): + if not self._submit_button.visible: + self._update_value() + + def __panel__(self): + return self._layout + + @param.depends("value") + def value_as_dict(self) -> dict: + "Returns the value as a dictionary. Can be used to easily display the value in a JSON pane." + if not self.value: + return {} + return self.value.dict() + + +__all__ = [ + "ModelParameterized", + "ModelViewer", + "ModelForm", + "sync_with_parameterized", + "sync_with_rx", + "sync_with_widget", + "to_parameterized", + "to_rx", + "to_viewer", +] diff --git a/panel/pane/markup.py b/panel/pane/markup.py index c98cd5d693..4a80ef2c0f 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -458,7 +458,6 @@ def _process_param_change(self, params): return super()._process_param_change(params) - class JSON(HTMLBasePane): """ The `JSON` pane allows rendering arbitrary JSON strings, dicts and other @@ -474,6 +473,10 @@ class JSON(HTMLBasePane): depth = param.Integer(default=1, bounds=(-1, None), doc=""" Depth to which the JSON tree will be expanded on initialization.""") + default = param.Callable(default=None, doc=""" + Default serialization fallback function that should return a + serializable version of an object or raise a TypeError.""") + encoder = param.ClassSelector(class_=json.JSONEncoder, is_instance=False, doc=""" Custom JSONEncoder class used to serialize objects to JSON string.""") @@ -490,7 +493,7 @@ class JSON(HTMLBasePane): _bokeh_model: ClassVar[Model] = _BkJSON _rename: ClassVar[Mapping[str, str | None]] = { - "object": "text", "encoder": None, "style": "styles" + "object": "text", "encoder": None, "style": "styles", "default": None } _rerender_params: ClassVar[list[str]] = [ @@ -505,7 +508,11 @@ class JSON(HTMLBasePane): def applies(cls, obj: Any, **params) -> float | bool | None: if isinstance(obj, (list, dict)): try: - json.dumps(obj, cls=params.get('encoder', cls.encoder)) + json.dumps( + obj, + cls=params.get('encoder', cls.encoder), + default=params.get('default', cls.default) + ) except Exception: return False else: @@ -520,7 +527,11 @@ def _transform_object(self, obj: Any) -> dict[str, Any]: data = json.loads(obj) except Exception: data = obj - text = json.dumps(data or {}, cls=self.encoder) + text = json.dumps( + data, + cls=self.encoder, + default=self.default + ) return dict(object=text) def _process_property_change(self, properties: dict[str, Any]) -> dict[str, Any]: diff --git a/panel/tests/dataclasses/__init__.py b/panel/tests/dataclasses/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/panel/tests/dataclasses/test_dataclass.py b/panel/tests/dataclasses/test_dataclass.py new file mode 100644 index 0000000000..6027109c22 --- /dev/null +++ b/panel/tests/dataclasses/test_dataclass.py @@ -0,0 +1,591 @@ +""" +Tests for utilities that allow us to wrap dataclass like class and +instances via familiar APIs like watch, bind, depends and rx." +""" +from typing import Callable, Literal + +import param +import pytest + +import panel as pn + +from panel._dataclasses.base import VariableLengthTuple +from panel._dataclasses.pydantic import PydanticUtils +from panel.dataclass import ( + ModelForm, ModelParameterized, ModelViewer, _get_utils, + sync_with_parameterized, to_parameterized, to_rx, to_viewer, +) +from panel.viewable import Layoutable, Viewer + +PydanticUtils._is_testing =True + +def get_traitlets_example_model(): + import traitlets + + from ipywidgets import DOMWidget, register + + @register + class TraitletsExampleModel(DOMWidget): + _view_name = traitlets.Unicode("ExampleIpyWidgetView").tag(sync=True) + _view_module = traitlets.Unicode("example_ipywidget").tag(sync=True) + _view_module_version = traitlets.Unicode("0.1.0").tag(sync=True) + + name = traitlets.Unicode("Default Name").tag(description="A string trait") + age = traitlets.Int(0).tag(description="An integer trait") + weight = traitlets.Float(0.0).tag(description="A float trait") + read_only = traitlets.Unicode("A Value", read_only=True) + bool_field = traitlets.Bool(False) + bytes_field = traitlets.Bytes(b"abc") + callable_field = traitlets.Callable(str) + dict_field = traitlets.Dict({"a": 1, "b": 2}) + literal_field = traitlets.Enum(["a", "b", "c"], default_value="a") + list_field = traitlets.List([1, 2, 3]) + tuple_field = traitlets.Tuple((1, 2, 3)) + + return TraitletsExampleModel + + +def get_pydantic_example_model(): + from pydantic import BaseModel + + + + class PydanticExampleModel(BaseModel): + name: str = "Default Name" + age: int = 0 + weight: float = 0.0 + read_only: str = "A Value" # Cannot make constant + bool_field: bool = False + bytes_field: bytes = b"abc" + callable_field: Callable = str + dict_field: dict = {"a": 1, "b": 2} + literal_field: Literal['a','b','c'] = 'a' + list_field: list = [1, 2, 3] + tuple_field: tuple = (1, 2, 3) + + + return PydanticExampleModel + + + +EXAMPLE_MODELS = [ ] +for get_example_model in [get_traitlets_example_model, get_pydantic_example_model]: + try: + EXAMPLE_MODELS.append(get_example_model()) + except ImportError: + pass + + +@pytest.fixture(params=EXAMPLE_MODELS) +def model_class(request): + return request.param + +@pytest.fixture() +def model(model_class): + return model_class(name="A", age=1, weight=1.1) + +@pytest.fixture() +def utils(model): + return _get_utils(model) + +@pytest.fixture +def can_observe_field(utils): + return utils.can_observe_field + +@pytest.fixture +def supports_constant_fields(utils): + return utils.supports_constant_fields + +def _assert_is_synced(parameterized, model, can_observe_field): + if can_observe_field: + # sync: model -> parameterized + model.name = "B" + model.age = 2 + model.weight = 2.2 + assert parameterized.name == model.name + assert parameterized.age == model.age + assert parameterized.weight == model.weight + + # parameterized synced to model + with param.edit_constant(parameterized): + parameterized.name = "C" + parameterized.age = 3 + parameterized.weight = 3.3 + assert parameterized.name == model.name + assert parameterized.age == model.age + assert parameterized.weight == model.weight + +def test_sync_with_parameterized(model, can_observe_field): + class ExampleParameterized(param.Parameterized): + name = param.String(default="Default Name") + age = param.Integer(default=0) + weight = param.Number(default=0.0) + + parameterized = ExampleParameterized() + sync_with_parameterized(model, parameterized) + + _assert_is_synced(parameterized, model, can_observe_field) + +def test_to_parameterized(model): + viewer = to_parameterized(model) + + assert {"name", "age", "weight"} <= set(viewer.param) + + assert viewer.name == model.name + assert viewer.age == model.age + assert viewer.weight == model.weight + +@pytest.mark.xfail(reason="For now instantiating Pydantic Model without required values is not supported") +def test_to_parameterized_no_defaults(): + from pydantic import BaseModel + class ExampleModel(BaseModel): + some_text: str + some_number: int + + class ExampleModelParameterized(ModelParameterized): + _model_class = ExampleModel + + ExampleModelParameterized() + +def test_to_parameterized_readonly(model, supports_constant_fields): + if not supports_constant_fields: + pytest.skip(f"Constant fields not supported for {type(model)}") + + parameterized = to_parameterized(model, names=("read_only",)) + assert parameterized.param.read_only.constant + with param.edit_constant(parameterized): + parameterized.read_only = "Some other value" + assert model.read_only != "Some other value" + + +def test_to_parameterized_is_synced(model, can_observe_field): + viewer = to_parameterized(model) + + assert viewer.name == model.name == "A" + assert viewer.age == model.age == 1 + assert viewer.weight == model.weight == 1.1 + + _assert_is_synced(viewer, model, can_observe_field) + + +def test_to_parameterized_names_tuple(model, can_observe_field): + parameterized = to_parameterized(model, names=("name", "age")) + + assert {"name", "age"} <= set(parameterized.param) + assert "weight" not in set(parameterized.param) + + assert parameterized.name == model.name + assert parameterized.age == model.age + + if can_observe_field: + # model synced to parameterized + model.name = "B" + model.age = 2 + assert parameterized.name == model.name + assert parameterized.age == model.age + + # parameterized synced to model + with param.edit_constant(parameterized): + parameterized.name = "C" + parameterized.age = 3 + assert parameterized.name == model.name + assert parameterized.age == model.age + + +def test_to_parameterized_bases(model, can_observe_field): + class ExampleParameterized(param.Parameterized): + name = param.String("default", doc="A string parameter") + age = param.Integer(0, bounds=(0, 10)) + not_trait = param.Parameter(1) + + parameterized = to_parameterized(model, bases=ExampleParameterized) + assert isinstance(parameterized, ExampleParameterized) + assert {"name", "age", "weight", "not_trait", "read_only"} <= set(parameterized.param) + + assert parameterized.name == model.name == "A" + assert parameterized.age == model.age == ExampleParameterized.param.age.default + + if can_observe_field: + # sync: model -> parameterized + model.name = "B" + model.age = 2 + assert parameterized.name == model.name + assert parameterized.age == model.age + + # sync: parameterized -> model + with param.edit_constant(parameterized): + parameterized.name = "C" + parameterized.age = 3 + parameterized.weight = 3.3 + assert parameterized.name == model.name + assert parameterized.age == model.age + + +def test_to_parameterized_names_dict(model, can_observe_field): + parameterized = to_parameterized(model, names={"name": "xname", "age": "xage"}) + + assert {"xname", "xage"} <= set(parameterized.param) + assert "weight" not in set(parameterized.param) + + assert parameterized.xname == model.name + assert parameterized.xage == model.age + + if can_observe_field: + # sync: model -> parameterized + model.name = "B" + model.age = 2 + assert parameterized.xname == model.name + assert parameterized.xage == model.age + + # sync: parameterized -> model + parameterized.xname = "C" + parameterized.xage = 3 + assert parameterized.xname == model.name + assert parameterized.xage == model.age + + +def test_to_viewer(model): + viewer = to_viewer(model) + + assert isinstance(viewer, Layoutable) + assert isinstance(viewer, Viewer) + + component = viewer.__panel__() + assert component + # Make sure we can view the component + pn.panel(component) + + # Todo: Move this part to test_ipywidget + viewer.sizing_mode = "stretch_width" + assert viewer.sizing_mode == component.sizing_mode + + +def test_to_viewer_names_and_kwargs(model): + viewer = to_viewer(model, names=("name", "age"), sizing_mode="stretch_width") + assert viewer.__panel__().sizing_mode == "stretch_width" + + +def test_to_viewer_bases(model, can_observe_field): + + class ExampleParameterized(param.Parameterized): + name = param.String("default", doc="A string parameter") + age = param.Integer(0, bounds=(0, 10)) + not_trait = param.Parameter(1) + + viewer = to_viewer(model, bases=ExampleParameterized) + + assert viewer.name == model.name == "A" + assert viewer.age == model.age == 0 + assert viewer.weight == model.weight == 1.1 + + _assert_is_synced(viewer, model, can_observe_field) + + +def test_to_rx_all_public_and_relevant(model): + rxs = to_rx(model) + + names = _get_utils(model).get_public_and_relevant_field_names(model) + for name, rx in zip(names, rxs): + assert isinstance(rx, param.reactive.rx) + assert rx.rx.value == getattr(model, name) + + +def test_to_rx_all_custom(model, can_observe_field): + age, weight, name = to_rx(model, "age", "weight", "name") + assert isinstance(name, param.reactive.rx) + assert isinstance(age, param.reactive.rx) + assert isinstance(weight, param.reactive.rx) + + assert name.rx.value == model.name + assert age.rx.value == model.age + assert weight.rx.value == model.weight + + if can_observe_field: + # model synced to reactive + model.name = "B" + model.age = 2 + model.weight = 2.2 + assert name.rx.value == model.name + assert age.rx.value == model.age + assert weight.rx.value == model.weight + + # reactive synced to viewer + name.rx.value = "C" + age.rx.value = 3 + weight.rx.value = 3.3 + assert name.rx.value == model.name + assert age.rx.value == model.age + assert weight.rx.value == model.weight + + +def test_to_rx_subset(model, can_observe_field): + name, age = to_rx(model, "name", "age") + assert isinstance(name, param.reactive.rx) + assert isinstance(age, param.reactive.rx) + + assert name.rx.value == model.name + assert age.rx.value == model.age + + if can_observe_field: + # model synced to reactive + model.name = "B" + model.age = 2 + assert name.rx.value == model.name + assert age.rx.value == model.age + + # reactive synced to viewer + name.rx.value = "C" + age.rx.value = 3 + assert name.rx.value == model.name + assert age.rx.value == model.age + + +def test_to_rx_single(model, can_observe_field): + age = to_rx(model, "age") + assert isinstance(age, param.reactive.rx) + + assert age.rx.value == model.age + + if can_observe_field: + # sync: model -> rx + model.age = 2 + assert age.rx.value == model.age + + # sync: rx -> model + age.rx.value = 3 + assert age.rx.value == model.age + + +def test_wrap_model_names_tuple(model_class, can_observe_field): + class ExampleWrapper(ModelParameterized): + _model_class = model_class + _model_names = ("name", "age") + + parameterized = ExampleWrapper(age=100) + + widget = parameterized.model + assert parameterized.name == widget.name == "Default Name" + assert parameterized.age == widget.age == 100 + + if can_observe_field: + # sync: model -> parameterized + widget.name = "B" + widget.age = 2 + assert parameterized.name == widget.name + assert parameterized.age == widget.age + + # sync: parameterized -> model + with param.edit_constant(parameterized): + parameterized.name = "C" + parameterized.age = 3 + assert parameterized.name == widget.name + assert parameterized.age == widget.age + + +def test_wrap_model_names_dict(model_class, can_observe_field): + class ExampleWrapper(ModelParameterized): + _model_class = model_class + _model_names = {"name": "xname", "age": "xage"} + + parameterized = ExampleWrapper(xage=100) + + widget = parameterized.model + assert parameterized.xname == widget.name == "Default Name" + assert parameterized.xage == widget.age == 100 + + if can_observe_field: + # sync: model -> parameterized + widget.name = "B" + widget.age = 2 + assert parameterized.xname == widget.name + assert parameterized.xage == widget.age + + # widget synced to viewer + parameterized.xname = "C" + parameterized.xage = 3 + assert parameterized.xname == widget.name + assert parameterized.xage == widget.age + + +def test_widget_viewer_from_class_and_no_names(model_class, can_observe_field): + class ExampleViewer(ModelViewer): + _model_class = model_class + + parameterized = ExampleViewer(age=100) + assert {"weight", "age", "name", "design", "tags"} <= set(parameterized.param) + + widget = parameterized.model + assert parameterized.name == widget.name == "Default Name" + assert parameterized.age == widget.age == 100 + + if can_observe_field: + # sync: model -> parameterized + widget.name = "B" + widget.age = 2 + assert parameterized.name == widget.name + assert parameterized.age == widget.age + + # widget synced to viewer + with param.edit_constant(parameterized): + parameterized.name = "C" + parameterized.age = 3 + assert parameterized.name == widget.name + assert parameterized.age == widget.age + + +def test_widget_viewer_from_class_and_list_names(model_class, can_observe_field): + class ExampleViewer(ModelViewer): + _model_class = model_class + _model_names = ["name", "age"] + + parameterized = ExampleViewer(age=100) + + widget = parameterized.model + assert parameterized.name == widget.name == "Default Name" + assert parameterized.age == widget.age == 100 + + if can_observe_field: + # sync: model -> parameterized + widget.name = "B" + widget.age = 2 + assert parameterized.name == widget.name + assert parameterized.age == widget.age + + # widget synced to viewer + with param.edit_constant(parameterized): + parameterized.name = "C" + parameterized.age = 3 + assert parameterized.name == widget.name + assert parameterized.age == widget.age + + +def test_widget_viewer_from_class_and_dict_names(model_class, can_observe_field): + class ExampleViewer(ModelViewer): + _model_class = model_class + _model_names = {"name": "xname", "age": "xage"} + + parameterized = ExampleViewer(xage=100) + + widget = parameterized.model + assert parameterized.xname == widget.name == "Default Name" + assert parameterized.xage == widget.age == 100 + + if can_observe_field: + # sync: model -> parameterized + widget.name = "B" + widget.age = 2 + assert parameterized.xname == widget.name + assert parameterized.xage == widget.age + + # widget synced to viewer + parameterized.xname = "C" + parameterized.xage = 3 + assert parameterized.xname == widget.name + assert parameterized.xage == widget.age + + +def test_widget_viewer_from_instance(model_class, can_observe_field): + class ExampleWidgetViewer(ModelViewer): + _model_class = model_class + + name = param.String("default", doc="A string parameter") + age = param.Integer(3, bounds=(0, 10)) + not_trait = param.Parameter(1) + + viewer = ExampleWidgetViewer(height=500, sizing_mode="stretch_width") + widget = viewer.model + assert {"name", "age", "weight", "not_trait", "read_only"} <= set(viewer.param) + + assert viewer.name == widget.name == "Default Name" + assert viewer.age == widget.age == 3 + assert viewer.weight == widget.weight == 0.0 + + _assert_is_synced(viewer, widget, can_observe_field) + + +def test_widget_viewer_child_class(model, can_observe_field): + class ExampleWidgetViewer(ModelViewer): + name = param.String("default", doc="A string parameter") + age = param.Integer(3, bounds=(0, 10)) + not_trait = param.Parameter(-2.0) + + viewer = ExampleWidgetViewer(model=model, height=500, sizing_mode="stretch_width") + assert {"name", "age", "weight", "not_trait", "read_only"} <= set(viewer.param) + + assert viewer.name == model.name == "A" + assert viewer.age == model.age == 3 + assert viewer.weight == model.weight == 1.1 + + _assert_is_synced(viewer, model, can_observe_field) + + +def test_dont_sync_non_shared_parameter(model): + class ExampleWidgetViewer(param.Parameterized): + name = param.String("default", doc="A string parameter") + age = param.Integer(3, bounds=(0, 10)) + + parameterized = ExampleWidgetViewer() + sync_with_parameterized(model=model, parameterized=parameterized) + + +def test_dont_sync_non_shared_trait(model): + class ExampleWidgetViewer(param.Parameterized): + wealth = param.Integer(3, bounds=(0, 10)) + + parameterized = ExampleWidgetViewer() + sync_with_parameterized(model=model, parameterized=parameterized, names=["wealth"]) + +def test_model_parameterized_parameters_added_to_instance_not_class(model): + parameterized_all = ModelParameterized( + model=model + ) + + parameterized_one = ModelParameterized( + model=model, names=("age",) + ) + + assert set(parameterized_one.param) < set(parameterized_all.param) + +def test_can_create_correct_parameter_type(model_class): + class ExampleParameterized(ModelParameterized): + _model_class = model_class + + parameterized = ExampleParameterized() + + assert isinstance(parameterized, param.Parameterized) + assert isinstance(parameterized.param.name, param.String) + assert isinstance(parameterized.param.age, param.Integer) + assert isinstance(parameterized.param.weight, param.Number) + assert isinstance(parameterized.param.bool_field, param.Boolean) + assert isinstance(parameterized.param.list_field, param.List) + assert isinstance(parameterized.param.tuple_field, VariableLengthTuple) + assert isinstance(parameterized.param.dict_field, param.Dict) + assert isinstance(parameterized.param.bytes_field, param.Bytes) + assert isinstance(parameterized.param.callable_field, param.Callable) + assert isinstance(parameterized.param.literal_field, param.Selector) + + # Can we set a tuple of another length? + parameterized.tuple_field = (1,2,3,4) + +def test_model_form(model_class): + model = ModelForm(model_class=model_class, button_kwargs=dict(name="Run"), show_name=False, sort=True) + + assert model._model + assert model._parameterized + assert model._submit_button + assert model._param_pane + assert model.__panel__() + + assert not model.value + + # Updates on submit only + model._parameterized.age=100 + assert not model.value + model._submit_button.clicks += 1 + assert model.value + assert model.value.age == 100 + + # Updates on parameter change + model._submit_button.visible=False + model._parameterized.age=101 + assert model.value.age == 101 diff --git a/panel/tests/ui/pane/test_ipywidget.py b/panel/tests/ui/pane/test_ipywidget.py index 470951e831..bef14b4a6b 100644 --- a/panel/tests/ui/pane/test_ipywidget.py +++ b/panel/tests/ui/pane/test_ipywidget.py @@ -16,7 +16,7 @@ import reacton except Exception: reacton = None -requires_reacton = pytest.mark.skipif(reacton is None, reason="requires reaction") +requires_reacton = pytest.mark.skipif(reacton is None, reason="requires reacton") try: import anywidget diff --git a/panel/util/__init__.py b/panel/util/__init__.py index 3692ff2545..537f50cbef 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -396,7 +396,7 @@ def base_version(version: str) -> str: Return the version passed as input if no match is found with the pattern. """ # look at the start for e.g. 0.13.0, 0.13.0rc1, 0.13.0a19, 0.13.0b10 - pattern = r"([\d]+\.[\d]+\.[\d]+(?:a|rc|b)?[\d]*)" + pattern = r"([\d]+\.[\d]+\.[\d]+(?:\.?(?:a|rc|b|dev)[\d]*)?)" match = re.match(pattern, version) if match: return match.group() diff --git a/pixi.toml b/pixi.toml index 0f1eaea10e..cf27b9480d 100644 --- a/pixi.toml +++ b/pixi.toml @@ -115,6 +115,7 @@ numba = "*" reacton = "*" scipy = "*" textual = "*" +pydantic = "*" [feature.test-unit-task.tasks] # So it is not showing up in the test-ui environment test-unit = 'pytest panel/tests -n logical --dist loadgroup' @@ -152,6 +153,7 @@ PANEL_IPYWIDGET = "1" [feature.doc.dependencies] lxml = "*" nbsite = ">=0.8.4" +pydantic = "*" selenium = "*" [feature.doc.tasks]