framejs 0.4.0__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
framejs-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Metapages
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
framejs-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.5
2
+ Name: framejs
3
+ Version: 0.4.0
4
+ Summary: Embed framejs frames as interactive widgets in Jupyter and marimo notebooks
5
+ Project-URL: Homepage, https://framejs.io/docs/integrations/jupyter
6
+ Project-URL: Repository, https://github.com/metapages/framejs.io
7
+ Project-URL: Issues, https://github.com/metapages/framejs.io/issues
8
+ Project-URL: Changelog, https://github.com/metapages/framejs.io/blob/main/python/README.md
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Framework :: Jupyter
12
+ Classifier: Framework :: Jupyter :: JupyterLab
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Requires-Python: >=3.8
22
+ Requires-Dist: anywidget>=0.9.0
23
+ Requires-Dist: traitlets>=5.0.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Provides-Extra: jupyter
27
+ Requires-Dist: jupyterlab; extra == 'jupyter'
28
+ Provides-Extra: marimo
29
+ Requires-Dist: marimo; extra == 'marimo'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # framejs
33
+
34
+ An [anywidget](https://anywidget.dev/) for embedding [framejs](https://framejs.io/docs)
35
+ frames (and [metapage](https://docs.metapage.io/) URLs) in Jupyter and marimo
36
+ notebooks.
37
+
38
+ > **Renamed in 0.4.0.** This package was published as `metaframe-widget`, and the
39
+ > class as `MetaframeWidget`. Both names still work — `metaframe-widget` now
40
+ > installs this package, and `MetaframeWidget` is an alias of `Frame` — but new
41
+ > code should use `framejs` and `Frame`.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install framejs
47
+ ```
48
+
49
+ With environment extras:
50
+
51
+ ```bash
52
+ pip install "framejs[jupyter]" # includes jupyterlab
53
+ pip install "framejs[marimo]" # includes marimo
54
+ ```
55
+
56
+ ## Quick start
57
+
58
+ ### Jupyter
59
+
60
+ ```python
61
+ from framejs import Frame
62
+
63
+ w = Frame(url="https://framejs.io/#?js=...", height="300px")
64
+ w
65
+ ```
66
+
67
+ ### marimo
68
+
69
+ ```python
70
+ import marimo as mo
71
+ from framejs import Frame
72
+
73
+ w = Frame(url="https://framejs.io/#?js=...", height="300px")
74
+ mo.ui.anywidget(w)
75
+ ```
76
+
77
+ A widget is always created from a URL. To embed your own code, build and save it
78
+ at [framejs.io](https://framejs.io/) — the editor mints a short URL you can paste
79
+ into `url=`. The URL is the portable, saveable form of a frame; the code itself
80
+ lives behind it rather than being inlined in your notebook.
81
+
82
+ ### URL forms
83
+
84
+ Any of these work as `url=`:
85
+
86
+ ```python
87
+ # Raw / full URL — the code is inlined in the hash (can get very long)
88
+ Frame(url="https://framejs.io/#?js=...")
89
+
90
+ # Expiring snapshot — content-addressed, kept ~30 days, then garbage-collected
91
+ # (editor: "Create expiring snapshot")
92
+ Frame(url="https://framejs.io/j/<sha256>")
93
+
94
+ # Durable, editable frame — permanent, tied to your account (editor: "Save")
95
+ # Paste either host: a framejs.app/j/<uuid> URL is loaded from framejs.io, the
96
+ # runtime that serves frames with CORS headers (a notebook cannot load the
97
+ # framejs.app one directly). `w.url` still reads back what you passed.
98
+ Frame(url="https://framejs.app/j/<uuid>")
99
+ ```
100
+
101
+ Prefer the durable `/j/<uuid>` form in notebooks you keep. See
102
+ [Short URLs](https://framejs.io/docs/guide/short-urls) for the full comparison.
103
+
104
+ ## API reference
105
+
106
+ | Parameter | Type | Default | Description |
107
+ |-----------|------|---------|-------------|
108
+ | `url` | `str` | `""` | Full frame URL (including hash params) |
109
+ | `inputs` | `dict` | `{}` | Dict of inputs to push to the frame |
110
+ | `outputs` | `dict` | `{}` | Dict of outputs received from the frame (read-only) |
111
+ | `width` | `str` | `"100%"` | CSS width for the widget container |
112
+ | `height` | `str` | `"400px"` | CSS height for the widget container |
113
+ | `allow` | `str` | `""` | iframe `allow` attribute (e.g. `"camera; microphone"`) |
114
+
115
+ ### Methods
116
+
117
+ - **`set_inputs(d)`** — merge a dict into current inputs
118
+ - **`set_input(key, value)`** — set a single input key
119
+ - **`on_outputs_change(callback)`** — register a callback for output changes
120
+ - **`on_saved_url_change(callback)`** — register a callback for when the user creates an expiring snapshot (`/j/<sha256>`) inside the widget
121
+ - **`pipe_to(target, output_key, input_key=None)`** — connect an output to another widget's input
122
+
123
+ ## Piping widgets
124
+
125
+ ```python
126
+ source = Frame(url="...")
127
+ sink = Frame(url="...")
128
+ source.pipe_to(sink, output_key="result", input_key="data")
129
+ ```
130
+
131
+ ## No anywidget? Use a plain iframe
132
+
133
+ To send data **into** a frame and nothing more, you need no package at all —
134
+ encode the inputs into the URL and show it with `IPython.display.IFrame`. See
135
+ [the Jupyter guide](https://framejs.io/docs/integrations/jupyter#without-anywidget-a-plain-iframe).
136
+
137
+ ## Links
138
+
139
+ - [GitHub](https://github.com/metapages/framejs.io)
140
+ - [Jupyter examples](https://github.com/metapages/framejs.io/tree/main/examples/jupyter)
141
+ - [marimo examples](https://github.com/metapages/framejs.io/tree/main/examples/marimo)
@@ -0,0 +1,110 @@
1
+ # framejs
2
+
3
+ An [anywidget](https://anywidget.dev/) for embedding [framejs](https://framejs.io/docs)
4
+ frames (and [metapage](https://docs.metapage.io/) URLs) in Jupyter and marimo
5
+ notebooks.
6
+
7
+ > **Renamed in 0.4.0.** This package was published as `metaframe-widget`, and the
8
+ > class as `MetaframeWidget`. Both names still work — `metaframe-widget` now
9
+ > installs this package, and `MetaframeWidget` is an alias of `Frame` — but new
10
+ > code should use `framejs` and `Frame`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install framejs
16
+ ```
17
+
18
+ With environment extras:
19
+
20
+ ```bash
21
+ pip install "framejs[jupyter]" # includes jupyterlab
22
+ pip install "framejs[marimo]" # includes marimo
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ### Jupyter
28
+
29
+ ```python
30
+ from framejs import Frame
31
+
32
+ w = Frame(url="https://framejs.io/#?js=...", height="300px")
33
+ w
34
+ ```
35
+
36
+ ### marimo
37
+
38
+ ```python
39
+ import marimo as mo
40
+ from framejs import Frame
41
+
42
+ w = Frame(url="https://framejs.io/#?js=...", height="300px")
43
+ mo.ui.anywidget(w)
44
+ ```
45
+
46
+ A widget is always created from a URL. To embed your own code, build and save it
47
+ at [framejs.io](https://framejs.io/) — the editor mints a short URL you can paste
48
+ into `url=`. The URL is the portable, saveable form of a frame; the code itself
49
+ lives behind it rather than being inlined in your notebook.
50
+
51
+ ### URL forms
52
+
53
+ Any of these work as `url=`:
54
+
55
+ ```python
56
+ # Raw / full URL — the code is inlined in the hash (can get very long)
57
+ Frame(url="https://framejs.io/#?js=...")
58
+
59
+ # Expiring snapshot — content-addressed, kept ~30 days, then garbage-collected
60
+ # (editor: "Create expiring snapshot")
61
+ Frame(url="https://framejs.io/j/<sha256>")
62
+
63
+ # Durable, editable frame — permanent, tied to your account (editor: "Save")
64
+ # Paste either host: a framejs.app/j/<uuid> URL is loaded from framejs.io, the
65
+ # runtime that serves frames with CORS headers (a notebook cannot load the
66
+ # framejs.app one directly). `w.url` still reads back what you passed.
67
+ Frame(url="https://framejs.app/j/<uuid>")
68
+ ```
69
+
70
+ Prefer the durable `/j/<uuid>` form in notebooks you keep. See
71
+ [Short URLs](https://framejs.io/docs/guide/short-urls) for the full comparison.
72
+
73
+ ## API reference
74
+
75
+ | Parameter | Type | Default | Description |
76
+ |-----------|------|---------|-------------|
77
+ | `url` | `str` | `""` | Full frame URL (including hash params) |
78
+ | `inputs` | `dict` | `{}` | Dict of inputs to push to the frame |
79
+ | `outputs` | `dict` | `{}` | Dict of outputs received from the frame (read-only) |
80
+ | `width` | `str` | `"100%"` | CSS width for the widget container |
81
+ | `height` | `str` | `"400px"` | CSS height for the widget container |
82
+ | `allow` | `str` | `""` | iframe `allow` attribute (e.g. `"camera; microphone"`) |
83
+
84
+ ### Methods
85
+
86
+ - **`set_inputs(d)`** — merge a dict into current inputs
87
+ - **`set_input(key, value)`** — set a single input key
88
+ - **`on_outputs_change(callback)`** — register a callback for output changes
89
+ - **`on_saved_url_change(callback)`** — register a callback for when the user creates an expiring snapshot (`/j/<sha256>`) inside the widget
90
+ - **`pipe_to(target, output_key, input_key=None)`** — connect an output to another widget's input
91
+
92
+ ## Piping widgets
93
+
94
+ ```python
95
+ source = Frame(url="...")
96
+ sink = Frame(url="...")
97
+ source.pipe_to(sink, output_key="result", input_key="data")
98
+ ```
99
+
100
+ ## No anywidget? Use a plain iframe
101
+
102
+ To send data **into** a frame and nothing more, you need no package at all —
103
+ encode the inputs into the URL and show it with `IPython.display.IFrame`. See
104
+ [the Jupyter guide](https://framejs.io/docs/integrations/jupyter#without-anywidget-a-plain-iframe).
105
+
106
+ ## Links
107
+
108
+ - [GitHub](https://github.com/metapages/framejs.io)
109
+ - [Jupyter examples](https://github.com/metapages/framejs.io/tree/main/examples/jupyter)
110
+ - [marimo examples](https://github.com/metapages/framejs.io/tree/main/examples/marimo)
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "framejs"
7
+ version = "0.4.0"
8
+ description = "Embed framejs frames as interactive widgets in Jupyter and marimo notebooks"
9
+ license = "MIT"
10
+ requires-python = ">=3.8"
11
+ readme = "README.md"
12
+ dependencies = [
13
+ "anywidget>=0.9.0",
14
+ "traitlets>=5.0.0",
15
+ ]
16
+ classifiers = [
17
+ "Framework :: Jupyter",
18
+ "Framework :: Jupyter :: JupyterLab",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.8",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "License :: OSI Approved :: MIT License",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://framejs.io/docs/integrations/jupyter"
31
+ Repository = "https://github.com/metapages/framejs.io"
32
+ Issues = "https://github.com/metapages/framejs.io/issues"
33
+ Changelog = "https://github.com/metapages/framejs.io/blob/main/python/README.md"
34
+
35
+ [project.optional-dependencies]
36
+ jupyter = ["jupyterlab"]
37
+ marimo = ["marimo"]
38
+ dev = ["pytest"]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/framejs"]
@@ -0,0 +1,3 @@
1
+ from ._widget import Frame, MetaframeWidget, runtime_url
2
+
3
+ __all__ = ["Frame", "MetaframeWidget", "runtime_url"]
@@ -0,0 +1,208 @@
1
+ ESM = """
2
+ const CDN_URL = "https://cdn.jsdelivr.net/npm/@metapages/metapage@1.10.14/+esm";
3
+
4
+ let _renderMetapage = null;
5
+
6
+ async function loadRenderMetapage() {
7
+ if (!_renderMetapage) {
8
+ const mod = await import(CDN_URL);
9
+ _renderMetapage = mod.renderMetapage;
10
+ }
11
+ return _renderMetapage;
12
+ }
13
+
14
+ export default {
15
+ async render({ model, el }) {
16
+ // el's own `display` is managed by the notebook host — JupyterLab's
17
+ // ipywidgets layer resets el.style.display to "block" AFTER render, which
18
+ // silently kills a flex column set directly on el (that's why the widget
19
+ // renders full-height standalone but collapses to ~40px in a notebook).
20
+ // So we don't lay out el itself; we only give it a definite size, then own
21
+ // an inner `root` wrapper. root's height:100% resolves against el's definite
22
+ // height regardless of el's display, and root's flex column (which the host
23
+ // never touches) lets the metapage fill the space with an optional
24
+ // "saved URL" footer beneath it.
25
+ el.style.boxSizing = "border-box";
26
+ el.style.width = model.get("width") || "100%";
27
+ el.style.height = model.get("height") || "400px";
28
+
29
+ const root = document.createElement("div");
30
+ root.style.cssText = "display: flex; flex-direction: column; width: 100%; height: 100%; box-sizing: border-box;";
31
+ el.appendChild(root);
32
+
33
+ // Inject CSS to remove iframe borders and inline gaps
34
+ const style = document.createElement("style");
35
+ // renderMetapage lays the metaframe out in a react-grid-layout grid and
36
+ // stamps an inline `min-height` floor on the grid container (default h=3 *
37
+ // rowHeight 100 + margins => 350px) and on the item wrapper (300px). The
38
+ // grid rows are `1fr`, so nothing else pins the size — only those floors.
39
+ // When the widget's height is set below 350px, the floor wins and the
40
+ // content is cut off at 350px. Override the floors so the metaframe fills
41
+ // our explicit height at any size (large heights already fill via 1fr).
42
+ style.textContent = ".framejs-frame-container { box-sizing: border-box; flex: 1 1 auto; min-height: 0; width: 100%; } .framejs-frame-container * { box-sizing: border-box; min-height: 0 !important; } .framejs-frame-container > div { height: 100%; } .framejs-frame-container iframe { border: none; display: block; margin: 0; padding: 0; box-sizing: border-box; width: 100%; height: 100%; }";
43
+ root.appendChild(style);
44
+
45
+ const container = document.createElement("div");
46
+ container.className = "framejs-frame-container metaframe-widget-container";
47
+ root.appendChild(container);
48
+
49
+ // Footer showing the latest short URL produced by editing + saving.
50
+ const footer = document.createElement("div");
51
+ footer.style.cssText = "flex: 0 0 auto; display: none; align-items: center; gap: 6px; padding: 4px 6px; font-family: sans-serif; font-size: 12px; color: #555; background: #f7f7f7; border-top: 1px solid #e0e0e0; overflow: hidden;";
52
+ const footerLabel = document.createElement("span");
53
+ footerLabel.textContent = "Saved:";
54
+ footerLabel.style.flex = "0 0 auto";
55
+ const footerLink = document.createElement("a");
56
+ footerLink.target = "_blank";
57
+ footerLink.rel = "noopener noreferrer";
58
+ footerLink.style.cssText = "flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
59
+ const footerCopy = document.createElement("button");
60
+ footerCopy.textContent = "copy";
61
+ footerCopy.style.cssText = "flex: 0 0 auto; cursor: pointer; font-size: 11px; padding: 1px 6px;";
62
+ footer.appendChild(footerLabel);
63
+ footer.appendChild(footerLink);
64
+ footer.appendChild(footerCopy);
65
+ root.appendChild(footer);
66
+
67
+ function renderSavedUrl() {
68
+ const savedUrl = model.get("saved_url") || "";
69
+ if (savedUrl) {
70
+ footerLink.textContent = savedUrl;
71
+ footerLink.href = savedUrl;
72
+ footer.style.display = "flex";
73
+ } else {
74
+ footer.style.display = "none";
75
+ }
76
+ }
77
+ footerCopy.addEventListener("click", () => {
78
+ const savedUrl = model.get("saved_url") || "";
79
+ if (savedUrl && navigator.clipboard) {
80
+ navigator.clipboard.writeText(savedUrl);
81
+ }
82
+ });
83
+ renderSavedUrl();
84
+
85
+ const renderMetapage = await loadRenderMetapage();
86
+
87
+ let currentResult = null;
88
+
89
+ function applyAllowToIframes() {
90
+ const allow = model.get("allow") || "";
91
+ if (allow) {
92
+ container.querySelectorAll("iframe").forEach(iframe => {
93
+ iframe.allow = allow;
94
+ });
95
+ }
96
+ }
97
+
98
+ async function createMetapage() {
99
+ if (currentResult) {
100
+ currentResult.dispose();
101
+ currentResult = null;
102
+ }
103
+ container.innerHTML = "";
104
+
105
+ // The runtime URL, not the one the user typed -- see runtime_url()
106
+ // in _widget.py. Falls back to `url` so an empty trait is harmless.
107
+ const url = model.get("_runtime_url") || model.get("url");
108
+ if (!url) return;
109
+
110
+ const definition = {
111
+ version: "0.3",
112
+ metaframes: {
113
+ mf: { url },
114
+ },
115
+ };
116
+
117
+ const result = await renderMetapage({
118
+ definition,
119
+ rootDiv: container,
120
+ onOutputs: (outputs) => {
121
+ const mfOutputs = outputs.mf || {};
122
+ model.set("outputs", JSON.parse(JSON.stringify(mfOutputs)));
123
+ model.save_changes();
124
+ },
125
+ });
126
+ currentResult = result;
127
+
128
+ applyAllowToIframes();
129
+
130
+ // Push current inputs if any
131
+ const inputs = model.get("inputs");
132
+ if (inputs && Object.keys(inputs).length > 0) {
133
+ result.setInputs({ mf: inputs });
134
+ }
135
+ }
136
+
137
+ // NOTE: the message `type` below is a WIRE PROTOCOL string shared with the
138
+ // framejs editor (editor/src/.../ButtonShortenUrl.tsx). It is deliberately
139
+ // NOT renamed with the package: the editor is deployed once and talks to
140
+ // every installed version of this package, including releases that predate
141
+ // the framejs rename, so changing it here would make old installs stop
142
+ // receiving snapshot URLs.
143
+ //
144
+ // The embedded editor (same-origin to the URL-shortening worker) mints an
145
+ // expiring /j/<sha256> snapshot when the user clicks "Create expiring
146
+ // snapshot", then postMessages it here. We record it in `saved_url`
147
+ // WITHOUT touching `url`, so the live iframe the user is editing is never
148
+ // torn down and reloaded.
149
+ function expectedOrigin() {
150
+ try {
151
+ // Must be the URL the iframe actually loaded, or a snapshot
152
+ // message from the embedded editor is dropped as cross-origin.
153
+ return new URL(model.get("_runtime_url") || model.get("url") || "").origin;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+ const onMessage = (event) => {
159
+ const data = event.data;
160
+ if (!data || data.type !== "metaframe-widget:shorturl" || !data.url) {
161
+ return;
162
+ }
163
+ // Route to the correct widget (one notebook may hold several) and
164
+ // verify the message came from our metaframe's origin.
165
+ const iframe = container.querySelector("iframe");
166
+ if (!iframe || event.source !== iframe.contentWindow) return;
167
+ const origin = expectedOrigin();
168
+ if (origin && event.origin !== origin) return;
169
+
170
+ model.set("saved_url", data.url);
171
+ model.save_changes();
172
+ };
173
+ window.addEventListener("message", onMessage);
174
+
175
+ // `_runtime_url` is derived from `url`, so this covers a url change too --
176
+ // and a change that maps to the SAME frame (the .app and .io spellings of
177
+ // one URL) correctly does not tear the iframe down.
178
+ model.on("change:_runtime_url", createMetapage);
179
+ model.on("change:saved_url", renderSavedUrl);
180
+
181
+ model.on("change:inputs", () => {
182
+ if (currentResult) {
183
+ const inputs = model.get("inputs");
184
+ currentResult.setInputs({ mf: inputs });
185
+ }
186
+ });
187
+
188
+ model.on("change:width", () => {
189
+ el.style.width = model.get("width") || "100%";
190
+ });
191
+
192
+ model.on("change:height", () => {
193
+ el.style.height = model.get("height") || "400px";
194
+ });
195
+
196
+ model.on("change:allow", applyAllowToIframes);
197
+
198
+ await createMetapage();
199
+
200
+ return () => {
201
+ window.removeEventListener("message", onMessage);
202
+ if (currentResult) {
203
+ currentResult.dispose();
204
+ }
205
+ };
206
+ },
207
+ };
208
+ """
@@ -0,0 +1,151 @@
1
+ import urllib.parse
2
+
3
+ import anywidget
4
+ import traitlets
5
+
6
+ from ._esm import ESM
7
+
8
+ #: Hosts serving the framejs *account* layer. Their /j/<id> URLs are the ones
9
+ #: people copy and share, but they are not what a notebook should load.
10
+ _APP_HOSTS = frozenset({"framejs.app", "www.framejs.app"})
11
+ #: The host serving the frame *runtime*.
12
+ _RUNTIME_HOST = "framejs.io"
13
+
14
+
15
+ def runtime_url(url: str) -> str:
16
+ """Map a shareable ``framejs.app`` frame URL onto the ``framejs.io`` runtime.
17
+
18
+ ``framejs.app/j/<id>`` and ``framejs.io/j/<id>`` are the same frame, but only
19
+ framejs.io serves it as an embeddable metaframe with permissive CORS. The
20
+ framejs.app URL answers a framed request with a branded wrapper page, and its
21
+ ``/j/<id>/metaframe.json`` — which the metapage client fetches before it
22
+ renders anything — carries no ``Access-Control-Allow-Origin``. A notebook on
23
+ ``http://localhost:8888`` therefore gets::
24
+
25
+ Access to fetch at 'https://framejs.app/j/<id>/metaframe.json' from origin
26
+ 'http://localhost:8888' has been blocked by CORS policy
27
+
28
+ and no widget. Normalising here means a user can paste either URL — the one
29
+ the share panel gives them is the framejs.app one — and it just works.
30
+
31
+ Anything that is not a framejs.app ``/j/`` URL is returned unchanged, so
32
+ full ``#?js=`` URLs, framejs.io URLs and local dev stacks are untouched.
33
+ Query and fragment are preserved (``?v=<sha256>`` pins a published version,
34
+ ``#?inputs=`` feeds the frame).
35
+ """
36
+ if not url:
37
+ return url
38
+ try:
39
+ parts = urllib.parse.urlsplit(url)
40
+ except ValueError: # malformed; hand it back and let the browser complain
41
+ return url
42
+ host = (parts.hostname or "").lower()
43
+ if host not in _APP_HOSTS:
44
+ return url
45
+ if parts.path != "/j" and not parts.path.startswith("/j/"):
46
+ return url
47
+ return urllib.parse.urlunsplit(parts._replace(netloc=_RUNTIME_HOST))
48
+
49
+
50
+ class Frame(anywidget.AnyWidget):
51
+ """Widget that renders a framejs frame in an iframe (Jupyter and marimo).
52
+
53
+ Args:
54
+ url: Full frame URL (including hash params).
55
+ inputs: Dict of inputs to push to the frame.
56
+ outputs: Dict of outputs received from the frame (read-only).
57
+ width: CSS width for the widget container (default "100%").
58
+ height: CSS height for the widget container (default "400px").
59
+ allow: iframe allow attribute string (e.g. "camera; microphone").
60
+ saved_url: Latest expiring snapshot URL (/j/<sha256>) minted by the
61
+ "Create expiring snapshot" button inside the widget (read-only).
62
+ Handy for a quick capture; for anything you want to keep, Save a
63
+ durable /j/<uuid> frame and use that URL instead.
64
+ """
65
+
66
+ _esm = ESM
67
+
68
+ url = traitlets.Unicode("").tag(sync=True)
69
+ inputs = traitlets.Dict({}).tag(sync=True)
70
+ outputs = traitlets.Dict({}).tag(sync=True)
71
+ width = traitlets.Unicode("100%").tag(sync=True)
72
+ height = traitlets.Unicode("400px").tag(sync=True)
73
+ allow = traitlets.Unicode("").tag(sync=True)
74
+ # The URL the iframe ACTUALLY loads: `url` mapped through runtime_url().
75
+ # Kept as its own synced trait rather than rewriting `url`, so reading back
76
+ # `w.url` returns exactly what the caller passed. The ESM uses this for both
77
+ # the iframe src and the postMessage origin check -- those two must agree or
78
+ # snapshot URLs from the embedded editor are silently discarded.
79
+ _runtime_url = traitlets.Unicode("").tag(sync=True)
80
+ # Set by the ESM when the user clicks "Create expiring snapshot" inside the
81
+ # embedded editor. Read-only from Python's perspective; updating it does not
82
+ # reload the iframe (so editing is not interrupted).
83
+ saved_url = traitlets.Unicode("").tag(sync=True)
84
+
85
+ def __init__(self, *args, **kwargs):
86
+ super().__init__(*args, **kwargs)
87
+ # Sync layout.height/width so ipywidgets' LayoutView doesn't clear
88
+ # the styles that the ESM sets on the element.
89
+ self.layout.height = self.height
90
+ self.layout.width = self.width
91
+ self.observe(self._sync_layout_height, names=["height"])
92
+ self.observe(self._sync_layout_width, names=["width"])
93
+ self._runtime_url = runtime_url(self.url)
94
+ self.observe(self._sync_runtime_url, names=["url"])
95
+
96
+ def _sync_runtime_url(self, change):
97
+ self._runtime_url = runtime_url(change["new"])
98
+
99
+ def _sync_layout_height(self, change):
100
+ self.layout.height = change["new"]
101
+
102
+ def _sync_layout_width(self, change):
103
+ self.layout.width = change["new"]
104
+
105
+ def set_inputs(self, d: dict):
106
+ """Merge a dict into the current inputs."""
107
+ self.inputs = {**self.inputs, **d}
108
+
109
+ def set_input(self, key: str, value):
110
+ """Set a single input key."""
111
+ self.inputs = {**self.inputs, key: value}
112
+
113
+ def on_outputs_change(self, callback):
114
+ """Register a callback for when outputs change.
115
+
116
+ The callback receives a change dict with 'old' and 'new' keys.
117
+ """
118
+ self.observe(callback, names=["outputs"])
119
+
120
+ def on_saved_url_change(self, callback):
121
+ """Register a callback for when the user creates an expiring snapshot.
122
+
123
+ Fires after the user clicks "Create expiring snapshot" inside the
124
+ embedded editor. The callback receives a change dict whose 'new' key
125
+ holds the freshly-minted /j/<sha256> snapshot URL.
126
+ """
127
+ self.observe(callback, names=["saved_url"])
128
+
129
+ def pipe_to(self, target: "Frame", output_key: str, input_key: str = None):
130
+ """Connect an output of this widget to an input of another.
131
+
132
+ When this widget's output_key changes, push the value to
133
+ target's input_key (defaults to output_key if not specified).
134
+ """
135
+ if input_key is None:
136
+ input_key = output_key
137
+
138
+ def _forward(change):
139
+ new_outputs = change.get("new", {})
140
+ if output_key in new_outputs:
141
+ target.set_input(input_key, new_outputs[output_key])
142
+
143
+ self.observe(_forward, names=["outputs"])
144
+
145
+
146
+ #: Deprecated alias. The class was called ``MetaframeWidget`` while the package
147
+ #: was published as ``metaframe-widget``; both were renamed in 0.4.0. Kept so
148
+ #: notebooks and copied snippets written against the old name keep running --
149
+ #: it is the same class, not a subclass, so ``isinstance`` and ``pipe_to``
150
+ #: between the two names behave identically.
151
+ MetaframeWidget = Frame
@@ -0,0 +1,211 @@
1
+ from framejs import Frame
2
+
3
+
4
+ def test_create_widget():
5
+ w = Frame(url="https://framejs.io/#?js=abc")
6
+ assert w.url == "https://framejs.io/#?js=abc"
7
+ assert w.inputs == {}
8
+ assert w.outputs == {}
9
+ assert w.height == "400px"
10
+
11
+
12
+ def test_set_inputs():
13
+ w = Frame()
14
+ w.set_inputs({"a": 1, "b": 2})
15
+ assert w.inputs == {"a": 1, "b": 2}
16
+ w.set_inputs({"b": 3, "c": 4})
17
+ assert w.inputs == {"a": 1, "b": 3, "c": 4}
18
+
19
+
20
+ def test_set_input():
21
+ w = Frame()
22
+ w.set_input("x", 42)
23
+ assert w.inputs == {"x": 42}
24
+ w.set_input("y", "hello")
25
+ assert w.inputs == {"x": 42, "y": "hello"}
26
+
27
+
28
+ def test_on_outputs_change():
29
+ w = Frame()
30
+ changes = []
31
+ w.on_outputs_change(lambda change: changes.append(change))
32
+ w.outputs = {"key": "value"}
33
+ assert len(changes) == 1
34
+ assert changes[0]["new"] == {"key": "value"}
35
+
36
+
37
+ def test_pipe_to():
38
+ source = Frame()
39
+ sink = Frame()
40
+ source.pipe_to(sink, output_key="doubled", input_key="data")
41
+ source.outputs = {"doubled": [2, 4, 6]}
42
+ assert sink.inputs == {"data": [2, 4, 6]}
43
+
44
+
45
+ def test_pipe_to_default_key():
46
+ source = Frame()
47
+ sink = Frame()
48
+ source.pipe_to(sink, output_key="result")
49
+ source.outputs = {"result": 42}
50
+ assert sink.inputs == {"result": 42}
51
+
52
+
53
+ def test_pipe_to_ignores_unrelated_keys():
54
+ source = Frame()
55
+ sink = Frame()
56
+ source.pipe_to(sink, output_key="x")
57
+ source.outputs = {"y": 99}
58
+ assert sink.inputs == {}
59
+
60
+
61
+ def test_layout_height_synced_on_init():
62
+ """layout.height must match the height trait so ipywidgets doesn't clear it."""
63
+ w = Frame()
64
+ assert w.layout.height == "400px"
65
+ assert w.layout.width == "100%"
66
+
67
+
68
+ def test_layout_height_synced_with_custom_value():
69
+ w = Frame(height="600px", width="80%")
70
+ assert w.layout.height == "600px"
71
+ assert w.layout.width == "80%"
72
+
73
+
74
+ def test_layout_height_updates_on_change():
75
+ """Changing height trait after init must propagate to layout."""
76
+ w = Frame()
77
+ w.height = "500px"
78
+ assert w.layout.height == "500px"
79
+ w.width = "50%"
80
+ assert w.layout.width == "50%"
81
+
82
+
83
+ def test_pipe_chain_five_widgets():
84
+ """Five widgets piped in a chain propagate data end-to-end."""
85
+ w1 = Frame()
86
+ w2 = Frame()
87
+ w3 = Frame()
88
+ w4 = Frame()
89
+ w5 = Frame()
90
+
91
+ w1.pipe_to(w2, output_key="data")
92
+ w2.pipe_to(w3, output_key="data")
93
+ w3.pipe_to(w4, output_key="data")
94
+ w4.pipe_to(w5, output_key="data")
95
+
96
+ # Simulate w1 producing output
97
+ w1.outputs = {"data": [1, 2, 3]}
98
+ assert w2.inputs == {"data": [1, 2, 3]}
99
+
100
+ # Simulate w2 producing output
101
+ w2.outputs = {"data": [2, 4, 6]}
102
+ assert w3.inputs == {"data": [2, 4, 6]}
103
+
104
+ # Simulate w3 producing output
105
+ w3.outputs = {"data": [6, 12, 18]}
106
+ assert w4.inputs == {"data": [6, 12, 18]}
107
+
108
+ # Simulate w4 producing output
109
+ w4.outputs = {"data": [16, 22, 28]}
110
+ assert w5.inputs == {"data": [16, 22, 28]}
111
+
112
+
113
+ # --- the rename (0.4.0: metaframe-widget/MetaframeWidget -> framejs/Frame) ----
114
+
115
+
116
+ def test_metaframe_widget_alias_is_the_same_class():
117
+ """`MetaframeWidget` is an ALIAS, not a subclass.
118
+
119
+ Old notebooks keep working, and a widget built under either name pipes to,
120
+ and isinstance-checks against, the other.
121
+ """
122
+ from framejs import MetaframeWidget
123
+
124
+ assert MetaframeWidget is Frame
125
+
126
+ source = MetaframeWidget()
127
+ sink = Frame()
128
+ assert isinstance(source, Frame)
129
+ source.pipe_to(sink, output_key="x")
130
+ source.outputs = {"x": 1}
131
+ assert sink.inputs == {"x": 1}
132
+
133
+
134
+ def test_package_exports():
135
+ import framejs
136
+
137
+ assert sorted(framejs.__all__) == ["Frame", "MetaframeWidget", "runtime_url"]
138
+
139
+
140
+ # --- framejs.app -> framejs.io normalisation ---------------------------------
141
+ # A notebook must load the RUNTIME (framejs.io), not the account layer: only
142
+ # framejs.io serves /j/<id>/metaframe.json with CORS headers, and the metapage
143
+ # client fetches that before it renders anything. Pasting the framejs.app URL —
144
+ # which is the one the share panel hands out — otherwise fails with a bare CORS
145
+ # error and no widget.
146
+
147
+ import pytest
148
+
149
+ from framejs import runtime_url
150
+
151
+
152
+ @pytest.mark.parametrize(
153
+ "given,expected",
154
+ [
155
+ # the case from the bug report
156
+ (
157
+ "https://framejs.app/j/019f61392e0778f8aba8dd7ffe35a83c",
158
+ "https://framejs.io/j/019f61392e0778f8aba8dd7ffe35a83c",
159
+ ),
160
+ # a pinned version and hash inputs must survive the swap
161
+ (
162
+ "https://framejs.app/j/abc?v=deadbeef#?inputs=eyJhIjoxfQ==",
163
+ "https://framejs.io/j/abc?v=deadbeef#?inputs=eyJhIjoxfQ==",
164
+ ),
165
+ ("https://www.framejs.app/j/abc", "https://framejs.io/j/abc"),
166
+ ("https://FrameJS.App/j/abc", "https://framejs.io/j/abc"),
167
+ ],
168
+ )
169
+ def test_app_frame_urls_are_mapped_to_the_runtime(given, expected):
170
+ assert runtime_url(given) == expected
171
+
172
+
173
+ @pytest.mark.parametrize(
174
+ "url",
175
+ [
176
+ "",
177
+ # already the runtime
178
+ "https://framejs.io/j/abc",
179
+ # a full inline-code URL has no /j/ path
180
+ "https://framejs.io/#?js=YWxwaGE=",
181
+ # not a frame route: /f/ is the file endpoint
182
+ "https://framejs.app/f/abc",
183
+ "https://framejs.app/dashboard",
184
+ # must not be fooled by a lookalike host
185
+ "https://framejs.app.evil.com/j/abc",
186
+ "https://notframejs.app/j/abc",
187
+ # a local dev stack has no framejs.io counterpart we could name
188
+ "https://framejs-app.localhost:13829/j/abc",
189
+ ],
190
+ )
191
+ def test_other_urls_are_left_alone(url):
192
+ assert runtime_url(url) == url
193
+
194
+
195
+ def test_widget_exposes_both_the_given_and_the_runtime_url():
196
+ app = "https://framejs.app/j/019f61392e0778f8aba8dd7ffe35a83c"
197
+ io = "https://framejs.io/j/019f61392e0778f8aba8dd7ffe35a83c"
198
+
199
+ w = Frame(url=app)
200
+ # What the caller passed is read back unchanged...
201
+ assert w.url == app
202
+ # ...while the iframe loads the runtime.
203
+ assert w._runtime_url == io
204
+ # The ESM needs it over the wire, or it falls back to the CORS-blocked URL.
205
+ assert "_runtime_url" in w.keys
206
+
207
+ # It tracks later assignments too.
208
+ w.url = "https://framejs.app/j/other"
209
+ assert w._runtime_url == "https://framejs.io/j/other"
210
+ w.url = "https://framejs.io/#?js=YWxwaGE="
211
+ assert w._runtime_url == w.url