openbb-agent-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. openbb_agent_server/__init__.py +17 -0
  2. openbb_agent_server/_vendor/__init__.py +0 -0
  3. openbb_agent_server/_vendor/sqlitevec.py +135 -0
  4. openbb_agent_server/acp/__init__.py +32 -0
  5. openbb_agent_server/acp/canvas.py +1056 -0
  6. openbb_agent_server/acp/canvas_app.py +480 -0
  7. openbb_agent_server/acp/provider.py +580 -0
  8. openbb_agent_server/app/__init__.py +1 -0
  9. openbb_agent_server/app/app.py +299 -0
  10. openbb_agent_server/app/config.py +754 -0
  11. openbb_agent_server/app/router.py +1680 -0
  12. openbb_agent_server/app/settings.py +779 -0
  13. openbb_agent_server/main.py +396 -0
  14. openbb_agent_server/memory/__init__.py +1 -0
  15. openbb_agent_server/memory/classifier.py +201 -0
  16. openbb_agent_server/memory/embeddings.py +118 -0
  17. openbb_agent_server/memory/factory.py +226 -0
  18. openbb_agent_server/memory/ingestion.py +424 -0
  19. openbb_agent_server/memory/reranker.py +165 -0
  20. openbb_agent_server/memory/retrievers.py +177 -0
  21. openbb_agent_server/memory/sqlite_store.py +593 -0
  22. openbb_agent_server/memory/store.py +196 -0
  23. openbb_agent_server/memory/translation.py +161 -0
  24. openbb_agent_server/memory/writer.py +163 -0
  25. openbb_agent_server/observability/__init__.py +1 -0
  26. openbb_agent_server/observability/logging.py +272 -0
  27. openbb_agent_server/openbb.toml.example +332 -0
  28. openbb_agent_server/persistence/__init__.py +1 -0
  29. openbb_agent_server/persistence/models.py +650 -0
  30. openbb_agent_server/persistence/prune.py +297 -0
  31. openbb_agent_server/persistence/sqlite_store.py +857 -0
  32. openbb_agent_server/persistence/store.py +444 -0
  33. openbb_agent_server/plugins/__init__.py +1 -0
  34. openbb_agent_server/plugins/auth/__init__.py +1 -0
  35. openbb_agent_server/plugins/auth/api_key_table.py +303 -0
  36. openbb_agent_server/plugins/auth/bearer_static.py +107 -0
  37. openbb_agent_server/plugins/auth/none.py +60 -0
  38. openbb_agent_server/plugins/auth/oidc_jwt.py +138 -0
  39. openbb_agent_server/plugins/auth/openbb_workspace.py +111 -0
  40. openbb_agent_server/plugins/checkpointers/__init__.py +1 -0
  41. openbb_agent_server/plugins/checkpointers/inmemory.py +61 -0
  42. openbb_agent_server/plugins/checkpointers/postgres.py +148 -0
  43. openbb_agent_server/plugins/checkpointers/sqlite.py +109 -0
  44. openbb_agent_server/plugins/middleware/__init__.py +1 -0
  45. openbb_agent_server/plugins/middleware/call_limit.py +143 -0
  46. openbb_agent_server/plugins/middleware/loop_guard.py +152 -0
  47. openbb_agent_server/plugins/middleware/tool_call_announcer.py +120 -0
  48. openbb_agent_server/plugins/middleware/tool_call_ledger.py +147 -0
  49. openbb_agent_server/plugins/middleware/tool_filter.py +117 -0
  50. openbb_agent_server/plugins/middleware/tool_message_normaliser.py +336 -0
  51. openbb_agent_server/plugins/middleware/usage_recorder.py +95 -0
  52. openbb_agent_server/plugins/models/__init__.py +1 -0
  53. openbb_agent_server/plugins/models/_validation.py +55 -0
  54. openbb_agent_server/plugins/models/anthropic_provider.py +158 -0
  55. openbb_agent_server/plugins/models/bedrock_provider.py +182 -0
  56. openbb_agent_server/plugins/models/fake_provider.py +138 -0
  57. openbb_agent_server/plugins/models/google_genai_provider.py +226 -0
  58. openbb_agent_server/plugins/models/groq_provider.py +248 -0
  59. openbb_agent_server/plugins/models/groq_rate_limiter.py +447 -0
  60. openbb_agent_server/plugins/models/nvidia_provider.py +241 -0
  61. openbb_agent_server/plugins/models/openai_compat_provider.py +266 -0
  62. openbb_agent_server/plugins/models/openai_provider.py +198 -0
  63. openbb_agent_server/plugins/models/vertex_provider.py +221 -0
  64. openbb_agent_server/plugins/subagents/__init__.py +1 -0
  65. openbb_agent_server/plugins/subagents/analyst.py +50 -0
  66. openbb_agent_server/plugins/subagents/charter.py +51 -0
  67. openbb_agent_server/plugins/subagents/pdf_reader.py +57 -0
  68. openbb_agent_server/plugins/subagents/researcher.py +69 -0
  69. openbb_agent_server/plugins/tools/__init__.py +1 -0
  70. openbb_agent_server/plugins/tools/_media.py +470 -0
  71. openbb_agent_server/plugins/tools/artifacts.py +328 -0
  72. openbb_agent_server/plugins/tools/background_jobs.py +158 -0
  73. openbb_agent_server/plugins/tools/client_side.py +115 -0
  74. openbb_agent_server/plugins/tools/dashboard.py +108 -0
  75. openbb_agent_server/plugins/tools/fetch_url.py +294 -0
  76. openbb_agent_server/plugins/tools/gemini_embeddings.py +230 -0
  77. openbb_agent_server/plugins/tools/gemini_image.py +621 -0
  78. openbb_agent_server/plugins/tools/gemma_audio.py +461 -0
  79. openbb_agent_server/plugins/tools/groq_audio.py +404 -0
  80. openbb_agent_server/plugins/tools/inspect_widget_data.py +389 -0
  81. openbb_agent_server/plugins/tools/mcp_http.py +230 -0
  82. openbb_agent_server/plugins/tools/mcp_local.py +194 -0
  83. openbb_agent_server/plugins/tools/memory_recall.py +95 -0
  84. openbb_agent_server/plugins/tools/paligemma_vision.py +468 -0
  85. openbb_agent_server/plugins/tools/pdf_extract.py +1089 -0
  86. openbb_agent_server/plugins/tools/python_module.py +93 -0
  87. openbb_agent_server/plugins/tools/pywry_canvas.py +1730 -0
  88. openbb_agent_server/plugins/tools/rerank.py +160 -0
  89. openbb_agent_server/plugins/tools/translate.py +138 -0
  90. openbb_agent_server/plugins/tools/vision_qa.py +380 -0
  91. openbb_agent_server/plugins/tools/web_search.py +177 -0
  92. openbb_agent_server/plugins/tools/widget_data.py +194 -0
  93. openbb_agent_server/plugins/tools/workspace_mcp.py +84 -0
  94. openbb_agent_server/prompts/__init__.py +12 -0
  95. openbb_agent_server/prompts/default_system_prompt.md +412 -0
  96. openbb_agent_server/protocol/__init__.py +1 -0
  97. openbb_agent_server/protocol/adapter.py +771 -0
  98. openbb_agent_server/protocol/schemas.py +556 -0
  99. openbb_agent_server/protocol/sse.py +60 -0
  100. openbb_agent_server/py.typed +0 -0
  101. openbb_agent_server/runtime/__init__.py +1 -0
  102. openbb_agent_server/runtime/builder.py +987 -0
  103. openbb_agent_server/runtime/canvas.py +234 -0
  104. openbb_agent_server/runtime/context.py +216 -0
  105. openbb_agent_server/runtime/embedded.py +384 -0
  106. openbb_agent_server/runtime/emit.py +568 -0
  107. openbb_agent_server/runtime/identity.py +118 -0
  108. openbb_agent_server/runtime/jobs.py +386 -0
  109. openbb_agent_server/runtime/pdf_store.py +715 -0
  110. openbb_agent_server/runtime/plugins.py +190 -0
  111. openbb_agent_server/runtime/principal.py +54 -0
  112. openbb_agent_server/runtime/registry.py +107 -0
  113. openbb_agent_server/runtime/services.py +169 -0
  114. openbb_agent_server/runtime/widget_store.py +707 -0
  115. openbb_agent_server-0.1.0.dist-info/METADATA +137 -0
  116. openbb_agent_server-0.1.0.dist-info/RECORD +118 -0
  117. openbb_agent_server-0.1.0.dist-info/WHEEL +4 -0
  118. openbb_agent_server-0.1.0.dist-info/entry_points.txt +67 -0
