Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions solara/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,5 +761,33 @@ def main():
cli(args[1:])


@cli.command()
@click.argument("app", nargs=-1, required=True)
@click.option("--output", default="vue-components/src", help="Directory for the generated entry and manifest.")
@click.option("--name", default="app-components", help="Bundle (ES module) name.")
def vue_bundle(app: typing.List[str], output: str, name: str):
"""Generate a bundler entry + manifest for all component_vue templates.

APP is one or more python modules (my.app) or scripts (app.py) that are
imported so every @component_vue decorator runs; the generated
<name>-entry.js is then built by your bundler (e.g. vite, with vue
external), and the manifest is consumed at runtime via the
SOLARA_VUE_BUNDLES environment variable.
"""
import importlib
import runpy
from pathlib import Path as _Path

from solara.components import vue_bundle as _vue_bundle

for target in app:
if target.endswith(".py"):
runpy.run_path(target)
else:
importlib.import_module(target)
entry = _vue_bundle.write_bundle_entry(_Path(output), name=name)
print(f"wrote {entry} and {entry.parent / (name + '-manifest.json')}")


if __name__ == "__main__":
main()
13 changes: 13 additions & 0 deletions solara/components/component_vue.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,19 @@ def Counter(count: int = 0, event_bump: Callable[[int], None] = None):
raise RuntimeError("esm_module requires ipyvue with ES module support")

def decorator(func: Callable[P, None]):
nonlocal vue_path, esm_module, esm_export
if vue_path is not None:
from pathlib import Path

from . import vue_bundle

vue_file = Path(inspect.getfile(func)).parent / vue_path
vue_bundle.record(vue_file, func.__name__)
if vue_bundle.enabled():
# serve the template from the precompiled bundle instead of
# shipping its source (see solara/components/vue_bundle.py)
esm_module, esm_export = vue_bundle.lookup(vue_file)
vue_path = None
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)
Expand Down
122 changes: 122 additions & 0 deletions solara/components/vue_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Serve component_vue templates from a precompiled ES module bundle.

The @component_vue decorator records every .vue file it is given. From that,
`write_bundle_entry` generates a vite/rollup entry (one export per template)
plus a manifest mapping each template's content hash to its export name.

With SOLARA_VUE_BUNDLES set (comma-separated manifest paths), component_vue
resolves templates through the manifests instead of shipping their source:
the widget uses Template(esm_module=..., esm_export=...) (requires ipyvue
with ES module support). A template whose current content hash is not in any
manifest is a hard error - a changed file means the bundle is stale.

Building the bundle (running vite) and defining the module
(ipyvue.define_module with a Path or url) remain the application's
responsibility; solara only generates text and checks it.
"""

import hashlib
import json
import logging
import os
import pathlib
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple

# every .vue file passed to @component_vue, in import order, with the
# decorated function's name (used as the export name)
_vue_files: Dict[Path, str] = {}

_manifests: Optional[Dict[str, Tuple[str, str]]] = None # sha1 -> (module, export)
_manifest_names: Dict[str, List[str]] = {} # basename -> manifest names, for errors
_loaded_bundles: List[str] = [] # manifest names, for errors


def _sha1(path: Path) -> str:
return hashlib.sha1(path.read_bytes()).hexdigest() # noqa: S324 - content id, not security


def record(vue_file: Path, component_name: str) -> None:
vue_file = vue_file.resolve()
if vue_file not in _vue_files:
_vue_files[vue_file] = component_name


def _load_manifests() -> Dict[str, Tuple[str, str]]:
global _manifests
if _manifests is None:
_manifests = {}
for manifest_path in os.environ.get("SOLARA_VUE_BUNDLES", "").split(","):
if not manifest_path.strip():
continue
manifest = json.loads(Path(manifest_path.strip()).read_text(encoding="utf8"))
_loaded_bundles.append(manifest["name"])
for file, entry in manifest["components"].items():
_manifests[entry["sha1"]] = (manifest["name"], entry["export"])
_manifest_names.setdefault(Path(file).name, []).append(manifest["name"])
return _manifests


def enabled() -> bool:
return bool(os.environ.get("SOLARA_VUE_BUNDLES"))


def lookup(vue_file: Path) -> Tuple[str, str]:
"""Resolve a template to (esm_module, esm_export), or raise if the bundle
does not contain the template's current content."""
manifests = _load_manifests()
entry = manifests.get(_sha1(vue_file))
if entry is not None:
return entry
if vue_file.name in _manifest_names:
raise RuntimeError(f"{vue_file} changed since bundle {_manifest_names[vue_file.name]} was built, rebuild it (see write_bundle_entry)")
raise RuntimeError(
f"{vue_file} is not in any of the bundles {_loaded_bundles} (from SOLARA_VUE_BUNDLES); regenerate and rebuild the bundle that should contain it"
)


