diff --git a/claude/scripts/build_distributions.py b/claude/scripts/build_distributions.py
index fc7cfab..fff7c4d 100644
--- a/claude/scripts/build_distributions.py
+++ b/claude/scripts/build_distributions.py
@@ -121,9 +121,7 @@ def _build_cowork_plugin() -> Path:
plugin = json.loads(plugin_json_path.read_text(encoding="utf-8"))
if not plugin["description"].endswith(COWORK_DESCRIPTION_SUFFIX):
plugin["description"] += COWORK_DESCRIPTION_SUFFIX
- plugin_json_path.write_text(
- json.dumps(plugin, indent=2) + "\n", encoding="utf-8"
- )
+ plugin_json_path.write_text(json.dumps(plugin, indent=2) + "\n", encoding="utf-8")
out = DIST / "pywry-cowork.plugin"
_zip_directory(workdir, out)
@@ -140,9 +138,7 @@ def _build_desktop_extension() -> Path:
def _summarize(path: Path) -> None:
size = path.stat().st_size
if size > SIZE_LIMIT_BYTES:
- raise RuntimeError(
- f"{path.name} is {size:,} bytes — exceeds the 50 MB limit"
- )
+ raise RuntimeError(f"{path.name} is {size:,} bytes — exceeds the 50 MB limit")
with zipfile.ZipFile(path) as zf:
files = zf.namelist()
rel = path.relative_to(REPO_ROOT)
diff --git a/pywry/pyproject.toml b/pywry/pyproject.toml
index 5860c74..016007f 100644
--- a/pywry/pyproject.toml
+++ b/pywry/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "pywry"
-version = "2.0.2"
+version = "2.0.3"
description = "A lightweight and blazingly fast, cross-platform, WebView rendering engine and desktop UI toolkit for Python. Batteries included."
authors = [{ name = "PyWry", email = "pywry2@gmail.com" }]
license = { text = "Apache 2.0" }
diff --git a/pywry/pywry/inline.py b/pywry/pywry/inline.py
index ecf3e28..4cbaf8f 100644
--- a/pywry/pywry/inline.py
+++ b/pywry/pywry/inline.py
@@ -41,6 +41,7 @@
_UNSET,
_Unset,
)
+from .tvchart.mixin import TVChartStateMixin
from .toolbar import Toolbar, get_toolbar_script, wrap_content_with_toolbars
from .widget_protocol import BaseWidget # noqa: TC001
@@ -1667,7 +1668,7 @@ async def get_widget_html_async(widget_id: str) -> str | None:
return await _state.get_widget_html_async(widget_id)
-class InlineWidget(GridStateMixin, PlotlyStateMixin, ToolbarStateMixin):
+class InlineWidget(GridStateMixin, PlotlyStateMixin, TVChartStateMixin, ToolbarStateMixin):
"""Base inline widget that renders via FastAPI server and IFrame.
Implements BaseWidget protocol for unified API across rendering backends.
@@ -3773,6 +3774,186 @@ def _preload_chart_data(user_id: str = "default") -> dict[str, str]:
return preload
+def generate_tvchart_html(
+ chart_html: str,
+ config_payload: str,
+ chart_id: str,
+ widget_id: str,
+ title: str = "Chart",
+ theme: ThemeLiteral | None = None,
+ toolbars: list[dict[str, Any] | Toolbar] | None = None,
+ modals: list[dict[str, Any] | Modal] | None = None,
+ inline_css: str = "",
+ full_document: bool = True,
+ token: str | None = None,
+) -> str:
+ """Generate HTML for a TradingView Lightweight Chart.
+
+ Parameters
+ ----------
+ chart_html : str
+ The chart container ``
`` (and any toolbar/modal markup).
+ config_payload : str
+ JSON string with ``chartOptions``, ``series``, ``storage``, etc.
+ chart_id : str
+ DOM id of the chart container element.
+ widget_id : str
+ Unique widget identifier (used by the pywry bridge).
+ title : str
+ Page title.
+ theme : 'dark' or 'light', optional
+ Color theme.
+ toolbars : list, optional
+ Toolbar configurations.
+ modals : list, optional
+ Modal configurations.
+ inline_css : str
+ Extra CSS to inject.
+ full_document : bool
+ If True, return complete HTML document; if False, content fragment only.
+ token : str or None
+ Widget auth token for the pywry bridge.
+
+ Returns
+ -------
+ str
+ """
+ from .assets import (
+ get_pywry_css,
+ get_scrollbar_js,
+ get_toast_css,
+ get_tvchart_defaults_js,
+ get_tvchart_js,
+ )
+ from .modal import wrap_content_with_modals
+ from .notebook import _wrap_content_with_toolbars
+
+ if theme is None:
+ theme = _get_default_theme()
+
+ tvchart_js = get_tvchart_js()
+ tvchart_script = f"" if tvchart_js else ""
+ tvchart_defaults = get_tvchart_defaults_js()
+ tvchart_defaults_script = f"" if tvchart_defaults else ""
+
+ # Chart init script — waits for LightweightCharts then renders
+ chart_init_script = f""""""
+
+ if not full_document:
+ # Content fragment for anywidget — caller handles wrapping
+ wrapped = _wrap_content_with_toolbars(chart_html, toolbars)
+ if modals:
+ modal_html, modal_scripts = wrap_content_with_modals("", modals)
+ wrapped = f"{wrapped}{modal_html}{modal_scripts}"
+ return f"{wrapped}\n{chart_init_script}"
+
+ # Full document for IFrame / browser mode
+ pywry_css = get_pywry_css()
+ pywry_style = f"" if pywry_css else ""
+ toast_css = get_toast_css()
+ toast_style = f"" if toast_css else ""
+ scrollbar_js = get_scrollbar_js()
+ scrollbar_script = f"" if scrollbar_js else ""
+ inline_style = f"" if inline_css else ""
+
+ if theme == "dark":
+ widget_theme_class = "pywry-theme-dark"
+ elif theme == "system":
+ widget_theme_class = "pywry-theme-system"
+ else:
+ widget_theme_class = "pywry-theme-light"
+
+ # Build widget content with toolbars
+ widget_content = wrap_content_with_toolbars(chart_html, toolbars)
+
+ # Inject modals
+ modal_block = ""
+ if modals:
+ modal_html, modal_scripts = wrap_content_with_modals("", modals)
+ modal_block = f"{modal_html}{modal_scripts}"
+
+ return f"""
+
+
+
+
{title}
+ {tvchart_script}
+ {tvchart_defaults_script}
+ {pywry_style}
+ {toast_style}
+ {inline_style}
+ {scrollbar_script}
+
+
+
+
+ {widget_content}
+
+ {modal_block}
+ {_get_pywry_bridge_js(widget_id, token)}
+ {chart_init_script}
+
+"""
+
+
def show_tvchart(
data: Any = None,
callbacks: dict[str, Callable[..., Any]] | None = None,
@@ -3845,10 +4026,8 @@ def show_tvchart(
import json as _json
import uuid as _uuid
- from .modal import wrap_content_with_modals
- from .notebook import _wrap_content_with_toolbars
+ from .notebook import create_tvchart_widget
from .runtime import is_headless
- from .widget import HAS_ANYWIDGET, PyWryTVChartWidget
if theme is None:
theme = _get_default_theme()
@@ -3930,25 +4109,21 @@ def show_tvchart(
chart_html = f'
'
- # Inject toolbars
- chart_html = _wrap_content_with_toolbars(chart_html, toolbars)
-
- # Inject modals
- if modals:
- modal_html, modal_scripts = wrap_content_with_modals("", modals)
- chart_html = f"{chart_html}{modal_html}{modal_scripts}"
-
- if HAS_ANYWIDGET and not open_browser and not is_headless():
- widget = PyWryTVChartWidget(
- content=chart_html,
- chart_config=config_payload,
- theme=theme,
- width=width,
- height=f"{height}px",
- chart_id=chart_id,
- )
- else:
- widget = PyWryTVChartWidget(content=chart_html)
+ # Create widget using auto-backend selection
+ # Force InlineWidget (IFrame) for BROWSER mode since it has open_in_browser()
+ widget = create_tvchart_widget(
+ chart_html=chart_html,
+ config_payload=config_payload,
+ chart_id=chart_id,
+ widget_id=widget_id,
+ title=title,
+ theme=theme,
+ width=width,
+ height=height,
+ toolbars=toolbars,
+ modals=modals,
+ force_iframe=open_browser,
+ )
if callbacks:
for event_type, callback in callbacks.items():
@@ -3960,14 +4135,17 @@ def show_tvchart(
wire_storage(user_id="default")
if provider is not None:
- widget._wire_datafeed_provider(provider)
+ wire_datafeed = getattr(widget, "_wire_datafeed_provider", None)
+ if callable(wire_datafeed):
+ wire_datafeed(provider)
+ # Display
if is_headless():
pass
+ elif open_browser:
+ open_fn = getattr(widget, "open_in_browser", None)
+ if callable(open_fn):
+ open_fn()
else:
- open_in_browser = getattr(widget, "open_in_browser", None)
- if open_browser and callable(open_in_browser):
- open_in_browser()
- else:
- widget.display()
+ widget.display()
return widget
diff --git a/pywry/pywry/notebook.py b/pywry/pywry/notebook.py
index b1f7368..aa9ccf7 100644
--- a/pywry/pywry/notebook.py
+++ b/pywry/pywry/notebook.py
@@ -616,3 +616,111 @@ def create_dataframe_widget(
widget.on("grid:export-csv", _make_grid_export_handler(widget))
return widget
+
+
+def create_tvchart_widget(
+ chart_html: str,
+ config_payload: str,
+ chart_id: str,
+ widget_id: str,
+ title: str = "Chart",
+ theme: Literal["dark", "light", "system"] = "dark",
+ width: str = "100%",
+ height: int = 500,
+ toolbars: list[Any] | None = None,
+ modals: list[Any] | None = None,
+ inline_css: str = "",
+ port: int | None = None,
+ force_iframe: bool = False,
+) -> Any:
+ """Create a TVChart widget using the best available backend.
+
+ Automatically selects:
+ 1. PyWryTVChartWidget (anywidget) if available - best performance
+ 2. InlineWidget (FastAPI) as fallback - broader compatibility
+
+ Parameters
+ ----------
+ chart_html : str
+ Chart container ``
`` HTML.
+ config_payload : str
+ JSON string with chart configuration.
+ chart_id : str
+ DOM id of the chart container element.
+ widget_id : str
+ Unique widget identifier.
+ title : str
+ Widget title.
+ theme : str
+ 'dark', 'light', or 'system'.
+ width : str
+ Widget width (CSS).
+ height : int
+ Widget height in pixels.
+ toolbars : list, optional
+ Toolbar configurations.
+ modals : list, optional
+ Modal configurations.
+ inline_css : str
+ Extra CSS to inject.
+ port : int, optional
+ Server port (only for InlineWidget fallback).
+ force_iframe : bool, optional
+ If True, force use of InlineWidget instead of anywidget.
+ Required for BROWSER mode which needs open_in_browser() method.
+ Default: False.
+
+ Returns
+ -------
+ BaseWidget
+ Widget instance implementing BaseWidget protocol.
+ """
+ from . import inline
+ from .modal import wrap_content_with_modals
+ from .runtime import is_headless
+ from .widget import HAS_ANYWIDGET
+
+ use_anywidget = HAS_ANYWIDGET and not force_iframe and not is_headless()
+ if use_anywidget:
+ from .widget import PyWryTVChartWidget
+
+ content = _wrap_content_with_toolbars(chart_html, toolbars)
+ if modals:
+ modal_html, modal_scripts = wrap_content_with_modals("", modals)
+ content = f"{content}{modal_html}{modal_scripts}"
+
+ return PyWryTVChartWidget(
+ content=content,
+ chart_config=config_payload,
+ theme=theme,
+ width=width,
+ height=f"{height}px" if isinstance(height, int) else height,
+ chart_id=chart_id,
+ )
+
+ # Fallback to InlineWidget (FastAPI server)
+ widget_token = inline._generate_widget_token(widget_id)
+
+ html = inline.generate_tvchart_html(
+ chart_html=chart_html,
+ config_payload=config_payload,
+ chart_id=chart_id,
+ widget_id=widget_id,
+ title=title,
+ theme=theme,
+ toolbars=toolbars,
+ modals=modals,
+ inline_css=inline_css,
+ full_document=True,
+ token=widget_token,
+ )
+
+ return inline.InlineWidget(
+ html=html,
+ width=width,
+ height=height,
+ port=port or 8765,
+ widget_id=widget_id,
+ browser_only=force_iframe,
+ token=widget_token,
+ )