metaframe-widget 0.1.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__/
@@ -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.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: metaframe-widget
3
+ Version: 0.1.0
4
+ Summary: anywidget for embedding metaframe URLs in Jupyter and marimo notebooks
5
+ Project-URL: Homepage, https://github.com/metapages/metaframe-js
6
+ Project-URL: Repository, https://github.com/metapages/metaframe-js
7
+ Project-URL: Issues, https://github.com/metapages/metaframe-js/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Framework :: Jupyter
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: anywidget>=0.9.0
21
+ Requires-Dist: traitlets>=5.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Provides-Extra: jupyter
25
+ Requires-Dist: jupyterlab; extra == 'jupyter'
26
+ Provides-Extra: marimo
27
+ Requires-Dist: marimo; extra == 'marimo'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # metaframe-widget
31
+
32
+ An [anywidget](https://anywidget.dev/) for embedding [metaframe](https://metapages.org/) URLs in Jupyter and marimo notebooks.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install metaframe-widget
38
+ ```
39
+
40
+ With environment extras:
41
+
42
+ ```bash
43
+ pip install "metaframe-widget[jupyter]" # includes jupyterlab
44
+ pip install "metaframe-widget[marimo]" # includes marimo
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ### Jupyter
50
+
51
+ ```python
52
+ from metaframe_widget import MetaframeWidget
53
+
54
+ w = MetaframeWidget(url="https://js.mtfm.io/#?js=...", height="300px")
55
+ w
56
+ ```
57
+
58
+ ### marimo
59
+
60
+ ```python
61
+ import marimo as mo
62
+ from metaframe_widget import MetaframeWidget
63
+
64
+ w = MetaframeWidget(url="https://js.mtfm.io/#?js=...", height="300px")
65
+ mo.ui.anywidget(w)
66
+ ```
67
+
68
+ ## Create from inline JavaScript
69
+
70
+ ```python
71
+ w = MetaframeWidget.from_code("""
72
+ setOutput("result", getInput("data") * 2);
73
+ """, height="300px")
74
+ ```
75
+
76
+ ## API reference
77
+
78
+ | Parameter | Type | Default | Description |
79
+ |-----------|------|---------|-------------|
80
+ | `url` | `str` | `""` | Full metaframe URL (including hash params) |
81
+ | `inputs` | `dict` | `{}` | Dict of inputs to push to the metaframe |
82
+ | `outputs` | `dict` | `{}` | Dict of outputs received from the metaframe (read-only) |
83
+ | `width` | `str` | `"100%"` | CSS width for the widget container |
84
+ | `height` | `str` | `"400px"` | CSS height for the widget container |
85
+ | `allow` | `str` | `""` | iframe `allow` attribute (e.g. `"camera; microphone"`) |
86
+
87
+ ### Methods
88
+
89
+ - **`set_inputs(d)`** — merge a dict into current inputs
90
+ - **`set_input(key, value)`** — set a single input key
91
+ - **`on_outputs_change(callback)`** — register a callback for output changes
92
+ - **`from_code(js_code, **kwargs)`** — class method to create a widget from inline JS
93
+ - **`pipe_to(target, output_key, input_key=None)`** — connect an output to another widget's input
94
+
95
+ ## Piping widgets
96
+
97
+ ```python
98
+ source = MetaframeWidget(url="...")
99
+ sink = MetaframeWidget(url="...")
100
+ source.pipe_to(sink, output_key="result", input_key="data")
101
+ ```
102
+
103
+ ## Links
104
+
105
+ - [GitHub](https://github.com/metapages/metaframe-js)
106
+ - [Jupyter examples](https://github.com/metapages/metaframe-js/tree/main/examples/jupyter)
107
+ - [marimo examples](https://github.com/metapages/metaframe-js/tree/main/examples/marimo)
@@ -0,0 +1,78 @@
1
+ # metaframe-widget
2
+
3
+ An [anywidget](https://anywidget.dev/) for embedding [metaframe](https://metapages.org/) URLs in Jupyter and marimo notebooks.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install metaframe-widget
9
+ ```
10
+
11
+ With environment extras:
12
+
13
+ ```bash
14
+ pip install "metaframe-widget[jupyter]" # includes jupyterlab
15
+ pip install "metaframe-widget[marimo]" # includes marimo
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ### Jupyter
21
+
22
+ ```python
23
+ from metaframe_widget import MetaframeWidget
24
+
25
+ w = MetaframeWidget(url="https://js.mtfm.io/#?js=...", height="300px")
26
+ w
27
+ ```
28
+
29
+ ### marimo
30
+
31
+ ```python
32
+ import marimo as mo
33
+ from metaframe_widget import MetaframeWidget
34
+
35
+ w = MetaframeWidget(url="https://js.mtfm.io/#?js=...", height="300px")
36
+ mo.ui.anywidget(w)
37
+ ```
38
+
39
+ ## Create from inline JavaScript
40
+
41
+ ```python
42
+ w = MetaframeWidget.from_code("""
43
+ setOutput("result", getInput("data") * 2);
44
+ """, height="300px")
45
+ ```
46
+
47
+ ## API reference
48
+
49
+ | Parameter | Type | Default | Description |
50
+ |-----------|------|---------|-------------|
51
+ | `url` | `str` | `""` | Full metaframe URL (including hash params) |
52
+ | `inputs` | `dict` | `{}` | Dict of inputs to push to the metaframe |
53
+ | `outputs` | `dict` | `{}` | Dict of outputs received from the metaframe (read-only) |
54
+ | `width` | `str` | `"100%"` | CSS width for the widget container |
55
+ | `height` | `str` | `"400px"` | CSS height for the widget container |
56
+ | `allow` | `str` | `""` | iframe `allow` attribute (e.g. `"camera; microphone"`) |
57
+
58
+ ### Methods
59
+
60
+ - **`set_inputs(d)`** — merge a dict into current inputs
61
+ - **`set_input(key, value)`** — set a single input key
62
+ - **`on_outputs_change(callback)`** — register a callback for output changes
63
+ - **`from_code(js_code, **kwargs)`** — class method to create a widget from inline JS
64
+ - **`pipe_to(target, output_key, input_key=None)`** — connect an output to another widget's input
65
+
66
+ ## Piping widgets
67
+
68
+ ```python
69
+ source = MetaframeWidget(url="...")
70
+ sink = MetaframeWidget(url="...")
71
+ source.pipe_to(sink, output_key="result", input_key="data")
72
+ ```
73
+
74
+ ## Links
75
+
76
+ - [GitHub](https://github.com/metapages/metaframe-js)
77
+ - [Jupyter examples](https://github.com/metapages/metaframe-js/tree/main/examples/jupyter)
78
+ - [marimo examples](https://github.com/metapages/metaframe-js/tree/main/examples/marimo)
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "metaframe-widget"
7
+ version = "0.1.0"
8
+ description = "anywidget for embedding metaframe URLs 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
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.8",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "License :: OSI Approved :: MIT License",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/metapages/metaframe-js"
30
+ Repository = "https://github.com/metapages/metaframe-js"
31
+ Issues = "https://github.com/metapages/metaframe-js/issues"
32
+
33
+ [project.optional-dependencies]
34
+ jupyter = ["jupyterlab"]
35
+ marimo = ["marimo"]
36
+ dev = ["pytest"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/metaframe_widget"]
@@ -0,0 +1,3 @@
1
+ from ._widget import MetaframeWidget
2
+
3
+ __all__ = ["MetaframeWidget"]
@@ -0,0 +1,111 @@
1
+ ESM = """
2
+ const CDN_URL = "https://cdn.jsdelivr.net/npm/@metapages/metapage@1.10.6/+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
+ // Size el explicitly so marimo and Jupyter honour width/height
17
+ el.style.display = "block";
18
+ el.style.boxSizing = "border-box";
19
+ el.style.width = model.get("width") || "100%";
20
+ el.style.height = model.get("height") || "400px";
21
+
22
+ // Inject CSS to remove iframe borders and inline gaps
23
+ const style = document.createElement("style");
24
+ style.textContent = ".metaframe-widget-container { box-sizing: border-box; width: 100%; height: 100%; } .metaframe-widget-container iframe { border: none; display: block; margin: 0; padding: 0; box-sizing: border-box; width: 100%; height: 100%; }";
25
+ el.appendChild(style);
26
+
27
+ const container = document.createElement("div");
28
+ container.className = "metaframe-widget-container";
29
+ container.style.width = "100%";
30
+ container.style.height = "100%";
31
+ el.appendChild(container);
32
+
33
+ const renderMetapage = await loadRenderMetapage();
34
+
35
+ let currentResult = null;
36
+
37
+ function applyAllowToIframes() {
38
+ const allow = model.get("allow") || "";
39
+ if (allow) {
40
+ container.querySelectorAll("iframe").forEach(iframe => {
41
+ iframe.allow = allow;
42
+ });
43
+ }
44
+ }
45
+
46
+ async function createMetapage() {
47
+ if (currentResult) {
48
+ currentResult.dispose();
49
+ currentResult = null;
50
+ }
51
+ container.innerHTML = "";
52
+
53
+ const url = model.get("url");
54
+ if (!url) return;
55
+
56
+ const definition = {
57
+ version: "0.3",
58
+ metaframes: {
59
+ mf: { url },
60
+ },
61
+ };
62
+
63
+ const result = await renderMetapage({
64
+ definition,
65
+ rootDiv: container,
66
+ onOutputs: (outputs) => {
67
+ const mfOutputs = outputs.mf || {};
68
+ model.set("outputs", JSON.parse(JSON.stringify(mfOutputs)));
69
+ model.save_changes();
70
+ },
71
+ });
72
+ currentResult = result;
73
+
74
+ applyAllowToIframes();
75
+
76
+ // Push current inputs if any
77
+ const inputs = model.get("inputs");
78
+ if (inputs && Object.keys(inputs).length > 0) {
79
+ result.setInputs({ mf: inputs });
80
+ }
81
+ }
82
+
83
+ model.on("change:url", createMetapage);
84
+
85
+ model.on("change:inputs", () => {
86
+ if (currentResult) {
87
+ const inputs = model.get("inputs");
88
+ currentResult.setInputs({ mf: inputs });
89
+ }
90
+ });
91
+
92
+ model.on("change:width", () => {
93
+ el.style.width = model.get("width") || "100%";
94
+ });
95
+
96
+ model.on("change:height", () => {
97
+ el.style.height = model.get("height") || "400px";
98
+ });
99
+
100
+ model.on("change:allow", applyAllowToIframes);
101
+
102
+ await createMetapage();
103
+
104
+ return () => {
105
+ if (currentResult) {
106
+ currentResult.dispose();
107
+ }
108
+ };
109
+ },
110
+ };
111
+ """
@@ -0,0 +1,83 @@
1
+ import base64
2
+ import urllib.parse
3
+
4
+ import anywidget
5
+ import traitlets
6
+
7
+ from ._esm import ESM
8
+
9
+
10
+ class MetaframeWidget(anywidget.AnyWidget):
11
+ """Widget that renders a metaframe iframe (works in Jupyter and marimo).
12
+
13
+ Args:
14
+ url: Full metaframe URL (including hash params).
15
+ inputs: Dict of inputs to push to the metaframe.
16
+ outputs: Dict of outputs received from the metaframe (read-only).
17
+ width: CSS width for the widget container (default "100%").
18
+ height: CSS height for the widget container (default "400px").
19
+ allow: iframe allow attribute string (e.g. "camera; microphone").
20
+ """
21
+
22
+ _esm = ESM
23
+
24
+ url = traitlets.Unicode("").tag(sync=True)
25
+ inputs = traitlets.Dict({}).tag(sync=True)
26
+ outputs = traitlets.Dict({}).tag(sync=True)
27
+ width = traitlets.Unicode("100%").tag(sync=True)
28
+ height = traitlets.Unicode("400px").tag(sync=True)
29
+ allow = traitlets.Unicode("").tag(sync=True)
30
+
31
+ def set_inputs(self, d: dict):
32
+ """Merge a dict into the current inputs."""
33
+ self.inputs = {**self.inputs, **d}
34
+
35
+ def set_input(self, key: str, value):
36
+ """Set a single input key."""
37
+ self.inputs = {**self.inputs, key: value}
38
+
39
+ def on_outputs_change(self, callback):
40
+ """Register a callback for when outputs change.
41
+
42
+ The callback receives a change dict with 'old' and 'new' keys.
43
+ """
44
+ self.observe(callback, names=["outputs"])
45
+
46
+ @classmethod
47
+ def from_code(
48
+ cls,
49
+ js_code: str,
50
+ width: str = "100%",
51
+ height: str = "400px",
52
+ allow: str = "",
53
+ **kwargs,
54
+ ) -> "MetaframeWidget":
55
+ """Create a MetaframeWidget from raw JavaScript code.
56
+
57
+ Encodes the code into a js.mtfm.io URL with the code in the hash.
58
+
59
+ Args:
60
+ js_code: JavaScript source for the metaframe.
61
+ width: CSS width for the widget container (default "100%").
62
+ height: CSS height for the widget container (default "400px").
63
+ allow: iframe allow attribute string (e.g. "camera; microphone").
64
+ """
65
+ encoded = base64.b64encode(js_code.encode()).decode()
66
+ url = f"https://js.mtfm.io/#?js={urllib.parse.quote(encoded)}"
67
+ return cls(url=url, width=width, height=height, allow=allow, **kwargs)
68
+
69
+ def pipe_to(self, target: "MetaframeWidget", output_key: str, input_key: str = None):
70
+ """Connect an output of this widget to an input of another.
71
+
72
+ When this widget's output_key changes, push the value to
73
+ target's input_key (defaults to output_key if not specified).
74
+ """
75
+ if input_key is None:
76
+ input_key = output_key
77
+
78
+ def _forward(change):
79
+ new_outputs = change.get("new", {})
80
+ if output_key in new_outputs:
81
+ target.set_input(input_key, new_outputs[output_key])
82
+
83
+ self.observe(_forward, names=["outputs"])
@@ -0,0 +1,105 @@
1
+ import base64
2
+ import urllib.parse
3
+
4
+ from metaframe_widget import MetaframeWidget
5
+
6
+
7
+ def test_create_widget():
8
+ w = MetaframeWidget(url="https://js.mtfm.io/#?js=abc")
9
+ assert w.url == "https://js.mtfm.io/#?js=abc"
10
+ assert w.inputs == {}
11
+ assert w.outputs == {}
12
+ assert w.height == "400px"
13
+
14
+
15
+ def test_set_inputs():
16
+ w = MetaframeWidget()
17
+ w.set_inputs({"a": 1, "b": 2})
18
+ assert w.inputs == {"a": 1, "b": 2}
19
+ w.set_inputs({"b": 3, "c": 4})
20
+ assert w.inputs == {"a": 1, "b": 3, "c": 4}
21
+
22
+
23
+ def test_set_input():
24
+ w = MetaframeWidget()
25
+ w.set_input("x", 42)
26
+ assert w.inputs == {"x": 42}
27
+ w.set_input("y", "hello")
28
+ assert w.inputs == {"x": 42, "y": "hello"}
29
+
30
+
31
+ def test_from_code():
32
+ code = 'console.log("hello");'
33
+ w = MetaframeWidget.from_code(code)
34
+ encoded = base64.b64encode(code.encode()).decode()
35
+ expected_url = f"https://js.mtfm.io/#?js={urllib.parse.quote(encoded)}"
36
+ assert w.url == expected_url
37
+
38
+
39
+ def test_from_code_with_kwargs():
40
+ w = MetaframeWidget.from_code("code", height="600px")
41
+ assert w.height == "600px"
42
+ assert "js.mtfm.io" in w.url
43
+
44
+
45
+ def test_on_outputs_change():
46
+ w = MetaframeWidget()
47
+ changes = []
48
+ w.on_outputs_change(lambda change: changes.append(change))
49
+ w.outputs = {"key": "value"}
50
+ assert len(changes) == 1
51
+ assert changes[0]["new"] == {"key": "value"}
52
+
53
+
54
+ def test_pipe_to():
55
+ source = MetaframeWidget()
56
+ sink = MetaframeWidget()
57
+ source.pipe_to(sink, output_key="doubled", input_key="data")
58
+ source.outputs = {"doubled": [2, 4, 6]}
59
+ assert sink.inputs == {"data": [2, 4, 6]}
60
+
61
+
62
+ def test_pipe_to_default_key():
63
+ source = MetaframeWidget()
64
+ sink = MetaframeWidget()
65
+ source.pipe_to(sink, output_key="result")
66
+ source.outputs = {"result": 42}
67
+ assert sink.inputs == {"result": 42}
68
+
69
+
70
+ def test_pipe_to_ignores_unrelated_keys():
71
+ source = MetaframeWidget()
72
+ sink = MetaframeWidget()
73
+ source.pipe_to(sink, output_key="x")
74
+ source.outputs = {"y": 99}
75
+ assert sink.inputs == {}
76
+
77
+
78
+ def test_pipe_chain_five_widgets():
79
+ """Five widgets piped in a chain propagate data end-to-end."""
80
+ w1 = MetaframeWidget()
81
+ w2 = MetaframeWidget()
82
+ w3 = MetaframeWidget()
83
+ w4 = MetaframeWidget()
84
+ w5 = MetaframeWidget()
85
+
86
+ w1.pipe_to(w2, output_key="data")
87
+ w2.pipe_to(w3, output_key="data")
88
+ w3.pipe_to(w4, output_key="data")
89
+ w4.pipe_to(w5, output_key="data")
90
+
91
+ # Simulate w1 producing output
92
+ w1.outputs = {"data": [1, 2, 3]}
93
+ assert w2.inputs == {"data": [1, 2, 3]}
94
+
95
+ # Simulate w2 producing output
96
+ w2.outputs = {"data": [2, 4, 6]}
97
+ assert w3.inputs == {"data": [2, 4, 6]}
98
+
99
+ # Simulate w3 producing output
100
+ w3.outputs = {"data": [6, 12, 18]}
101
+ assert w4.inputs == {"data": [6, 12, 18]}
102
+
103
+ # Simulate w4 producing output
104
+ w4.outputs = {"data": [16, 22, 28]}
105
+ assert w5.inputs == {"data": [16, 22, 28]}