@@ -0,0 +1,1056 @@
1
+ """The PyWry live canvas — the window's main content page.
2
+
3
+ ``PyWryCanvas`` implements the
4
+ :class:`~openbb_agent_server.runtime.canvas.LiveCanvas` protocol over a
5
+ PyWry widget handle. It only needs the handle's ``emit(type, data)``
6
+ method (duck-typed), so this module imports nothing from pywry and is
7
+ unit-testable everywhere; the pywry-specific wiring lives in
8
+ :mod:`openbb_agent_server.acp.canvas_app`.
9
+
10
+ Cross-path rendering contract
11
+ -----------------------------
12
+
13
+ PyWry has three rendering paths — native window, anywidget/notebook,
14
+ and the inline browser iframe — and they do NOT share the same update
15
+ surface: ``eval_js`` and the ``pywry:set-content`` handler exist only
16
+ on the native path, and ``<script>`` tags inside content rendered via
17
+ ``innerHTML`` are inert on the anywidget path. The one channel uniform
18
+ across all three is ``handle.emit(type, data)`` delivered to page
19
+ JavaScript through ``window.pywry.on(type, cb)``.
20
+
21
+ The canvas therefore mirrors the pattern PyWry's own ``ChatManager``
22
+ uses:
23
+
24
+ * All updates are plain events (``obb-canvas:*``) with JSON payloads —
25
+ no ``eval_js`` anywhere.
26
+ * The page-side handlers live in :data:`CANVAS_BOOTSTRAP_JS`. It is
27
+ embedded as a ``<script>`` tag by :func:`build_canvas_html` (executes
28
+ on the native + inline paths, where initial content scripts run) and
29
+ pushed through the anywidget ``_asset_js`` trait when the handle
30
+ exposes ``set_trait`` (where initial-content scripts are inert). The
31
+ bootstrap is idempotent, so double delivery is harmless.
32
+ * Plotly assets load lazily through an ``obb-canvas:load-assets`` event
33
+ whose payload the page-side handler injects as a ``<script>`` element
34
+ — DOM-appended scripts execute on every path.
35
+ * HTML, tables, and images render via ``innerHTML`` replacement inside
36
+ the page handler, so ``<script>`` tags in agent-supplied markup do
37
+ not execute on any path.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import html as html_mod
43
+ import json
44
+ import logging
45
+ from collections.abc import Callable
46
+ from typing import Any
47
+
48
+ logger = logging.getLogger("openbb_agent_server.acp.canvas")
49
+
50
+ CANVAS_ELEMENT_ID = "openbb-canvas"
51
+ # Chart + container id for the canvas's TVChart engine chart. All
52
+ # ``tvchart:*`` protocol events address it via ``chartId``.
53
+ TVCHART_CHART_ID = "openbb-canvas-tvchart"
54
+ _MAX_TABLE_ROWS = 2000
55
+
56
+ _EMPTY_STATE = (
57
+ '<div class="obb-canvas-empty">'
58
+ "<h2>Canvas</h2>"
59
+ "<p>Ask the agent to chart, tabulate, or lay out anything here.</p>"
60
+ "</div>"
61
+ )
62
+
63
+ # Page-side event handlers — the single rendering implementation every
64
+ # PyWry path shares. Registered against ``window.pywry.on`` with a
65
+ # short retry loop so it works whether the bridge or this script loads
66
+ # first. ``window.__obbCanvas`` is the idempotency marker: the script
67
+ # may arrive twice (page HTML + anywidget asset trait).
68
+ CANVAS_BOOTSTRAP_JS = """
69
+ (() => {
70
+ if (window.__obbCanvas) { return; }
71
+ const state = { ready: false };
72
+ window.__obbCanvas = state;
73
+ const byId = (id) => document.getElementById(id || 'openbb-canvas');
74
+ const heading = (title) => {
75
+ if (!title) { return null; }
76
+ const h = document.createElement('h2');
77
+ h.className = 'obb-canvas-title';
78
+ h.textContent = title;
79
+ return h;
80
+ };
81
+ const setHtml = (d) => {
82
+ const el = byId(d.id);
83
+ if (!el) { return; }
84
+ el.innerHTML = d.html || '';
85
+ // Wire any pywry toolbar markup (tooltips, icon buttons, dropdowns)
86
+ // the same way the widget renderers do after innerHTML updates.
87
+ if (typeof window.initToolbarHandlers === 'function') {
88
+ try { window.initToolbarHandlers(el, window.pywry); } catch (e) {}
89
+ }
90
+ };
91
+ const renderMarkdown = (d) => {
92
+ const el = byId(d.id);
93
+ if (!el) { return; }
94
+ el.replaceChildren();
95
+ const h = heading(d.title);
96
+ if (h) { el.appendChild(h); }
97
+ const box = document.createElement('div');
98
+ box.className = 'obb-canvas-md';
99
+ if (window.marked && typeof window.marked.parse === 'function') {
100
+ box.innerHTML = window.marked.parse(d.text || '');
101
+ } else {
102
+ const pre = document.createElement('pre');
103
+ pre.style.whiteSpace = 'pre-wrap';
104
+ pre.textContent = d.text || '';
105
+ box.appendChild(pre);
106
+ }
107
+ el.appendChild(box);
108
+ };
109
+ const renderPlotly = (d) => {
110
+ const el = byId(d.id);
111
+ if (!el) { return; }
112
+ el.replaceChildren();
113
+ const h = heading(d.title);
114
+ if (h) { el.appendChild(h); }
115
+ const plot = document.createElement('div');
116
+ plot.style.cssText = 'width:100%;height:100%;min-height:420px;';
117
+ el.appendChild(plot);
118
+ const fig = d.figure || {};
119
+ if (window.Plotly) {
120
+ window.Plotly.newPlot(
121
+ plot,
122
+ fig.data || [],
123
+ fig.layout || {},
124
+ Object.assign({ responsive: true }, fig.config || {})
125
+ );
126
+ } else {
127
+ plot.innerHTML = '<pre>Plotly.js is not loaded in this window.</pre>';
128
+ }
129
+ };
130
+ const loadAssets = (d) => {
131
+ (d.scripts || []).forEach((src) => {
132
+ const s = document.createElement('script');
133
+ s.textContent = src;
134
+ document.head.appendChild(s);
135
+ });
136
+ };
137
+ // Bridge the inline/browser ws-bridge gap. The tvchart engine's
138
+ // client-side toolbar controls (chart type, drawing tools, indicators
139
+ // panel, settings, undo/redo, screenshot, fullscreen, interval
140
+ // dropdown, ...) register handlers via ``pywry.on`` and fire them by
141
+ // calling ``pywry.emit``. The native bridge dispatches emit BOTH to
142
+ // the host and to local handlers; the ws-bridge (browser iframe) only
143
+ // sends to the server, so those controls would never reach their
144
+ // local handlers and would appear dead. Mirror the native bridge:
145
+ // when emit fires an event that has a local handler, also dispatch it
146
+ // locally. Guarded to the ws-bridge shape (``_fire`` + ``_handlers``)
147
+ // and applied once, so the native/anywidget paths are untouched. We
148
+ // only local-dispatch when a handler is registered, so outbound-only
149
+ // events (tvchart:data-request, notifications) are not queued in the
150
+ // bridge's ``_pending`` buffer.
151
+ const patchEmit = () => {
152
+ const b = window.pywry;
153
+ if (!b || b.__obbDualEmit) { return; }
154
+ if (typeof b.emit !== 'function' || typeof b._fire !== 'function' || !b._handlers) {
155
+ return;
156
+ }
157
+ const orig = b.emit.bind(b);
158
+ b.emit = function(type, data) {
159
+ orig(type, data);
160
+ if ((b._handlers[type] || []).length > 0) {
161
+ try { b._fire(type, data); } catch (e) {}
162
+ }
163
+ };
164
+ b.__obbDualEmit = true;
165
+ };
166
+ const wire = () => {
167
+ if (!window.pywry || typeof window.pywry.on !== 'function') { return false; }
168
+ patchEmit();
169
+ window.pywry.on('obb-canvas:set-html', setHtml);
170
+ window.pywry.on('obb-canvas:markdown', renderMarkdown);
171
+ window.pywry.on('obb-canvas:plotly', renderPlotly);
172
+ window.pywry.on('obb-canvas:load-assets', loadAssets);
173
+ state.ready = true;
174
+ return true;
175
+ };
176
+ if (!wire()) {
177
+ let tries = 0;
178
+ const timer = setInterval(() => {
179
+ tries += 1;
180
+ if (wire() || tries > 100) { clearInterval(timer); }
181
+ }, 50);
182
+ }
183
+ })();
184
+ """
185
+
186
+
187
+ def build_canvas_html(
188
+ *,
189
+ heading: str = "OpenBB Agent",
190
+ subtitle: str = "",
191
+ ) -> str:
192
+ """Build the main content page the chat toolbar attaches to.
193
+
194
+ A header strip, the ``#openbb-canvas`` container the agent draws
195
+ into via the ``pywry_canvas`` tools, and the canvas bootstrap
196
+ script (which executes on the native + inline paths; the anywidget
197
+ path receives the same script through the ``_asset_js`` trait).
198
+
199
+ Parameters
200
+ ----------
201
+ heading : str
202
+ Bold title shown in the header strip; HTML-escaped before use.
203
+ subtitle : str
204
+ Optional dimmed text rendered beside the heading; HTML-escaped
205
+ and omitted entirely when empty.
206
+
207
+ Returns
208
+ -------
209
+ str
210
+ A full HTML fragment: the canvas root with header, the
211
+ ``#openbb-canvas`` main container seeded with the empty state,
212
+ and the bootstrap ``<script>`` tag.
213
+ """
214
+ sub = (
215
+ f'<span class="obb-canvas-subtitle">{html_mod.escape(subtitle)}</span>'
216
+ if subtitle
217
+ else ""
218
+ )
219
+ return f"""
220
+ <div class="obb-canvas-root">
221
+ <header class="obb-canvas-header">
222
+ <strong>{html_mod.escape(heading)}</strong>
223
+ {sub}
224
+ </header>
225
+ <main id="{CANVAS_ELEMENT_ID}" class="obb-canvas-main">
226
+ {_EMPTY_STATE}
227
+ </main>
228
+ </div>
229
+ <script>{CANVAS_BOOTSTRAP_JS}</script>
230
+ """
231
+
232
+
233
+ CANVAS_CSS = """
234
+ .obb-canvas-root { display: flex; flex-direction: column; height: 100vh; }
235
+ .obb-canvas-header {
236
+ display: flex; align-items: baseline; gap: 12px;
237
+ padding: 10px 16px; border-bottom: 1px solid rgba(128,128,128,0.25);
238
+ flex: 0 0 auto;
239
+ }
240
+ .obb-canvas-subtitle { opacity: 0.65; font-size: 0.85em; }
241
+ .obb-canvas-main { flex: 1 1 auto; overflow: auto; padding: 16px; }
242
+ .obb-canvas-empty { opacity: 0.55; text-align: center; margin-top: 18vh; }
243
+ .obb-canvas-title { margin: 0 0 12px 0; }
244
+ .obb-canvas-table { border-collapse: collapse; width: 100%; }
245
+ .obb-canvas-table th, .obb-canvas-table td {
246
+ border: 1px solid rgba(128,128,128,0.3);
247
+ padding: 6px 10px; text-align: left; font-size: 0.9em;
248
+ }
249
+ .obb-canvas-table th { position: sticky; top: 0; }
250
+ .obb-canvas-img { max-width: 100%; height: auto; }
251
+ .obb-canvas-doc { width: 100%; height: 80vh; border: 0; }
252
+ .obb-canvas-audio, .obb-canvas-video { max-width: 100%; }
253
+ .obb-canvas-text {
254
+ white-space: pre-wrap; word-break: break-word;
255
+ font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.9em;
256
+ }
257
+ .obb-canvas-download {
258
+ display: inline-block; padding: 8px 14px; border-radius: 6px;
259
+ border: 1px solid rgba(128,128,128,0.4); text-decoration: none;
260
+ font-weight: 600; margin-bottom: 12px;
261
+ }
262
+ .obb-canvas-note { opacity: 0.65; font-size: 0.85em; }
263
+ .obb-canvas-tvchart-frame {
264
+ position: relative; display: flex; flex-direction: column;
265
+ min-height: 0; overflow: hidden;
266
+ }
267
+ .obb-canvas-tvchart-frame > * { flex: 1 1 auto; min-height: 0; }
268
+ """
269
+
270
+
271
+ def _section(html: str, title: str | None) -> str:
272
+ """Prefix content with an escaped heading when a title is given.
273
+
274
+ Parameters
275
+ ----------
276
+ html : str
277
+ The content fragment the heading is prepended to.
278
+ title : str or None
279
+ Section title; HTML-escaped and wrapped in an ``<h2>``. When
280
+ falsy, ``html`` is returned unchanged.
281
+
282
+ Returns
283
+ -------
284
+ str
285
+ ``html`` optionally preceded by an ``<h2 class="obb-canvas-title">``
286
+ heading.
287
+ """
288
+ if not title:
289
+ return html
290
+ return f'<h2 class="obb-canvas-title">{html_mod.escape(title)}</h2>{html}'
291
+
292
+
293
+ def render_table_html(
294
+ rows: list[dict[str, Any]],
295
+ *,
296
+ columns: list[str] | None = None,
297
+ ) -> str:
298
+ """Render rows as an escaped HTML table (capped at ``_MAX_TABLE_ROWS``).
299
+
300
+ Every header and cell value is HTML-escaped; ``dict``/``list`` cell
301
+ values are JSON-encoded and ``None`` becomes an empty cell. When more
302
+ rows are supplied than the cap allows, only the first
303
+ ``_MAX_TABLE_ROWS`` are emitted and a note records the totals.
304
+
305
+ Parameters
306
+ ----------
307
+ rows : list of dict
308
+ Row records keyed by column name. When ``columns`` is omitted,
309
+ the keys of the first row determine the column order.
310
+ columns : list of str, optional
311
+ Explicit column order. Cells absent from a row render empty.
312
+
313
+ Returns
314
+ -------
315
+ str
316
+ An ``<table class="obb-canvas-table">`` fragment, followed by a
317
+ truncation note when rows were dropped.
318
+ """
319
+ cols = columns or (list(rows[0].keys()) if rows else [])
320
+ shown = rows[:_MAX_TABLE_ROWS]
321
+ head = "".join(f"<th>{html_mod.escape(str(c))}</th>" for c in cols)
322
+ body_rows: list[str] = []
323
+ for row in shown:
324
+ cells = "".join(
325
+ f"<td>{html_mod.escape(_cell_text(row.get(c)))}</td>" for c in cols
326
+ )
327
+ body_rows.append(f"<tr>{cells}</tr>")
328
+ note = (
329
+ f'<p class="obb-canvas-note">Showing {len(shown):,} of {len(rows):,} rows.</p>'
330
+ if len(rows) > len(shown)
331
+ else ""
332
+ )
333
+ return (
334
+ f'<table class="obb-canvas-table"><thead><tr>{head}</tr></thead>'
335
+ f"<tbody>{''.join(body_rows)}</tbody></table>{note}"
336
+ )
337
+
338
+
339
+ def _cell_text(value: Any) -> str:
340
+ if value is None:
341
+ return ""
342
+ if isinstance(value, (dict, list)):
343
+ return json.dumps(value, default=str)
344
+ return str(value)
345
+
346
+
347
+ def _looks_like_datafeed_provider(obj: Any) -> bool:
348
+ """Tell a provider INSTANCE from a zero-arg factory/class.
349
+
350
+ A pywry ``DatafeedProvider`` instance exposes ``get_bars`` and is not
351
+ a class; a factory is a plain callable (function or provider class)
352
+ that must be invoked to produce the instance.
353
+ """
354
+ return hasattr(obj, "get_bars") and not isinstance(obj, type)
355
+
356
+
357
+ def _doc_kind_for_mime(mime: str | None) -> str:
358
+ """Map a MIME type to the canvas render family used by ``show_document``.
359
+
360
+ ``image`` / ``pdf`` / ``audio`` / ``video`` / ``text`` get a native
361
+ HTML element; everything else (Office docs, archives, unknown binary)
362
+ falls back to a ``download`` link.
363
+ """
364
+ m = (mime or "").split(";", 1)[0].strip().lower()
365
+ if m.startswith("image/"):
366
+ return "image"
367
+ if m == "application/pdf":
368
+ return "pdf"
369
+ if m.startswith("audio/"):
370
+ return "audio"
371
+ if m.startswith("video/"):
372
+ return "video"
373
+ if m in ("text/plain", "application/json"):
374
+ return "text"
375
+ return "download"
376
+
377
+
378
+ def _embed_html(
379
+ src: str,
380
+ *,
381
+ kind: str,
382
+ filename: str | None = None,
383
+ text: str | None = None,
384
+ ) -> str:
385
+ """Build the escaped HTML element for a ``show_document`` render.
386
+
387
+ ``src`` is a ready-to-use URL or ``data:`` URI; it is escaped for the
388
+ HTML attribute context. ``text`` (when given) is escaped for text
389
+ context. ``<script>`` never appears, and PDFs use an ``<iframe>``
390
+ (``data:`` in ``<embed>`` is blocked by some webviews) so the doc
391
+ renders uniformly on every path.
392
+ """
393
+ esc = html_mod.escape(src, quote=True)
394
+ if kind == "image":
395
+ return f'<img class="obb-canvas-img" src="{esc}">'
396
+ if kind == "pdf":
397
+ name = html_mod.escape(filename or "document.pdf", quote=True)
398
+ return f'<iframe class="obb-canvas-doc" src="{esc}" title="{name}"></iframe>'
399
+ if kind == "audio":
400
+ return f'<audio class="obb-canvas-audio" controls src="{esc}"></audio>'
401
+ if kind == "video":
402
+ return f'<video class="obb-canvas-video" controls src="{esc}"></video>'
403
+ if kind == "text":
404
+ return f'<pre class="obb-canvas-text">{html_mod.escape(text or "")}</pre>'
405
+ # download fallback — a link plus any extracted text we were handed.
406
+ name = html_mod.escape(filename or "file", quote=True)
407
+ link = (
408
+ f'<a class="obb-canvas-download" href="{esc}" download="{name}">'
409
+ f"Download {name}</a>"
410
+ )
411
+ if text:
412
+ link += f'<pre class="obb-canvas-text">{html_mod.escape(text)}</pre>'
413
+ return link
414
+
415
+
416
+ class PyWryCanvas:
417
+ """LiveCanvas over any PyWry widget handle.
418
+
419
+ Every update is a plain ``handle.emit(event, data)`` — the one
420
+ channel that behaves identically on PyWry's native-window,
421
+ anywidget/notebook, and inline-iframe rendering paths. The
422
+ page-side handlers come from :data:`CANVAS_BOOTSTRAP_JS`; on
423
+ handles that expose ``set_trait`` (the anywidget path, where
424
+ initial-content scripts are inert) the constructor delivers the
425
+ bootstrap through the ``_asset_js`` trait as well.
426
+
427
+ Parameters
428
+ ----------
429
+ handle : Any
430
+ The widget handle returned by ``app.show()`` — anything with
431
+ ``emit(event_type, data)``.
432
+ element_id : str
433
+ DOM id of the canvas container in the main page.
434
+ plotly_assets : Callable[[], str] | None
435
+ Returns the plotly.js source to load on first ``show_plotly``
436
+ (sent via ``obb-canvas:load-assets`` and DOM-injected by the
437
+ page handler). ``None`` skips loading — the figure still
438
+ renders if the page already has ``window.Plotly``, and shows a
439
+ notice otherwise.
440
+ tvchart_assets : Callable[[], str] | None
441
+ Same contract for first ``show_tvchart``: the lightweight-
442
+ charts bundle PLUS pywry's tvchart engine modules (which
443
+ self-register the ``tvchart:*`` protocol handlers) PLUS the
444
+ toolbar handlers script.
445
+ tvchart_chrome : Callable[[str, list[str] | None, str | None], str] | None
446
+ ``(container_html, intervals, selected_interval) -> page_html``
447
+ — wraps the chart container with pywry's TVChart toolbar set
448
+ (``build_tvchart_toolbars`` + ``wrap_content_with_toolbars``).
449
+ ``None`` renders the bare chart without chrome.
450
+ tvchart_controller_factory : Callable[[Any, str], Any] | None
451
+ ``(handle, chart_id) -> controller`` — binds pywry's
452
+ ``TVChartStateMixin`` (the full protocol surface) to the canvas
453
+ chart. Exposed via :meth:`tvchart_controller` for the
454
+ ``canvas_tvchart_*`` tools.
455
+ tvchart_datafeed_provider : Any | Callable[[], Any] | None
456
+ A pywry ``DatafeedProvider`` instance — or a zero-arg factory
457
+ returning one — that supplies symbol search, resolution, and
458
+ historical/compare bars for ANY symbol. When present,
459
+ :meth:`show_tvchart_symbol` mounts a datafeed-backed chart
460
+ (``useDatafeed=True``) and the provider is wired to the
461
+ controller via pywry's ``_wire_datafeed_provider``, so the
462
+ header's Symbol Search and Compare controls work end to end
463
+ (``symbol-search → resolve → data-request → data-response``).
464
+ ``None`` leaves the canvas in static-data mode, where charts are
465
+ mounted from caller-supplied bars and have no search/compare
466
+ data source.
467
+ """
468
+
469
+ def __init__(
470
+ self,
471
+ handle: Any,
472
+ *,
473
+ element_id: str = CANVAS_ELEMENT_ID,
474
+ plotly_assets: Callable[[], str] | None = None,
475
+ tvchart_assets: Callable[[], str] | None = None,
476
+ tvchart_chrome: Callable[[str, list[str] | None, str | None], str]
477
+ | None = None,
478
+ tvchart_controller_factory: Callable[[Any, str], Any] | None = None,
479
+ tvchart_datafeed_provider: Any | Callable[[], Any] | None = None,
480
+ ) -> None:
481
+ """Bind the canvas to a PyWry widget handle.
482
+
483
+ See the class docstring for the full parameter reference. The
484
+ bootstrap is delivered immediately (and again via ``_asset_js``
485
+ on handles that expose ``set_trait``).
486
+ """
487
+ self._handle = handle
488
+ self._element_id = element_id
489
+ self._plotly_assets = plotly_assets
490
+ self._plotly_injected = False
491
+ self._tvchart_assets = tvchart_assets
492
+ self._tvchart_injected = False
493
+ self._tvchart_chrome = tvchart_chrome
494
+ self._tvchart_controller_factory = tvchart_controller_factory
495
+ self._tvchart_controller: Any = None
496
+ self._tvchart_datasets: dict[str, list[dict[str, Any]]] = {}
497
+ self._tvchart_series_type = "Candlestick"
498
+ self._tvchart_wired = False
499
+ self._tvchart_datafeed_provider_spec = tvchart_datafeed_provider
500
+ self._tvchart_datafeed_provider: Any = None
501
+ self._tvchart_datafeed_resolved = False
502
+ self._tvchart_datafeed_wired = False
503
+ self._ensure_bootstrap()
504
+
505
+ def tvchart_controller(self) -> Any:
506
+ """Return the bound TVChart protocol controller, or ``None``.
507
+
508
+ Available after the first :meth:`show_tvchart` when the host
509
+ supplied a ``tvchart_controller_factory``. Carries pywry's
510
+ entire ``TVChartStateMixin`` surface scoped to
511
+ ``TVCHART_CHART_ID``.
512
+ """
513
+ return self._tvchart_controller
514
+
515
+ def _ensure_bootstrap(self) -> None:
516
+ """Deliver the page handlers on paths where page scripts are inert.
517
+
518
+ The anywidget path renders content via ``innerHTML`` (scripts
519
+ do not execute) but executes anything pushed through the
520
+ ``_asset_js`` trait. Native / inline handles have no
521
+ ``set_trait`` and already ran the bootstrap from the page HTML.
522
+
523
+ Only ``PyWryChatWidget`` carries the ``_asset_js`` trait — the
524
+ base ``PyWryWidget`` has no script-execution channel at all
525
+ (verified live; pywry's own ``ChatManager`` asset injection has
526
+ the same constraint), so notebook hosting must use the
527
+ chat-paired widget.
528
+ """
529
+ set_trait = getattr(self._handle, "set_trait", None)
530
+ if set_trait is None:
531
+ return
532
+ has_trait = getattr(self._handle, "has_trait", None)
533
+ if callable(has_trait) and not has_trait("_asset_js"):
534
+ logger.warning(
535
+ "canvas: this anywidget handle has no _asset_js trait, so "
536
+ "the canvas bootstrap cannot execute — plain PyWryWidget "
537
+ "has no script channel. Host notebook canvases on "
538
+ "PyWryChatWidget (the chat-paired widget) instead."
539
+ )
540
+ return
541
+ try:
542
+ set_trait("_asset_js", CANVAS_BOOTSTRAP_JS)
543
+ except Exception:
544
+ logger.warning(
545
+ "canvas: bootstrap delivery via _asset_js failed", exc_info=True
546
+ )
547
+
548
+ def _emit(self, event: str, data: dict[str, Any]) -> None:
549
+ self._handle.emit(event, {"id": self._element_id, **data})
550
+
551
+ def show_html(self, html: str, *, title: str | None = None) -> None:
552
+ """Replace the canvas with an HTML fragment.
553
+
554
+ The fragment is delivered verbatim to the page and assigned via
555
+ ``innerHTML``, so any ``<script>`` tags it contains are inert.
556
+
557
+ Parameters
558
+ ----------
559
+ html : str
560
+ Raw HTML markup to drop into the canvas container.
561
+ title : str or None, optional
562
+ Optional section heading prepended above the fragment;
563
+ HTML-escaped.
564
+ """
565
+ self._emit("obb-canvas:set-html", {"html": _section(html, title)})
566
+
567
+ def show_markdown(self, text: str, *, title: str | None = None) -> None:
568
+ """Render markdown via ``window.marked`` with a ``<pre>`` fallback.
569
+
570
+ Parameters
571
+ ----------
572
+ text : str
573
+ Markdown source. Parsed by ``window.marked`` when present;
574
+ otherwise shown as preformatted text.
575
+ title : str or None, optional
576
+ Optional section heading rendered above the rendered markdown.
577
+ """
578
+ self._emit("obb-canvas:markdown", {"text": text, "title": title})
579
+
580
+ def _ensure_plotly(self) -> None:
581
+ if self._plotly_injected or self._plotly_assets is None:
582
+ return
583
+ try:
584
+ bundle = self._plotly_assets()
585
+ except Exception:
586
+ logger.warning("canvas: plotly asset load failed", exc_info=True)
587
+ return
588
+ self._emit("obb-canvas:load-assets", {"scripts": [bundle]})
589
+ self._plotly_injected = True
590
+
591
+ def show_plotly(self, figure: dict[str, Any], *, title: str | None = None) -> None:
592
+ """Render a Plotly figure into the canvas container.
593
+
594
+ On first call the plotly.js bundle is injected (when the host
595
+ supplied ``plotly_assets``). The figure is round-tripped through
596
+ JSON with ``default=str`` so non-serializable leaves (dates,
597
+ numpy scalars) cannot break the transport.
598
+
599
+ Parameters
600
+ ----------
601
+ figure : dict
602
+ A Plotly figure mapping; its ``data``, ``layout``, and
603
+ optional ``config`` keys are forwarded to ``Plotly.newPlot``.
604
+ title : str or None, optional
605
+ Optional section heading rendered above the chart.
606
+ """
607
+ self._ensure_plotly()
608
+ # Round-trip through JSON so non-serializable leaves (dates,
609
+ # numpy scalars rendered via str) cannot break any path's
610
+ # transport encoding.
611
+ safe_figure = json.loads(json.dumps(figure, default=str))
612
+ self._emit("obb-canvas:plotly", {"figure": safe_figure, "title": title})
613
+
614
+ def show_table(
615
+ self,
616
+ rows: list[dict[str, Any]],
617
+ *,
618
+ title: str | None = None,
619
+ columns: list[str] | None = None,
620
+ ) -> None:
621
+ """Replace the canvas with an escaped HTML table.
622
+
623
+ Delegates to :func:`render_table_html`, so values are HTML-escaped
624
+ and the row count is capped at ``_MAX_TABLE_ROWS``.
625
+
626
+ Parameters
627
+ ----------
628
+ rows : list of dict
629
+ Row records keyed by column name.
630
+ title : str or None, optional
631
+ Optional section heading rendered above the table.
632
+ columns : list of str, optional
633
+ Explicit column order; defaults to the first row's keys.
634
+ """
635
+ self._emit(
636
+ "obb-canvas:set-html",
637
+ {"html": _section(render_table_html(rows, columns=columns), title)},
638
+ )
639
+
640
+ def _ensure_tvchart(self) -> None:
641
+ if self._tvchart_injected or self._tvchart_assets is None:
642
+ return
643
+ try:
644
+ bundle = self._tvchart_assets()
645
+ except Exception:
646
+ logger.warning("canvas: tvchart asset load failed", exc_info=True)
647
+ return
648
+ self._emit("obb-canvas:load-assets", {"scripts": [bundle]})
649
+ self._tvchart_injected = True
650
+
651
+ def _resolve_datafeed_provider(self) -> Any:
652
+ """Resolve the datafeed provider instance (calling a factory once)."""
653
+ if self._tvchart_datafeed_resolved:
654
+ return self._tvchart_datafeed_provider
655
+ spec = self._tvchart_datafeed_provider_spec
656
+ provider = spec
657
+ if callable(spec) and not _looks_like_datafeed_provider(spec):
658
+ # A zero-arg factory (provider instances expose get_bars etc.,
659
+ # so the duck-type check tells a factory from an instance).
660
+ try:
661
+ provider = spec()
662
+ except Exception:
663
+ logger.warning(
664
+ "canvas: datafeed provider factory failed", exc_info=True
665
+ )
666
+ provider = None
667
+ self._tvchart_datafeed_provider = provider
668
+ self._tvchart_datafeed_resolved = True
669
+ return provider
670
+
671
+ def _ensure_tvchart_controller(self) -> Any:
672
+ """Bind pywry's TVChart controller once (if a factory was given)."""
673
+ if (
674
+ self._tvchart_controller is None
675
+ and self._tvchart_controller_factory is not None
676
+ ):
677
+ try:
678
+ self._tvchart_controller = self._tvchart_controller_factory(
679
+ self._handle, TVCHART_CHART_ID
680
+ )
681
+ except Exception:
682
+ logger.warning(
683
+ "canvas: tvchart controller binding failed", exc_info=True
684
+ )
685
+ return self._tvchart_controller
686
+
687
+ def _ensure_datafeed_wired(self) -> bool:
688
+ """Wire the datafeed provider to the controller once.
689
+
690
+ Delegates to pywry's ``_wire_datafeed_provider`` (the documented
691
+ auto-wiring), which registers the datafeed config/search/resolve/
692
+ history handlers AND the ``tvchart:data-request`` handler that
693
+ serves interval switches, symbol-search selections, and compare
694
+ series — all through ``provider.get_bars``. Returns ``True`` when
695
+ a provider is wired (so the static data-request handler is
696
+ skipped to avoid double responses).
697
+ """
698
+ if self._tvchart_datafeed_wired:
699
+ return True
700
+ provider = self._resolve_datafeed_provider()
701
+ if provider is None:
702
+ return False
703
+ controller = self._ensure_tvchart_controller()
704
+ wire = getattr(controller, "_wire_datafeed_provider", None)
705
+ if not callable(wire):
706
+ logger.warning(
707
+ "canvas: controller has no _wire_datafeed_provider — symbol "
708
+ "search/compare will not be answered"
709
+ )
710
+ return False
711
+ try:
712
+ wire(provider)
713
+ except Exception:
714
+ logger.warning("canvas: datafeed provider wiring failed", exc_info=True)
715
+ return False
716
+ self._tvchart_datafeed_wired = True
717
+ return True
718
+
719
+ def _ensure_tvchart_wired(self) -> None:
720
+ """Register the protocol's Python obligation once.
721
+
722
+ Interval switches in the toolbar make the engine emit
723
+ ``tvchart:data-request``; the host answers with
724
+ ``tvchart:data-response`` (the exact contract
725
+ ``examples/pywry_demo_tvchart.py`` implements).
726
+
727
+ When a datafeed provider is configured, its pywry wiring already
728
+ owns ``tvchart:data-request`` (serving every symbol/interval via
729
+ ``get_bars``), so the static per-interval handler is skipped to
730
+ avoid double responses.
731
+ """
732
+ if self._tvchart_wired:
733
+ return
734
+ if self._ensure_datafeed_wired():
735
+ self._tvchart_wired = True
736
+ return
737
+ on = getattr(self._handle, "on", None)
738
+ if not callable(on):
739
+ logger.warning(
740
+ "canvas: handle has no .on() — tvchart interval switching "
741
+ "will not be answered"
742
+ )
743
+ self._tvchart_wired = True
744
+ return
745
+ on("tvchart:data-request", self._on_tvchart_data_request)
746
+ self._tvchart_wired = True
747
+
748
+ def _on_tvchart_data_request(self, data: Any, *_args: Any) -> None:
749
+ """Serve the stashed per-interval dataset back to the engine."""
750
+ try:
751
+ payload = dict(data or {})
752
+ if payload.get("chartId") not in (None, TVCHART_CHART_ID):
753
+ return
754
+ if payload.get("seriesId", "main") != "main":
755
+ # Compare-series requests carry symbols we have no data
756
+ # for; answering them with main bars would be wrong.
757
+ return
758
+ if not self._tvchart_datasets:
759
+ return
760
+ interval = str(payload.get("resolution") or payload.get("interval") or "")
761
+ if interval not in self._tvchart_datasets:
762
+ interval = next(iter(self._tvchart_datasets))
763
+ self._handle.emit(
764
+ "tvchart:data-response",
765
+ {
766
+ "chartId": payload.get("chartId") or TVCHART_CHART_ID,
767
+ "seriesId": "main",
768
+ "bars": self._tvchart_datasets[interval],
769
+ "fitContent": True,
770
+ "interval": interval,
771
+ },
772
+ )
773
+ except Exception:
774
+ logger.warning("canvas: tvchart data-request failed", exc_info=True)
775
+
776
+ def show_tvchart(
777
+ self,
778
+ datasets: dict[str, list[dict[str, Any]]],
779
+ *,
780
+ selected_interval: str | None = None,
781
+ series_type: str = "Candlestick",
782
+ title: str | None = None,
783
+ chart_options: dict[str, Any] | None = None,
784
+ height: str | None = None,
785
+ ) -> None:
786
+ """Mount a full PyWry TVChart — engine, toolbars, and data flow.
787
+
788
+ This drives pywry's own TradingView chart protocol end to end,
789
+ exactly like ``app.show_tvchart``:
790
+
791
+ * the host's ``tvchart_assets`` supply lightweight-charts, the
792
+ tvchart engine (which self-registers the ``tvchart:*``
793
+ handlers), and the toolbar handlers;
794
+ * the host's ``tvchart_chrome`` wraps the chart container with
795
+ ``build_tvchart_toolbars`` chrome — header (symbol search,
796
+ chart type, interval dropdown, indicators, settings, ...),
797
+ drawing rail, time-range bar, and the OHLC legend overlay;
798
+ * the chart mounts via the protocol's ``tvchart:create`` event;
799
+ * interval switches round-trip through ``tvchart:data-request``
800
+ → ``tvchart:data-response`` served from ``datasets``.
801
+
802
+ ``datasets`` maps interval codes (``"1m"``, ``"1h"``, ``"1d"``,
803
+ ``"1w"``, ...) to bar lists. Bars are
804
+ ``{time, open, high, low, close, volume?}`` (volume embedded in
805
+ the bars — the engine splits it into its own pane). Once
806
+ created, every other ``tvchart:*`` protocol event
807
+ (``tvchart:update``, ``tvchart:stream``, ``tvchart:add-series``,
808
+ ``tvchart:add-markers``, ...) targets the chart via
809
+ ``chartId=TVCHART_CHART_ID``.
810
+
811
+ Parameters
812
+ ----------
813
+ datasets : dict of str to list of dict
814
+ Interval code mapped to its bar list (see above). Must be
815
+ non-empty.
816
+ selected_interval : str or None, optional
817
+ Interval to mount first; falls back to the first key in
818
+ ``datasets`` when missing or not present.
819
+ series_type : str, optional
820
+ Main-series type passed to the engine (e.g. ``"Candlestick"``,
821
+ ``"Line"``). Defaults to ``"Candlestick"``.
822
+ title : str or None, optional
823
+ Chart title shown in the chrome and section heading.
824
+ chart_options : dict or None, optional
825
+ Extra engine chart options forwarded as ``chartOptions``.
826
+ height : str or None, optional
827
+ CSS height for the chart frame; defaults to ``"560px"``.
828
+
829
+ Raises
830
+ ------
831
+ ValueError
832
+ If ``datasets`` is empty.
833
+ """
834
+ if not datasets:
835
+ raise ValueError("datasets must contain at least one interval")
836
+ sanitised: dict[str, list[dict[str, Any]]] = json.loads(
837
+ json.dumps(datasets, default=str)
838
+ )
839
+ intervals = list(sanitised)
840
+ selected = selected_interval if selected_interval in sanitised else intervals[0]
841
+ self._tvchart_datasets = sanitised
842
+ self._tvchart_series_type = series_type
843
+ self._ensure_tvchart()
844
+ self._ensure_tvchart_wired()
845
+ self._ensure_tvchart_controller()
846
+ self._mount_tvchart_chrome(intervals, selected, title=title, height=height)
847
+
848
+ payload = {
849
+ "containerId": TVCHART_CHART_ID,
850
+ "chartId": TVCHART_CHART_ID,
851
+ "chartOptions": chart_options or {},
852
+ "title": title or "",
853
+ "series": [
854
+ {
855
+ "seriesId": "main",
856
+ "seriesType": series_type,
857
+ "bars": sanitised[selected],
858
+ "volume": [],
859
+ "seriesOptions": {},
860
+ }
861
+ ],
862
+ "chartKind": "default",
863
+ "useDatafeed": False,
864
+ "interval": selected,
865
+ "storage": {"backend": "localStorage"},
866
+ }
867
+ # Protocol event — handled by pywry's tvchart engine, not the
868
+ # canvas bootstrap.
869
+ self._handle.emit("tvchart:create", payload)
870
+
871
+ def _mount_tvchart_chrome(
872
+ self,
873
+ intervals: list[str],
874
+ selected: str,
875
+ *,
876
+ title: str | None,
877
+ height: str | None,
878
+ ) -> None:
879
+ """Emit the chart container wrapped in pywry's toolbar chrome."""
880
+ container = (
881
+ f'<div id="{TVCHART_CHART_ID}" class="pywry-tvchart-container"></div>'
882
+ )
883
+ if self._tvchart_chrome is not None:
884
+ try:
885
+ body = self._tvchart_chrome(container, intervals, selected)
886
+ except Exception:
887
+ logger.warning(
888
+ "canvas: tvchart chrome build failed; rendering bare chart",
889
+ exc_info=True,
890
+ )
891
+ body = container
892
+ else:
893
+ body = container
894
+ frame = (
895
+ f'<div class="obb-canvas-tvchart-frame" '
896
+ f'style="height:{html_mod.escape(height or "560px", quote=True)};">'
897
+ f"{body}</div>"
898
+ )
899
+ self._emit("obb-canvas:set-html", {"html": _section(frame, title)})
900
+
901
+ def show_tvchart_symbol(
902
+ self,
903
+ symbol: str,
904
+ *,
905
+ intervals: list[str] | None = None,
906
+ selected_interval: str | None = None,
907
+ series_type: str = "Candlestick",
908
+ title: str | None = None,
909
+ chart_options: dict[str, Any] | None = None,
910
+ height: str | None = None,
911
+ ) -> None:
912
+ """Mount a DATAFEED-backed TVChart whose data comes from the provider.
913
+
914
+ Unlike :meth:`show_tvchart` (which renders caller-supplied bars
915
+ and has no search/compare data source), this mounts the chart in
916
+ ``useDatafeed=True`` mode for ``symbol`` and wires the configured
917
+ ``tvchart_datafeed_provider``. The provider then answers every
918
+ datafeed event — config, symbol search, resolve, history, and the
919
+ ``tvchart:data-request`` that interval switches, symbol-search
920
+ selections, and Compare emit — so the header's Symbol Search and
921
+ Compare controls work end to end against any symbol the provider
922
+ knows. Mirrors ``pywry.PyWry.show_tvchart(provider=..., symbol=...,
923
+ use_datafeed=True)``.
924
+
925
+ Requires a ``tvchart_datafeed_provider``; raises ``RuntimeError``
926
+ when none is configured (there is no data source to back search).
927
+
928
+ Parameters
929
+ ----------
930
+ symbol : str
931
+ Initial symbol to load; resolved through the datafeed
932
+ provider. Required.
933
+ intervals : list of str or None, optional
934
+ Interval ladder offered in the toolbar; defaults to
935
+ ``["1d", "1w", "1M"]``.
936
+ selected_interval : str or None, optional
937
+ Interval to mount first; falls back to the first ladder entry
938
+ when missing or not present.
939
+ series_type : str, optional
940
+ Main-series type passed to the engine. Defaults to
941
+ ``"Candlestick"``.
942
+ title : str or None, optional
943
+ Chart title; defaults to ``symbol``.
944
+ chart_options : dict or None, optional
945
+ Extra engine chart options forwarded as ``chartOptions``.
946
+ height : str or None, optional
947
+ CSS height for the chart frame; defaults to ``"560px"``.
948
+
949
+ Raises
950
+ ------
951
+ ValueError
952
+ If ``symbol`` is empty.
953
+ RuntimeError
954
+ If no ``tvchart_datafeed_provider`` is configured, since
955
+ symbol search and compare would have no data source.
956
+ """
957
+ if not symbol:
958
+ raise ValueError("symbol is required")
959
+ self._ensure_tvchart()
960
+ self._ensure_tvchart_controller()
961
+ if not self._ensure_datafeed_wired():
962
+ raise RuntimeError(
963
+ "show_tvchart_symbol needs a tvchart_datafeed_provider; none is "
964
+ "configured, so symbol search/compare have no data source. Use "
965
+ "show_tvchart(datasets) for caller-supplied bars instead."
966
+ )
967
+ # The provider owns tvchart:data-request, so mark wiring done.
968
+ self._tvchart_wired = True
969
+ ladder = list(intervals) if intervals else ["1d", "1w", "1M"]
970
+ selected = selected_interval if selected_interval in ladder else ladder[0]
971
+ self._tvchart_series_type = series_type
972
+ self._mount_tvchart_chrome(ladder, selected, title=title, height=height)
973
+
974
+ payload = {
975
+ "containerId": TVCHART_CHART_ID,
976
+ "chartId": TVCHART_CHART_ID,
977
+ "chartOptions": chart_options or {},
978
+ "title": title or symbol,
979
+ "series": [
980
+ {
981
+ "seriesId": "main",
982
+ "symbol": symbol,
983
+ "resolution": selected,
984
+ "seriesType": series_type,
985
+ "seriesOptions": {},
986
+ "bars": [],
987
+ "volume": [],
988
+ }
989
+ ],
990
+ "chartKind": "default",
991
+ "useDatafeed": True,
992
+ "interval": selected,
993
+ "storage": {"backend": "localStorage"},
994
+ }
995
+ self._handle.emit("tvchart:create", payload)
996
+
997
+ def show_image(self, src: str, *, title: str | None = None) -> None:
998
+ """Replace the canvas with an image element.
999
+
1000
+ ``src`` must already be a renderable string — an ``https`` URL or
1001
+ a ``data:`` URI. The tool layer normalizes bytes / local paths /
1002
+ uploaded files to one of these before calling here.
1003
+
1004
+ Parameters
1005
+ ----------
1006
+ src : str
1007
+ Renderable image source (``https`` URL or ``data:`` URI);
1008
+ escaped for the attribute context.
1009
+ title : str or None, optional
1010
+ Optional section heading rendered above the image.
1011
+ """
1012
+ img = f'<img class="obb-canvas-img" src="{html_mod.escape(src, quote=True)}">'
1013
+ self._emit("obb-canvas:set-html", {"html": _section(img, title)})
1014
+
1015
+ def show_document(
1016
+ self,
1017
+ *,
1018
+ src: str,
1019
+ mime: str,
1020
+ filename: str | None = None,
1021
+ title: str | None = None,
1022
+ text: str | None = None,
1023
+ ) -> None:
1024
+ """Replace the canvas with a rendered document, dispatched by MIME.
1025
+
1026
+ ``src`` is a ready ``https`` URL or ``data:`` URI (the tool layer
1027
+ normalizes bytes / paths / uploads). The MIME family selects the
1028
+ element: ``image/*`` → ``<img>``, ``application/pdf`` →
1029
+ ``<iframe>``, ``audio/*`` → ``<audio>``, ``video/*`` →
1030
+ ``<video>``, ``text/plain``/``application/json`` → ``<pre>``;
1031
+ anything else renders a download link (plus ``text`` when given as
1032
+ an extracted-content fallback). Like every renderer this is one
1033
+ ``obb-canvas:set-html`` emit, so it works on all three paths.
1034
+
1035
+ Parameters
1036
+ ----------
1037
+ src : str
1038
+ Renderable document source (``https`` URL or ``data:`` URI);
1039
+ escaped for the attribute context.
1040
+ mime : str
1041
+ MIME type used to select the render element / family.
1042
+ filename : str or None, optional
1043
+ Display / download name for PDFs and the download fallback.
1044
+ title : str or None, optional
1045
+ Optional section heading rendered above the document.
1046
+ text : str or None, optional
1047
+ Extracted text shown inline for the text family and appended
1048
+ beneath the download link as a fallback.
1049
+ """
1050
+ kind = _doc_kind_for_mime(mime)
1051
+ fragment = _embed_html(src, kind=kind, filename=filename, text=text)
1052
+ self._emit("obb-canvas:set-html", {"html": _section(fragment, title)})
1053
+
1054
+ def clear(self) -> None:
1055
+ """Reset the canvas to its empty state."""
1056
+ self._emit("obb-canvas:set-html", {"html": _EMPTY_STATE})