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.
- openbb_agent_server/__init__.py +17 -0
- openbb_agent_server/_vendor/__init__.py +0 -0
- openbb_agent_server/_vendor/sqlitevec.py +135 -0
- openbb_agent_server/acp/__init__.py +32 -0
- openbb_agent_server/acp/canvas.py +1056 -0
- openbb_agent_server/acp/canvas_app.py +480 -0
- openbb_agent_server/acp/provider.py +580 -0
- openbb_agent_server/app/__init__.py +1 -0
- openbb_agent_server/app/app.py +299 -0
- openbb_agent_server/app/config.py +754 -0
- openbb_agent_server/app/router.py +1680 -0
- openbb_agent_server/app/settings.py +779 -0
- openbb_agent_server/main.py +396 -0
- openbb_agent_server/memory/__init__.py +1 -0
- openbb_agent_server/memory/classifier.py +201 -0
- openbb_agent_server/memory/embeddings.py +118 -0
- openbb_agent_server/memory/factory.py +226 -0
- openbb_agent_server/memory/ingestion.py +424 -0
- openbb_agent_server/memory/reranker.py +165 -0
- openbb_agent_server/memory/retrievers.py +177 -0
- openbb_agent_server/memory/sqlite_store.py +593 -0
- openbb_agent_server/memory/store.py +196 -0
- openbb_agent_server/memory/translation.py +161 -0
- openbb_agent_server/memory/writer.py +163 -0
- openbb_agent_server/observability/__init__.py +1 -0
- openbb_agent_server/observability/logging.py +272 -0
- openbb_agent_server/openbb.toml.example +332 -0
- openbb_agent_server/persistence/__init__.py +1 -0
- openbb_agent_server/persistence/models.py +650 -0
- openbb_agent_server/persistence/prune.py +297 -0
- openbb_agent_server/persistence/sqlite_store.py +857 -0
- openbb_agent_server/persistence/store.py +444 -0
- openbb_agent_server/plugins/__init__.py +1 -0
- openbb_agent_server/plugins/auth/__init__.py +1 -0
- openbb_agent_server/plugins/auth/api_key_table.py +303 -0
- openbb_agent_server/plugins/auth/bearer_static.py +107 -0
- openbb_agent_server/plugins/auth/none.py +60 -0
- openbb_agent_server/plugins/auth/oidc_jwt.py +138 -0
- openbb_agent_server/plugins/auth/openbb_workspace.py +111 -0
- openbb_agent_server/plugins/checkpointers/__init__.py +1 -0
- openbb_agent_server/plugins/checkpointers/inmemory.py +61 -0
- openbb_agent_server/plugins/checkpointers/postgres.py +148 -0
- openbb_agent_server/plugins/checkpointers/sqlite.py +109 -0
- openbb_agent_server/plugins/middleware/__init__.py +1 -0
- openbb_agent_server/plugins/middleware/call_limit.py +143 -0
- openbb_agent_server/plugins/middleware/loop_guard.py +152 -0
- openbb_agent_server/plugins/middleware/tool_call_announcer.py +120 -0
- openbb_agent_server/plugins/middleware/tool_call_ledger.py +147 -0
- openbb_agent_server/plugins/middleware/tool_filter.py +117 -0
- openbb_agent_server/plugins/middleware/tool_message_normaliser.py +336 -0
- openbb_agent_server/plugins/middleware/usage_recorder.py +95 -0
- openbb_agent_server/plugins/models/__init__.py +1 -0
- openbb_agent_server/plugins/models/_validation.py +55 -0
- openbb_agent_server/plugins/models/anthropic_provider.py +158 -0
- openbb_agent_server/plugins/models/bedrock_provider.py +182 -0
- openbb_agent_server/plugins/models/fake_provider.py +138 -0
- openbb_agent_server/plugins/models/google_genai_provider.py +226 -0
- openbb_agent_server/plugins/models/groq_provider.py +248 -0
- openbb_agent_server/plugins/models/groq_rate_limiter.py +447 -0
- openbb_agent_server/plugins/models/nvidia_provider.py +241 -0
- openbb_agent_server/plugins/models/openai_compat_provider.py +266 -0
- openbb_agent_server/plugins/models/openai_provider.py +198 -0
- openbb_agent_server/plugins/models/vertex_provider.py +221 -0
- openbb_agent_server/plugins/subagents/__init__.py +1 -0
- openbb_agent_server/plugins/subagents/analyst.py +50 -0
- openbb_agent_server/plugins/subagents/charter.py +51 -0
- openbb_agent_server/plugins/subagents/pdf_reader.py +57 -0
- openbb_agent_server/plugins/subagents/researcher.py +69 -0
- openbb_agent_server/plugins/tools/__init__.py +1 -0
- openbb_agent_server/plugins/tools/_media.py +470 -0
- openbb_agent_server/plugins/tools/artifacts.py +328 -0
- openbb_agent_server/plugins/tools/background_jobs.py +158 -0
- openbb_agent_server/plugins/tools/client_side.py +115 -0
- openbb_agent_server/plugins/tools/dashboard.py +108 -0
- openbb_agent_server/plugins/tools/fetch_url.py +294 -0
- openbb_agent_server/plugins/tools/gemini_embeddings.py +230 -0
- openbb_agent_server/plugins/tools/gemini_image.py +621 -0
- openbb_agent_server/plugins/tools/gemma_audio.py +461 -0
- openbb_agent_server/plugins/tools/groq_audio.py +404 -0
- openbb_agent_server/plugins/tools/inspect_widget_data.py +389 -0
- openbb_agent_server/plugins/tools/mcp_http.py +230 -0
- openbb_agent_server/plugins/tools/mcp_local.py +194 -0
- openbb_agent_server/plugins/tools/memory_recall.py +95 -0
- openbb_agent_server/plugins/tools/paligemma_vision.py +468 -0
- openbb_agent_server/plugins/tools/pdf_extract.py +1089 -0
- openbb_agent_server/plugins/tools/python_module.py +93 -0
- openbb_agent_server/plugins/tools/pywry_canvas.py +1730 -0
- openbb_agent_server/plugins/tools/rerank.py +160 -0
- openbb_agent_server/plugins/tools/translate.py +138 -0
- openbb_agent_server/plugins/tools/vision_qa.py +380 -0
- openbb_agent_server/plugins/tools/web_search.py +177 -0
- openbb_agent_server/plugins/tools/widget_data.py +194 -0
- openbb_agent_server/plugins/tools/workspace_mcp.py +84 -0
- openbb_agent_server/prompts/__init__.py +12 -0
- openbb_agent_server/prompts/default_system_prompt.md +412 -0
- openbb_agent_server/protocol/__init__.py +1 -0
- openbb_agent_server/protocol/adapter.py +771 -0
- openbb_agent_server/protocol/schemas.py +556 -0
- openbb_agent_server/protocol/sse.py +60 -0
- openbb_agent_server/py.typed +0 -0
- openbb_agent_server/runtime/__init__.py +1 -0
- openbb_agent_server/runtime/builder.py +987 -0
- openbb_agent_server/runtime/canvas.py +234 -0
- openbb_agent_server/runtime/context.py +216 -0
- openbb_agent_server/runtime/embedded.py +384 -0
- openbb_agent_server/runtime/emit.py +568 -0
- openbb_agent_server/runtime/identity.py +118 -0
- openbb_agent_server/runtime/jobs.py +386 -0
- openbb_agent_server/runtime/pdf_store.py +715 -0
- openbb_agent_server/runtime/plugins.py +190 -0
- openbb_agent_server/runtime/principal.py +54 -0
- openbb_agent_server/runtime/registry.py +107 -0
- openbb_agent_server/runtime/services.py +169 -0
- openbb_agent_server/runtime/widget_store.py +707 -0
- openbb_agent_server-0.1.0.dist-info/METADATA +137 -0
- openbb_agent_server-0.1.0.dist-info/RECORD +118 -0
- openbb_agent_server-0.1.0.dist-info/WHEEL +4 -0
- 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})
|