Skip to content
Merged
61 changes: 53 additions & 8 deletions solara/components/component_vue.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import inspect
import os
from typing import Any, Callable, Dict, Type
from typing import Any, Callable, Dict, Optional, Type

import ipyvue as vue
import ipyvuetify as v
Expand Down Expand Up @@ -65,18 +65,36 @@ def event_handler(self, data, buffers=None, event_name=event_name, param=param):


def _widget_vue(
vue_path: str,
vue_path: Optional[str],
vuetify=True,
to_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {},
from_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {},
tags: Dict[str, Any] = {},
esm_module: Optional[str] = None,
esm_export: Optional[str] = None,
) -> Callable[[Callable[P, None]], Type[v.VuetifyTemplate]]:
def decorator(func: Callable[P, None]):
class VuetifyWidgetSolara(v.VuetifyTemplate):
template_file = (os.path.abspath(inspect.getfile(func)), vue_path)
if esm_module is not None:
# the component is an export of a precompiled ES module
# (see ipyvue.define_module) instead of a compiled-in-the-browser
# .vue file
class VuetifyWidgetSolara(v.VuetifyTemplate):
@traitlets.default("template")
def _default_template(self):
return vue.Template(esm_module=esm_module, esm_export=esm_export)

class VueWidgetSolara(vue.VueTemplate):
@traitlets.default("template")
def _default_template(self):
return vue.Template(esm_module=esm_module, esm_export=esm_export)

class VueWidgetSolara(vue.VueTemplate):
template_file = (os.path.abspath(inspect.getfile(func)), vue_path)
else:

class VuetifyWidgetSolara(v.VuetifyTemplate): # type: ignore[no-redef]
template_file = (os.path.abspath(inspect.getfile(func)), vue_path)

class VueWidgetSolara(vue.VueTemplate): # type: ignore[no-redef]
template_file = (os.path.abspath(inspect.getfile(func)), vue_path)

base_class = VuetifyWidgetSolara if vuetify else VueWidgetSolara
widget_class = _widget_from_signature("VueWidgetSolaraSub", base_class, func, "vue_", to_json=to_json, from_json=from_json, tags=tags)
Expand All @@ -87,11 +105,13 @@ class VueWidgetSolara(vue.VueTemplate):


def component_vue(
vue_path: str,
vue_path: Optional[str] = None,
vuetify=True,
tags: Dict[str, Any] = {},
to_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {},
from_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {},
esm_module: Optional[str] = None,
esm_export: Optional[str] = None,
) -> Callable[[Callable[P, None]], Callable[P, solara.Element]]:
"""Decorator to create a component backed by a Vue template.

Expand Down Expand Up @@ -165,15 +185,40 @@ def MyDateComponentSame(month: datetime.date, event_date_clicked: Callable):

```

## Precompiled ES modules

Instead of a `.vue` file that is compiled in the browser, the component can be an export of a
precompiled ES module (e.g. built with vite, see `ipyvue.define_module`). This requires ipyvue
with ES module support:

```python
import ipyvue
import solara

ipyvue.define_module("my-components", Path(__file__).parent / "dist/my-components.mjs")

@solara.component_vue(esm_module="my-components", esm_export="Counter")
def Counter(count: int = 0, event_bump: Callable[[int], None] = None):
pass
```

## Arguments

* `vue_path`: The path to the Vue template file.
* `vuetify`: Whether the Vue template uses Vuetify components.
* `esm_module`: Name of an ES module registered with `ipyvue.define_module` (alternative to `vue_path`).
* `esm_export`: The export of that module to use as the component (default: the default export).

"""
if (vue_path is None) == (esm_module is None):
raise TypeError("pass either vue_path or esm_module")
if esm_module is not None and not hasattr(vue, "define_module"):
raise RuntimeError("esm_module requires ipyvue with ES module support")

def decorator(func: Callable[P, None]):
VueWidgetSolaraSub = _widget_vue(vue_path, vuetify=vuetify, to_json=to_json, from_json=from_json, tags=tags)(func)
VueWidgetSolaraSub = _widget_vue(
vue_path, vuetify=vuetify, to_json=to_json, from_json=from_json, tags=tags, esm_module=esm_module, esm_export=esm_export
)(func)