def _export_names() -> Dict[Path, str]:
# the python component name, readable in the entry, the bundle and vue
# devtools; only a within-bundle name collision (two components with the
# same function name) gets a content-hash suffix to stay unique
names: Dict[Path, str] = {}
seen: Dict[str, Path] = {}
for vue_file, component_name in _vue_files.items():
name = re.sub(r"[^a-zA-Z0-9]", "_", component_name)
if name in seen:
logging.getLogger("solara").warning(
"duplicate component name %s (%s and %s); disambiguating with a content-hash suffix", name, seen[name], vue_file
)
name = f"{name}_{_sha1(vue_file)[:8]}"
else:
seen[name] = vue_file
names[vue_file] = name
return names


def write_bundle_entry(directory: Path, name: str = "app-components") -> Path:
"""Write <name>-entry.js and <name>-manifest.json for every component_vue
template imported so far; returns the entry path. Import the application
first, then call this, then run the bundler on the entry."""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
if not _vue_files:
raise RuntimeError("no component_vue templates recorded; import the application first")
lines = []
exports = []
components = {}
export_names = _export_names()
for i, vue_file in enumerate(_vue_files):
export = export_names[vue_file]
relative = pathlib.PurePath(os.path.relpath(vue_file, directory)).as_posix()
lines.append(f'import _c{i} from "{relative}";')
# give the component a devtools/debugging name; an explicit name in
# the SFC wins (spread comes after)
exports.append(f'export const {export} = {{ name: "{export}", ..._c{i} }};')
components[str(vue_file)] = {"export": export, "sha1": _sha1(vue_file)}
lines += [""] + exports
entry = directory / f"{name}-entry.js"
entry.write_text("// generated by solara.components.vue_bundle.write_bundle_entry - do not edit\n" + "\n".join(lines) + "\n", encoding="utf8")
(directory / f"{name}-manifest.json").write_text(json.dumps({"name": name, "components": components}, indent=2) + "\n", encoding="utf8")
return entry
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,50 @@ For urls solara serves itself this enables aggressive caching without staleness:
The same applies to ipyreact modules. During development, prefer the `Path` form: the file
is watched, so a bundler in watch mode gives in-place hot reload.

## Bundling all component_vue templates

`@solara.component_vue` records every template it is given, so solara can generate the
bundler input for the whole application:

```bash
solara vue-bundle app.py --output vue-components/src --name app-components
# writes app-components-entry.js (one named export per template)
# and app-components-manifest.json (content hash -> export)
```

Build the entry with vite (`vue` external — ipyvue provides it via the import map):

```js
// vue-components/vite.config.mjs
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; // or @vitejs/plugin-vue2 on the vue2 stack

export default defineConfig({
plugins: [vue()],
build: {
lib: { entry: "src/app-components-entry.js", formats: ["es"], fileName: () => "app-components.mjs" },
rollupOptions: { external: ["vue"] },
// debugging: external map next to the bundle for url-served production
// bundles; use "inline" when serving the module file-backed (blob urls
// cannot resolve a relative sourceMappingURL)
sourcemap: true,
},
});
```

Then define the module (as above) and turn the mode on:

```bash
SOLARA_VUE_BUNDLES=vue-components/src/app-components-manifest.json solara run app.py
```

