Skip to content

Commit bc95da2

Browse files
Merge pull request #727 from kkollsga/fix/optional-js-runtime-707
fix: replace quickjs with mini-racer for Python 3.13 + Windows support
2 parents ac18e69 + 40fbfbb commit bc95da2

File tree

3 files changed

+126
-12
lines changed

3 files changed

+126
-12
lines changed

pygwalker/utils/dsl_transform.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,62 @@
1-
from typing import Dict, List, Any
1+
from typing import Dict, List, Any, Optional, Callable
22
import os
33
import json
44

5-
try:
6-
from quickjs import Function
7-
except ImportError as exc:
8-
raise ImportError("`quickjs` is not installed, please install it first. refer it: `pip install quickjs`.") from exc
9-
105
from pygwalker._constants import ROOT_DIR
116
from .randoms import rand_str
127

8+
_dsl_to_workflow_js = None # type: Optional[Callable]
9+
_vega_to_dsl_js = None # type: Optional[Callable]
10+
11+
_INSTALL_MSG = (
12+
"Static HTML chart export requires a JavaScript runtime.\n"
13+
"Install with: pip install pygwalker[export]\n"
14+
"Or manually: pip install mini-racer"
15+
)
16+
17+
18+
def _make_js_callable(func_name, js_code):
19+
"""Create a callable that executes a named JS function via mini-racer (V8)."""
20+
from py_mini_racer import MiniRacer
21+
22+
ctx = MiniRacer()
23+
ctx.eval(js_code)
24+
25+
def call(*args):
26+
if not args:
27+
return ctx.eval("{}()".format(func_name))
28+
args_json = json.dumps(args)
29+
return ctx.eval("{}(...{})".format(func_name, args_json))
30+
31+
return call
32+
33+
34+
def _ensure_js_runtime():
35+
"""Lazily initialize JS runtime on first actual use."""
36+
global _dsl_to_workflow_js, _vega_to_dsl_js
37+
if _dsl_to_workflow_js is not None:
38+
return
39+
40+
try:
41+
dsl_js_path = os.path.join(ROOT_DIR, 'templates', 'dist', 'dsl-to-workflow.umd.js')
42+
vega_js_path = os.path.join(ROOT_DIR, 'templates', 'dist', 'vega-to-dsl.umd.js')
1343

14-
with open(os.path.join(ROOT_DIR, 'templates', 'dist', 'dsl-to-workflow.umd.js'), 'r', encoding='utf8') as f:
15-
_dsl_to_workflow_js = Function('main', f.read())
44+
with open(dsl_js_path, 'r', encoding='utf8') as f:
45+
_dsl_to_workflow_js = _make_js_callable('main', f.read())
1646

17-
with open(os.path.join(ROOT_DIR, 'templates', 'dist', 'vega-to-dsl.umd.js'), 'r', encoding='utf8') as f:
18-
_vega_to_dsl_js = Function('main', f.read())
47+
with open(vega_js_path, 'r', encoding='utf8') as f:
48+
_vega_to_dsl_js = _make_js_callable('main', f.read())
49+
except ImportError:
50+
raise ImportError(_INSTALL_MSG)
1951

2052

2153
def dsl_to_workflow(dsl: Dict[str, Any]) -> Dict[str, Any]:
54+
_ensure_js_runtime()
2255
return json.loads(_dsl_to_workflow_js(json.dumps(dsl)))
2356

2457

2558
def vega_to_dsl(vega_config: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]:
59+
_ensure_js_runtime()
2660
return json.loads(_vega_to_dsl_js(json.dumps({
2761
"vl": vega_config,
2862
"allFields": fields,

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ dependencies = [
3535
"packaging",
3636
"numpy",
3737
"ipylab<=1.0.0",
38-
"quickjs",
3938
"traitlets",
4039
"anywidget",
4140
]
@@ -65,8 +64,9 @@ labv4 = [
6564
"jupyter-server>2.5.0",
6665
"ipywidgets>=8.0.0"
6766
]
67+
export = ["mini-racer>=0.12"]
6868
all = [
69-
"pygwalker[pandas,polars,streamlit,reflex]",
69+
"pygwalker[pandas,polars,streamlit,reflex,export]",
7070
]
7171
dev = [
7272
"build",

tests/test_dsl_transform.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from unittest import mock
2+
import builtins
3+
4+
import pytest
5+
6+
import pygwalker.utils.dsl_transform as mod
7+
from pygwalker.utils.dsl_transform import dsl_to_workflow, vega_to_dsl
8+
9+
10+
def _reset_runtime():
11+
"""Reset the lazy-initialized JS runtime so tests are independent."""
12+
mod._dsl_to_workflow_js = None
13+
mod._vega_to_dsl_js = None
14+
15+
16+
def test_dsl_to_workflow_returns_valid_workflow():
17+
_reset_runtime()
18+
result = dsl_to_workflow({})
19+
assert "workflow" in result
20+
assert isinstance(result["workflow"], list)
21+
22+
23+
def test_dsl_to_workflow_with_fields():
24+
_reset_runtime()
25+
spec = {
26+
"encodings": {
27+
"dimensions": [{"fid": "a", "name": "a", "semanticType": "nominal", "analyticType": "dimension"}],
28+
"measures": [{"fid": "b", "name": "b", "semanticType": "quantitative", "analyticType": "measure"}],
29+
}
30+
}
31+
result = dsl_to_workflow(spec)
32+
assert "workflow" in result
33+
34+
35+
def test_vega_to_dsl_returns_expected_keys():
36+
_reset_runtime()
37+
vega_spec = {
38+
"mark": "bar",
39+
"encoding": {
40+
"x": {"field": "a", "type": "nominal"},
41+
"y": {"field": "b", "type": "quantitative"},
42+
},
43+
}
44+
fields = [
45+
{"fid": "a", "name": "a", "analyticType": "dimension", "semanticType": "nominal"},
46+
{"fid": "b", "name": "b", "analyticType": "measure", "semanticType": "quantitative"},
47+
]
48+
result = vega_to_dsl(vega_spec, fields)
49+
assert "config" in result
50+
assert "encodings" in result
51+
52+
53+
def test_import_error_when_no_js_runtime():
54+
_reset_runtime()
55+
original_import = builtins.__import__
56+
57+
def block_mini_racer(name, *args, **kwargs):
58+
if name == "py_mini_racer":
59+
raise ImportError("mocked")
60+
return original_import(name, *args, **kwargs)
61+
62+
with mock.patch("builtins.__import__", side_effect=block_mini_racer):
63+
with pytest.raises(ImportError, match="pip install pygwalker\\[export\\]"):
64+
dsl_to_workflow({})
65+
66+
67+
def test_runtime_initialized_only_once():
68+
_reset_runtime()
69+
original = mod._make_js_callable
70+
call_count = [0]
71+
72+
def counting_make(*args, **kwargs):
73+
call_count[0] += 1
74+
return original(*args, **kwargs)
75+
76+
with mock.patch.object(mod, '_make_js_callable', side_effect=counting_make):
77+
dsl_to_workflow({})
78+
dsl_to_workflow({})
79+
# _make_js_callable is called twice during init (once per UMD file), but only on first call
80+
assert call_count[0] == 2

0 commit comments

Comments
 (0)