def wrapper(*args, **kwargs):
event_callbacks = {}
Expand Down
7 changes: 7 additions & 0 deletions solara/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,13 @@ def load_app_widget(app_state, app_script: AppScript, pathname: str):
solara.server.esm.create_modules()
solara.server.esm.create_import_map()

import ipyvue

if hasattr(ipyvue, "define_module"):
import solara.server.esm_vue

solara.server.esm_vue.create_modules()

try:
render_context = context.app_object
app_state = app_state_initial
Expand Down
60 changes: 53 additions & 7 deletions solara/server/esm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# in the future, we may want to move the esm features of ipyreact
# into a separate package, and then we can import it unconditionally
import logging
import warnings
import threading
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Tuple, Union
from typing import Dict, List, Optional, Tuple, Union

import ipyreact.importmap
import ipyreact.module
Expand All @@ -21,14 +22,32 @@


# in solara server, we'll monkey patch ipyreact.module with this
def define_module(name, module: Union[str, Path]):
# collect the dependencies at this moment
def define_module(name: str, module: Union[str, Path, None] = None, *, code: Optional[str] = None, url: Optional[str] = None):
if sum(x is not None for x in (module, code, url)) != 1:
raise TypeError("pass exactly one of module (a Path), code or url")
if isinstance(module, str):
# the pre-0.6 ipyreact API: a plain str was module code
warnings.warn(
"passing module code as a plain str is deprecated, use code=... (or url=... for a url)",
DeprecationWarning,
stacklevel=2,
)
code = module
module = None
source: Union[str, Path]
if code is not None:
source = _CODE_PREFIX + code
elif url is not None:
source = url
else:
assert module is not None
source = module
with lock:
dependencies = list(_modules.keys())
logger.info("define module %s %s (dependencies=%r)", name, module, dependencies)
logger.info("define module %s (dependencies=%r)", name, dependencies)
if name in _modules:
old_module, dependencies = _modules[name]
_modules[name] = (module, dependencies)
_modules[name] = (source, dependencies)
if isinstance(module, Path):
# rebuilding the bundle (e.g. vite/esbuild --watch) triggers a normal
# solara reload, which re-reads the file in create_modules
Expand Down Expand Up @@ -68,15 +87,42 @@ def cleanup(kernel_id=kernel_id):
_modules_added[name] = create_module(name, module, dependencies=dependencies)
logger.info("create module %s %s %s", name, module, dependencies)
else:
_modules_added[name].code = module if not isinstance(module, Path) else module.read_text(encoding="utf8")
_modules_added[name].code = _read(module) if not _is_url(module) else _modules_added[name].code
_modules_added[name].dependencies = dependencies
logger.info("update module %s %s %s", name, module, dependencies)
widgets[name] = _modules_added[name]
return widgets


# inline source is stored with this prefix so a plain str always means a url
_CODE_PREFIX = "\x00code:"


def _is_url(module: Union[str, Path]) -> bool:
return isinstance(module, str) and not module.startswith(_CODE_PREFIX)


def _read(module: Union[str, Path]) -> str:
if isinstance(module, Path):
return module.read_text(encoding="utf8")
return module[len(_CODE_PREFIX) :] if module.startswith(_CODE_PREFIX) else module


def get_module_urls() -> List[str]:
from solara.server.server import versioned_url

with lock:
urls = [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)]
return [versioned_url(url) for url in urls]


def create_module(name, module: Union[str, Path], dependencies: List[str]):
return ipyreact.module.Module(code=module if not isinstance(module, Path) else module.read_text(encoding="utf8"), name=name, dependencies=dependencies)
from solara.server.server import versioned_url

if _is_url(module):
assert isinstance(module, str)
return ipyreact.module.Module(url=versioned_url(module), name=name, dependencies=dependencies)
return ipyreact.module.Module(code=_read(module), name=name, dependencies=dependencies)


def create_import_map():
Expand Down
127 changes: 127 additions & 0 deletions solara/server/esm_vue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Kernel-aware ES-module support for ipyvue, mirroring esm.py (ipyreact).
# ipyvue.define_module is monkey-patched (see patch.py) so modules are
# declared once at import time and materialized per kernel. Unlike the
# ipyreact variant, a redefinition recreates the Module widget when the
# kernel's previous widget was closed (e.g. by context.restart on hot
# reload), so in-place reloads actually reach the browser.
import logging
import threading
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

import ipyvue.esm

from solara.server import kernel_context

logger = logging.getLogger("solara.server.esm_vue")
lock = threading.Lock()