With the variable set, `component_vue` resolves every template through the manifests
instead of shipping its source: lookup is by content hash, so a template edited after
the bundle was built is a hard error (stale bundle) rather than silent drift. Unset,
everything behaves as before — that is the development mode, with per-template hot
reload. Each generated export carries the template's file name as the component
`name`, so vue devtools and warnings stay readable.

## Using a precompiled component as a tag

An export can also be used as a tag inside any other template via the `components` dict.
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/vue_bundle_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import json
from pathlib import Path

import pytest

from solara.components import vue_bundle

ipyvue = pytest.importorskip("ipyvue")


@pytest.fixture()
def clean_state(monkeypatch):
files = dict(vue_bundle._vue_files)
vue_bundle._vue_files.clear()
vue_bundle._manifests = None
vue_bundle._manifest_names.clear()
vue_bundle._loaded_bundles.clear()
monkeypatch.delenv("SOLARA_VUE_BUNDLES", raising=False)
try:
yield
finally:
vue_bundle._vue_files.clear()
vue_bundle._vue_files.update(files)
vue_bundle._manifests = None
vue_bundle._manifest_names.clear()
vue_bundle._loaded_bundles.clear()


def _make_template(tmp_path: Path, name: str, source: str) -> Path:
file = tmp_path / "app" / "components" / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(source)
return file


def test_write_entry_and_manifest(clean_state, tmp_path: Path):
a = _make_template(tmp_path, "a.vue", "<template><div>a</div></template>")
b = _make_template(tmp_path, "b.vue", "<template><div>b</div></template>")
vue_bundle.record(a, "CompA")
vue_bundle.record(b, "CompB")

entry = vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components")
lines = entry.read_text().splitlines()
assert any('from "../app/components/a.vue";' in line for line in lines)
assert any(line.startswith("export const CompA = ") and 'name: "CompA"' in line for line in lines)
manifest = json.loads((tmp_path / "bundle" / "test-components-manifest.json").read_text())
assert manifest["name"] == "test-components"
entry_a = next(v for k, v in manifest["components"].items() if k.endswith("a.vue"))
assert entry_a["export"] == "CompA"
assert entry_a["sha1"] == vue_bundle._sha1(a)


def test_lookup_by_content_hash(clean_state, tmp_path: Path, monkeypatch):
a = _make_template(tmp_path, "a.vue", "<template><div>a</div></template>")
vue_bundle.record(a, "CompA")
vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components")
monkeypatch.setenv("SOLARA_VUE_BUNDLES", str(tmp_path / "bundle" / "test-components-manifest.json"))
vue_bundle._manifests = None

assert vue_bundle.enabled()
assert vue_bundle.lookup(a) == ("test-components", "CompA")

# stale: content changed after the bundle was generated
a.write_text("<template><div>a2</div></template>")
with pytest.raises(RuntimeError, match="changed since bundle"):
vue_bundle.lookup(a)

# missing: never bundled
c = _make_template(tmp_path, "c.vue", "<template><div>c</div></template>")
with pytest.raises(RuntimeError, match=r"not in any of the bundles.*test-components"):
vue_bundle.lookup(c)


@pytest.mark.skipif(not hasattr(ipyvue, "define_module"), reason="needs ipyvue with ES module support")
def test_component_vue_uses_bundle(clean_state, tmp_path: Path, monkeypatch):
a = _make_template(tmp_path, "a.vue", "<template><div>{{ value }}</div></template>")
vue_bundle.record(a, "CompA")
vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components")
monkeypatch.setenv("SOLARA_VUE_BUNDLES", str(tmp_path / "bundle" / "test-components-manifest.json"))
vue_bundle._manifests = None

from solara.components.component_vue import _widget_vue

# simulate what @component_vue does for a template resolved via the bundle
module, export = vue_bundle.lookup(a)
assert (module, export) == ("test-components", "CompA")

@_widget_vue(None, esm_module=module, esm_export=export)
def Widget(value: int = 0):
pass

widget = Widget(value=3)
assert widget.template.esm_module == "test-components"
assert widget.template.esm_export == export
Loading