Skip to content
Open
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
1 change: 1 addition & 0 deletions examples/reference/panes/Vega.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"* **``object``** (dict or altair Chart): Either a dictionary containing a Vega or Vega-Lite plot specification, or an Altair Chart.\n",
"* **``show_actions``** (boolean): Whether to show the chart actions menu, such as save, edit, etc.\n",
"* **``theme``** (str): A theme to apply to the plot. Must be one of 'excel', 'ggplot2', 'quartz', 'vox', 'fivethirtyeight', 'dark', 'latimes', 'urbaninstitute', 'googlecharts', 'powerbi', 'carbonwhite', 'carbong10', 'carbong90', or 'carbong100'.\n",
"* **``validate``** (boolean): Whether to validate the Vega specification against the schema.\n",
"\n",
"Readonly parameters:\n",
"\n",
Expand Down
120 changes: 119 additions & 1 deletion panel/pane/vega.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
import sys

from collections.abc import Mapping
from copy import deepcopy
from typing import (
TYPE_CHECKING, Any, ClassVar, Literal,
)

import numpy as np
import param
import platformdirs
import requests

from bokeh.models import ColumnDataSource
from pyviz_comms import JupyterComm

from ..io import cache
from ..util import lazy_load
from .base import ModelPane
from .image import PDF, SVG, Image
Expand All @@ -29,6 +33,34 @@

VEGA_EXPORT_FORMATS = Literal['png', 'jpeg', 'svg', 'pdf', 'html', 'url', 'scenegraph']

SCHEMA_URL = "https://vega.github.io/schema/vega-lite/v5.json"

_VEGA_ZOOMABLE_MAP_ITEMS = {
"params": [
{"name": "tx", "update": "width / 2"},
{"name": "ty", "update": "height / 2"},
{
"name": "scale",
"value": 300,
"on": [{"events": {"type": "wheel", "consume": True}, "update": "clamp(scale * pow(1.003, -event.deltaY * pow(16, event.deltaMode)), 150, 50000)"}],
},
{"name": "angles", "value": [0, 0], "on": [{"events": "pointerdown", "update": "[rotateX, centerY]"}]},
{"name": "cloned", "value": None, "on": [{"events": "pointerdown", "update": "copy('projection')"}]},
{"name": "start", "value": None, "on": [{"events": "pointerdown", "update": "invert(cloned, xy())"}]},
{"name": "drag", "value": None, "on": [{"events": "[pointerdown, window:pointerup] > window:pointermove", "update": "invert(cloned, xy())"}]},
{"name": "delta", "value": None, "on": [{"events": {"signal": "drag"}, "update": "[drag[0] - start[0], start[1] - drag[1]]"}]},
{"name": "rotateX", "value": -240, "on": [{"events": {"signal": "delta"}, "update": "angles[0] + delta[0]"}]},
{"name": "centerY", "value": 40, "on": [{"events": {"signal": "delta"}, "update": "clamp(angles[1] + delta[1], -60, 60)"}]},
],
"projection": {
"scale": {"signal": "scale"},
"rotate": [{"signal": "rotateX"}, 0, 0],
"center": [0, {"signal": "centerY"}],
"translate": [{"signal": "tx"}, {"signal": "ty"}]
}
}


def ds_as_cds(dataset):
"""
Converts Vega dataset into Bokeh ColumnDataSource data (Narwhals-compatible)
Expand Down Expand Up @@ -226,18 +258,104 @@ class Vega(ModelPane):
'urbaninstitute', or 'googlecharts'.
""")

validate = param.Boolean(default=True, doc="""
Whether to validate the Vega specification against the schema.""")

priority: ClassVar[float | bool | None] = 0.8

_rename: ClassVar[Mapping[str, str | None]] = {
'selection': None, 'debounce': None, 'object': 'data'}
'selection': None, 'debounce': None, 'object': 'data', 'validate': None}

_updates: ClassVar[bool] = True

def __init__(self, object=None, **params):
if isinstance(object, dict) and "$schema" not in object:
self.param.warning(f"No $schema found; using {SCHEMA_URL} by default. Specify the schema explicitly to avoid this warning.")
object["$schema"] = SCHEMA_URL
super().__init__(object, **params)
self.param.watch(self._update_selections, ['object'])
self._update_selections()

@cache(to_disk=True, cache_path=platformdirs.user_cache_dir("panel"))
def _download_vega_lite_schema(self, schema_url: str | bytes) -> dict:
response = requests.get(schema_url, timeout=5)
return response.json()

@staticmethod
def _format_validation_error(error: Exception) -> str:
"""Format JSONSchema validation errors into a readable message."""
errors: dict[str, str] = {}
last_path = ""
rejected_paths = set()

def process_error(err):
nonlocal last_path
path = err.json_path
if errors and path == "$":
return # these $ downstream errors are due to upstream errors
if err.validator != "anyOf":
# other downstream errors that are due to upstream errors
# $.encoding.x.sort: '-host_count' is not one of ..
#$.encoding.x: 'value' is a required property
if (
(last_path != path
and last_path.split(path)[-1].count(".") <= 1)
or path in rejected_paths
):
rejected_paths.add(path)
# if we have a more specific error message, e.g. enum, don't overwrite it
elif path in errors and err.validator in ("const", "type"):
pass
else:
errors[path] = f"{path}: {err.message}"
last_path = path
if err.context:
for e in err.context:
process_error(e)