_modules: Dict[str, Tuple[Union[str, Path], List[str]]] = {}
_modules_added_per_kernel: Dict[str, Dict[str, ipyvue.esm.Module]] = defaultdict(dict)


def define_module(name: str, module: Optional[Path] = None, *, code: Optional[str] = None, url: Optional[str] = None):
if sum(x is not None for x in (module, code, url)) != 1:
raise TypeError("pass exactly one of module (a Path), code or url")
if module is not None and not isinstance(module, Path):
raise TypeError("module must be a Path; use url=... or code=... for strings")
source: Union[str, Path]
if code is not None:
source = _CODE_PREFIX + code
elif url is not None:
source = url
else:
assert module is not None
source = module
with lock:
dependencies = list(_modules.keys())
logger.info("define vue module %s (dependencies=%r)", name, dependencies)
if name in _modules:
_old_module, dependencies = _modules[name]
_modules[name] = (source, dependencies)
if isinstance(module, Path):
# rebuilding the bundle (e.g. vite build --watch) triggers a normal
# solara reload, which re-reads the file in create_modules
from solara.server import reload

reload.reloader.watcher.add_file(str(module))
if kernel_context.has_current_context():
create_modules()
return None


def get_module_names() -> List[str]:
return list(_modules.keys())


def get_module_urls() -> List[str]:
"""Urls of url-backed modules, for modulepreload hints in the page."""
with lock:
urls = [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)]
from solara.server.server import versioned_url

return [versioned_url(url) for url in urls]


# inline source is stored with this prefix so a plain str always means a url
_CODE_PREFIX = "\x00code:"


def _is_url(module: Union[str, Path]) -> bool:
return isinstance(module, str) and not module.startswith(_CODE_PREFIX)


def _read(module: Union[str, Path]) -> str:
if isinstance(module, Path):
return module.read_text(encoding="utf8")
return module[len(_CODE_PREFIX) :] if module.startswith(_CODE_PREFIX) else module


def _versioned(url: str) -> str:
from solara.server.server import versioned_url

return versioned_url(url)


def create_modules():
context = kernel_context.get_current_context()
kernel_id = context.id
if kernel_id not in _modules_added_per_kernel:
# widgets close with the kernel; drop our per-kernel bookkeeping too
def cleanup(kernel_id=kernel_id) -> None:
_modules_added_per_kernel.pop(kernel_id, None)

context.on_close(cleanup)
_modules_added = _modules_added_per_kernel[kernel_id]
widgets = {}
with lock:
for name, (module, dependencies) in _modules.items():
widget = _modules_added.get(name)
if widget is not None and widget.comm is None:
# closed by context.restart (hot reload) - a trait update
# would go nowhere, recreate instead
widget = None
if widget is None:
if _is_url(module):
assert isinstance(module, str)
widget = ipyvue.esm.Module(url=_versioned(module), name=name, dependencies=dependencies)
else:
widget = ipyvue.esm.Module(code=_read(module), name=name, dependencies=dependencies)
_modules_added[name] = widget
logger.info("create vue module %s", name)
elif _is_url(module):
assert isinstance(module, str)
url = _versioned(module)
if widget.url != url:
widget.url = url
logger.info("update vue module url %s", name)
else:
code = _read(module)
if widget.code != code:
widget.code = code
logger.info("update vue module %s", name)
if widget.dependencies != dependencies:
widget.dependencies = dependencies
widgets[name] = widget
return widgets
18 changes: 18 additions & 0 deletions solara/server/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,22 @@ def patch_ipyreact():
ipyreact.importmap._update_import_map = lambda: None


def patch_ipyvue_esm():
import ipyvue

if not hasattr(ipyvue, "define_module"):
# ipyvue without ES module support
return

import ipyvue.esm

from . import esm_vue

ipyvue.esm.define_module = esm_vue.define_module
ipyvue.esm.get_module_names = esm_vue.get_module_names
ipyvue.define_module = esm_vue.define_module


@solara.util.once
def patch_matplotlib():
import matplotlib
Expand Down Expand Up @@ -466,6 +482,8 @@ def patch():
else:
patch_ipyreact()

patch_ipyvue_esm()

if "MPLBACKEND" not in os.environ:
if ipykernel_version_major < 6:
# changed in https://github.com/ipython/ipykernel/pull/591
Expand Down
Loading
Loading