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, + )