process_error(error)
return "\n".join(errors.values())

@param.depends("object", watch=True, on_init=True)
def _validate_object(self):
try:
from jsonschema import ( # type: ignore[import-untyped]
Draft7Validator, ValidationError,
)
except ImportError as e:
self.param.warning(f"Skipping validation due to {e}.")
return # Skip validation if jsonschema is not available

object = self.object
if not self.validate or not object or not isinstance(object, dict):
return

schema = object.get('$schema', '')
if not schema:
raise ValidationError("No $schema found on Vega object")

try:
schema = self._download_vega_lite_schema(schema)
except Exception as e:
self.param.warning(f"Skipping validation because could not load Vega schema at {schema}: {e}")
return

try:
vega_validator = Draft7Validator(schema)
# the zoomable params work, but aren't officially valid
# so we need to remove them for validation
# https://stackoverflow.com/a/78342773/9324652
object_copy = deepcopy(object)
for key in _VEGA_ZOOMABLE_MAP_ITEMS.get("projection", {}):
object_copy.get("projection", {}).pop(key, None)
object_copy.pop("params", None)
# Use dummy URL to avoid $.data: 'url' is a required property
# when data is an inline dict / dataframe
object_copy["data"] = {"url": "dummy_url"}
vega_validator.validate(object_copy)
except ValidationError as e:
raise ValidationError(self._format_validation_error(e)) from e

@property
def _selections(self):
return _get_selections(self.object)
Expand Down
35 changes: 35 additions & 0 deletions panel/tests/pane/test_vega.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest

from jsonschema import ValidationError # type: ignore[import-untyped]
from packaging.version import Version

try:
Expand All @@ -24,6 +25,7 @@
from panel.pane import PaneBase, Vega
from panel.pane.image import PDF, SVG, Image
from panel.pane.markup import HTML
from panel.pane.vega import SCHEMA_URL

try:
import vl_convert as vlc # type: ignore[import-untyped]
Expand Down Expand Up @@ -548,3 +550,36 @@ def test_export_as_pane_false_returns_raw_data(self, vl_convert):
result = pane.export('svg', as_pane=False)
assert isinstance(result, str)
assert not isinstance(result, SVG)

def test_vega_missing_schema_auto_added():
vegalite = {
"data": {"url": "https://raw.githubusercontent.com/vega/vega/master/docs/data/barley.json"},
"mark": "bar",
"encoding": {
"x": {"aggregate": "sum", "field": "yield", "type": "quantitative"},
"y": {"field": "variety", "type": "nominal"},
"color": {"field": "site", "type": "nominal"}
}
}
# test self.param.warning
pane = pn.pane.Vega(vegalite)
assert pane.object["$schema"] == SCHEMA_URL


@pytest.mark.skipif(
pytest.importorskip("diskcache", reason="requires diskcache") is None,
reason="requires diskcache"
)
def test_vega_validate():
vegalite = {
"$schema": SCHEMA_URL,
"data": {"url": "https://raw.githubusercontent.com/vega/vega/master/docs/data/barley.json"},
"mark": "bar",
"encoding": {
"x": {"aggregate": "sum", "field": "yield", "type": "qnt"},
"y": {"field": "variety", "type": "nominal"},
"color": {"field": "site", "type": "nominal"}
}
}
with pytest.raises(ValidationError, match="'qnt' is not one of"):
pn.pane.Vega(vegalite)
10 changes: 10 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,14 @@ pytest-cov = "*"
pytest-github-actions-annotate-failures = "*"
pytest-rerunfailures = "*"
pytest-xdist = "*"
platformdirs = "*"
jsonschema = "*"

[feature.bokeh37.dependencies]
bokeh = "3.7.*"
diskcache = "*"
platformdirs = "*"
jsonschema = "*"

[feature.test.dependencies]
# Optional dependencies - recommended
Expand All @@ -201,10 +206,12 @@ ipympl = "*"
ipyvuetify = "*"
ipywidgets_bokeh = "*"
numba = "*"
platformdirs = "*"
polars = "*"
reacton = "*"
scipy = "*"
textual = "*"
jsonschema = "*"
vl-convert-python = "*"

[feature.test-314.dependencies]
Expand Down Expand Up @@ -234,6 +241,8 @@ polars = "*"
reacton = "*"
scipy = "*"
textual = "*"
jsonschema = "*"
vl-convert-python = "*"

[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'
Expand Down Expand Up @@ -273,6 +282,7 @@ pytest = "*"
pandas-stubs = "*"
types-bleach = "*"
types-croniter = "*"
types-jsonschema = "*"
types-Markdown = "*"
types-psutil = "*"
types-requests = "*"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ mypy = [
"pandas-stubs",
"types-bleach",
"types-croniter",
"types-jsonschema",
"types-Markdown",
"types-psutil",
"types-requests",
Expand Down
Loading