cositos 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.
- cositos/__init__.py +40 -0
- cositos/buffers.py +129 -0
- cositos/contrib/__init__.py +13 -0
- cositos/contrib/controls.py +175 -0
- cositos/contrib/harvest.py +86 -0
- cositos/embed.py +128 -0
- cositos/jupyter.py +98 -0
- cositos/model.py +140 -0
- cositos/protocol.py +144 -0
- cositos/serialize.py +227 -0
- cositos/transport.py +35 -0
- cositos-0.1.0.dist-info/METADATA +147 -0
- cositos-0.1.0.dist-info/RECORD +16 -0
- cositos-0.1.0.dist-info/WHEEL +4 -0
- cositos-0.1.0.dist-info/licenses/LICENSE +21 -0
- cositos-0.1.0.dist-info/licenses/NOTICE +51 -0
cositos/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""cositos: binding-free anywidget-style backend core."""
|
|
2
|
+
|
|
3
|
+
from cositos.buffers import put_buffers, remove_buffers
|
|
4
|
+
from cositos.embed import embed_html, embed_snippet, write_html
|
|
5
|
+
from cositos.model import Widget
|
|
6
|
+
from cositos.protocol import (
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
build_comm_open,
|
|
9
|
+
build_custom,
|
|
10
|
+
build_update,
|
|
11
|
+
parse_message,
|
|
12
|
+
)
|
|
13
|
+
from cositos.serialize import (
|
|
14
|
+
check_references,
|
|
15
|
+
dump_document,
|
|
16
|
+
dump_model,
|
|
17
|
+
load_document,
|
|
18
|
+
load_model,
|
|
19
|
+
)
|
|
20
|
+
from cositos.transport import Transport
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"PROTOCOL_VERSION",
|
|
24
|
+
"Transport",
|
|
25
|
+
"Widget",
|
|
26
|
+
"build_comm_open",
|
|
27
|
+
"build_custom",
|
|
28
|
+
"build_update",
|
|
29
|
+
"check_references",
|
|
30
|
+
"dump_document",
|
|
31
|
+
"dump_model",
|
|
32
|
+
"embed_html",
|
|
33
|
+
"embed_snippet",
|
|
34
|
+
"load_document",
|
|
35
|
+
"load_model",
|
|
36
|
+
"parse_message",
|
|
37
|
+
"put_buffers",
|
|
38
|
+
"remove_buffers",
|
|
39
|
+
"write_html",
|
|
40
|
+
]
|
cositos/buffers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Binary-buffer split/merge, faithful to widget protocol v2 (nested buffer_paths).
|
|
2
|
+
|
|
3
|
+
A "binary" value is ``bytes``, ``bytearray``, or ``memoryview``. On the wire, binary
|
|
4
|
+
values are stripped out of the JSON state into a parallel ``buffers`` list, and their
|
|
5
|
+
locations recorded in ``buffer_paths``. A path ending in a dict key is *removed* from
|
|
6
|
+
the state; a path ending in a list index is replaced by ``None`` so positions are kept.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
BinaryType = (bytes, bytearray, memoryview)
|
|
16
|
+
|
|
17
|
+
#: Max container nesting depth `remove_buffers` will descend before raising a clear error
|
|
18
|
+
#: (rather than an opaque ``RecursionError``). Deeper widget state is pathological.
|
|
19
|
+
_MAX_DEPTH = 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _items(substate: Any) -> Iterable[tuple[Any, Any]]:
|
|
23
|
+
"""Yield ``(key, value)`` pairs for a list (index keys) or dict (str keys)."""
|
|
24
|
+
if isinstance(substate, (list, tuple)):
|
|
25
|
+
return enumerate(substate)
|
|
26
|
+
return substate.items() # type: ignore[no-any-return]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _clone(substate: Any) -> Any:
|
|
30
|
+
return list(substate) if isinstance(substate, (list, tuple)) else dict(substate)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_binary(clone: Any, key: Any) -> None:
|
|
34
|
+
"""Blank a binary slot: ``None`` for list indices, remove for dict keys."""
|
|
35
|
+
if isinstance(clone, list):
|
|
36
|
+
clone[key] = None
|
|
37
|
+
else:
|
|
38
|
+
del clone[key]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _handle_item(
|
|
42
|
+
substate: Any,
|
|
43
|
+
clone: Any,
|
|
44
|
+
key: Any,
|
|
45
|
+
value: Any,
|
|
46
|
+
path: list[Any],
|
|
47
|
+
buffer_paths: list[list[Any]],
|
|
48
|
+
buffers: list[Any],
|
|
49
|
+
ancestors: tuple[int, ...],
|
|
50
|
+
depth: int,
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Process one ``(key, value)``; return the (possibly newly created) clone."""
|
|
53
|
+
if isinstance(value, BinaryType):
|
|
54
|
+
clone = clone if clone is not None else _clone(substate)
|
|
55
|
+
_extract_binary(clone, key)
|
|
56
|
+
buffers.append(value)
|
|
57
|
+
buffer_paths.append([*path, key])
|
|
58
|
+
elif isinstance(value, (list, tuple, dict)):
|
|
59
|
+
new_value = _separate(value, [*path, key], buffer_paths, buffers, ancestors, depth + 1)
|
|
60
|
+
if new_value is not value:
|
|
61
|
+
clone = clone if clone is not None else _clone(substate)
|
|
62
|
+
clone[key] = new_value
|
|
63
|
+
return clone
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _separate(
|
|
67
|
+
substate: Any,
|
|
68
|
+
path: list[Any],
|
|
69
|
+
buffer_paths: list[list[Any]],
|
|
70
|
+
buffers: list[Any],
|
|
71
|
+
ancestors: tuple[int, ...] = (),
|
|
72
|
+
depth: int = 0,
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""Recurse into dicts/lists, extracting binary values. Returns a cloned substate.
|
|
75
|
+
|
|
76
|
+
Clones a container only when it actually changes, mirroring the ipywidgets
|
|
77
|
+
algorithm so untouched subtrees keep their identity. A container that appears among
|
|
78
|
+
its own ancestors is a cycle, and nesting beyond :data:`_MAX_DEPTH` is capped — both
|
|
79
|
+
raise a clear :class:`ValueError` naming the path rather than a ``RecursionError``
|
|
80
|
+
(cositos-915). Shared but acyclic subtrees (a DAG) are fine: only the current
|
|
81
|
+
ancestor chain is checked, not every visited node.
|
|
82
|
+
"""
|
|
83
|
+
if not isinstance(substate, (list, tuple, dict)):
|
|
84
|
+
return substate
|
|
85
|
+
if depth > _MAX_DEPTH:
|
|
86
|
+
raise ValueError(f"state nesting exceeds {_MAX_DEPTH} levels at path {path}")
|
|
87
|
+
if id(substate) in ancestors:
|
|
88
|
+
raise ValueError(f"cyclic reference detected in state at path {path}")
|
|
89
|
+
ancestors = (*ancestors, id(substate))
|
|
90
|
+
|
|
91
|
+
clone: Any = None
|
|
92
|
+
for key, value in _items(substate):
|
|
93
|
+
clone = _handle_item(
|
|
94
|
+
substate, clone, key, value, path, buffer_paths, buffers, ancestors, depth
|
|
95
|
+
)
|
|
96
|
+
return clone if clone is not None else substate
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def remove_buffers(state: Any) -> tuple[Any, list[list[Any]], list[Any]]:
|
|
100
|
+
"""Return ``(state_without_buffers, buffer_paths, buffers)``.
|
|
101
|
+
|
|
102
|
+
Nesting is capped at :data:`_MAX_DEPTH`; the interpreter recursion limit is
|
|
103
|
+
temporarily raised so that cap (a clear error) trips before a raw ``RecursionError``.
|
|
104
|
+
"""
|
|
105
|
+
buffer_paths: list[list[Any]] = []
|
|
106
|
+
buffers: list[Any] = []
|
|
107
|
+
needed = (_MAX_DEPTH + 2) * 2 + 200 # ~2 frames per nesting level, plus headroom
|
|
108
|
+
old_limit = sys.getrecursionlimit()
|
|
109
|
+
try:
|
|
110
|
+
if old_limit < needed:
|
|
111
|
+
sys.setrecursionlimit(needed)
|
|
112
|
+
stripped = _separate(state, [], buffer_paths, buffers)
|
|
113
|
+
finally:
|
|
114
|
+
sys.setrecursionlimit(old_limit)
|
|
115
|
+
return stripped, buffer_paths, buffers
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def put_buffers(state: Any, buffer_paths: list[list[Any]], buffers: list[Any]) -> None:
|
|
119
|
+
"""Inverse of :func:`remove_buffers`; mutates ``state`` in place.
|
|
120
|
+
|
|
121
|
+
``buffer_paths`` and ``buffers`` must be the same length (``strict=True``); a mismatch
|
|
122
|
+
raises :class:`ValueError` rather than silently leaving a placeholder in ``state`` or
|
|
123
|
+
dropping a buffer (cositos-y07). This matches :func:`encode_buffers_base64`.
|
|
124
|
+
"""
|
|
125
|
+
for path, buffer in zip(buffer_paths, buffers, strict=True):
|
|
126
|
+
obj = state
|
|
127
|
+
for key in path[:-1]:
|
|
128
|
+
obj = obj[key]
|
|
129
|
+
obj[path[-1]] = buffer
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Optional, Python-only conveniences that sit *outside* the fixture-certified core.
|
|
2
|
+
|
|
3
|
+
Everything under :mod:`cositos.contrib` may depend on the wider Python widget ecosystem
|
|
4
|
+
(notably ``ipywidgets``). None of it is imported by :mod:`cositos` itself, so the pure,
|
|
5
|
+
cross-language-portable core stays free of those dependencies. Import from here explicitly:
|
|
6
|
+
|
|
7
|
+
from cositos.contrib import harvest, harvest_html
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from cositos.contrib.controls import dropdown, hbox, int_slider, vbox
|
|
11
|
+
from cositos.contrib.harvest import harvest, harvest_html
|
|
12
|
+
|
|
13
|
+
__all__ = ["harvest", "harvest_html", "int_slider", "dropdown", "vbox", "hbox"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""A real, unmodified ``@jupyter-widgets/controls``/``base`` catalog — an optional extension.
|
|
2
|
+
|
|
3
|
+
cositos' anti-goal is *reimplementing* the ipywidgets widget zoo (``docs/widgets.md``);
|
|
4
|
+
this module does not reimplement it. It reuses the real frontend verbatim by building
|
|
5
|
+
:data:`~cositos.serialize.ModelEntry` state dicts that carry the zoo's own identity
|
|
6
|
+
(``_model_name``/``_model_module``/``_view_name``/``_view_module`` etc., pinned at module
|
|
7
|
+
version ``2.0.0`` — the same choice already made by ``examples/composition/build.py``).
|
|
8
|
+
``cositos.protocol.build_comm_open``/``cositos.model.Widget.send_state`` already merge
|
|
9
|
+
``{**anywidget_defaults, **state}``, so a caller-supplied real-widget identity reaches the
|
|
10
|
+
wire with zero core changes
|
|
11
|
+
(``.wai/projects/cositos-core/research/2026-07-09-research-real-controls-widgets-as-optional-extension.md``,
|
|
12
|
+
findings 1–3).
|
|
13
|
+
|
|
14
|
+
Scope is intentionally trimmed (YAGNI) to exactly ``IntSlider``, ``Dropdown``, ``VBox``,
|
|
15
|
+
``HBox`` and their required companion models — see
|
|
16
|
+
``.wai/projects/cositos-core/designs/2026-07-09-design-controls-catalog-schema-and-scope.md``
|
|
17
|
+
for the full catalog schema and the id-uniqueness rule this module implements (every call
|
|
18
|
+
mints fresh companion-model ids; the catalog's own placeholder strings are never reused as
|
|
19
|
+
real ids — :func:`cositos.serialize.dump_document` rejects duplicate ``model_id``s).
|
|
20
|
+
|
|
21
|
+
**No ``ipywidgets``/``anywidget`` Python package is required.** This module builds plain
|
|
22
|
+
state dicts from a static JSON catalog (``fixtures/controls-catalog.json``) plus the
|
|
23
|
+
stdlib (``json``, ``uuid``) — it never imports either package (see
|
|
24
|
+
``tests/test_contrib_controls.py::test_module_imports_no_optional_widget_packages`` for
|
|
25
|
+
the automated guard). Manually re-verified 2026-07-09 in a fresh venv with neither
|
|
26
|
+
``ipywidgets`` nor ``anywidget`` installed (``pip install -e .`` only): building and
|
|
27
|
+
serializing an ``int_slider``/``vbox`` tree through :func:`cositos.serialize.dump_document`
|
|
28
|
+
and :func:`cositos.embed.embed_html` succeeded.
|
|
29
|
+
|
|
30
|
+
**Deliberately exempt from an OpenSpec requirement.** This mirrors
|
|
31
|
+
:mod:`cositos.contrib.harvest`, the existing contrib precedent: contrib modules are
|
|
32
|
+
optional, additive wrappers over the pure core (already OpenSpec-covered under
|
|
33
|
+
``openspec/specs/{embed,protocol,serialization}``) and do not themselves gain a spec.
|
|
34
|
+
|
|
35
|
+
**Correction to the design note, found by the real-browser check this ticket's acceptance
|
|
36
|
+
criteria required (not by reading the source alone):** ``Dropdown``'s ``options``,
|
|
37
|
+
``value``, and ``label`` traits carry no ``sync=True`` tag in ipywidgets
|
|
38
|
+
(``widget_selection.py``) — only ``_options_labels`` (label strings) and ``index`` are on
|
|
39
|
+
the wire. A first catalog draft that put ``options``/``value`` straight into state
|
|
40
|
+
rendered an empty, unselectable ``<select>`` in a real browser; :func:`dropdown` below
|
|
41
|
+
translates its ergonomic ``options``/``value`` parameters into the real
|
|
42
|
+
``_options_labels``/``index`` wire fields.
|
|
43
|
+
|
|
44
|
+
**Accepted, documented risk (unresolved from the research note):** the pinned
|
|
45
|
+
``2.0.0`` module version for ``@jupyter-widgets/controls``/``base`` is verified against
|
|
46
|
+
the CDN ``@jupyter-widgets/html-manager`` (static export) and a bare subprocess
|
|
47
|
+
``ipykernel`` comm (live kernel) — *not* against a real, currently-installed JupyterLab's
|
|
48
|
+
bundled ``@jupyter-widgets`` frontend extension version. Treat that combination as an
|
|
49
|
+
open risk specifically for the live-in-JupyterLab case until it is checked.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import uuid
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any
|
|
58
|
+
|
|
59
|
+
from cositos.serialize import ModelEntry
|
|
60
|
+
|
|
61
|
+
__all__ = ["int_slider", "dropdown", "vbox", "hbox"]
|
|
62
|
+
|
|
63
|
+
_CATALOG_PATH = (
|
|
64
|
+
Path(__file__).resolve().parent.parent.parent.parent / "fixtures" / "controls-catalog.json"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_catalog() -> dict[str, Any]:
|
|
69
|
+
return json.loads(_CATALOG_PATH.read_text()) # type: ignore[no-any-return]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_CATALOG = _load_catalog()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _mint_id(root_id: str, role: str) -> str:
|
|
76
|
+
return f"{root_id}-{role}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _identity(spec: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"_model_name": spec["model_name"],
|
|
82
|
+
"_model_module": spec["model_module"],
|
|
83
|
+
"_model_module_version": spec["model_module_version"],
|
|
84
|
+
"_view_name": spec["view_name"],
|
|
85
|
+
"_view_module": spec["view_module"],
|
|
86
|
+
"_view_module_version": spec["view_module_version"],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build(
|
|
91
|
+
catalog_key: str,
|
|
92
|
+
overrides: dict[str, Any],
|
|
93
|
+
*,
|
|
94
|
+
model_id: str | None = None,
|
|
95
|
+
) -> list[ModelEntry]:
|
|
96
|
+
"""Build one catalog entry's widget + its freshly id'd companion models.
|
|
97
|
+
|
|
98
|
+
The widget's own entry is always first in the returned list, followed by one entry
|
|
99
|
+
per companion (order matches the catalog's ``companions`` list). Every companion gets
|
|
100
|
+
a fresh id derived from this call's root id — never the catalog's literal
|
|
101
|
+
placeholder string — per the design note's id-uniqueness rule.
|
|
102
|
+
"""
|
|
103
|
+
spec = _CATALOG[catalog_key]
|
|
104
|
+
root_id = model_id or uuid.uuid4().hex
|
|
105
|
+
|
|
106
|
+
state = dict(spec["default_state"])
|
|
107
|
+
companion_entries: list[ModelEntry] = []
|
|
108
|
+
for companion in spec["companions"]:
|
|
109
|
+
key = companion["key_in_default_state"]
|
|
110
|
+
companion_id = _mint_id(root_id, key)
|
|
111
|
+
state[key] = f"IPY_MODEL_{companion_id}"
|
|
112
|
+
companion_state = {
|
|
113
|
+
"_model_name": companion["model_name"],
|
|
114
|
+
"_model_module": companion["model_module"],
|
|
115
|
+
"_model_module_version": companion["model_module_version"],
|
|
116
|
+
"_view_name": companion["view_name"],
|
|
117
|
+
"_view_module": companion["view_module"],
|
|
118
|
+
"_view_module_version": companion["view_module_version"],
|
|
119
|
+
}
|
|
120
|
+
companion_entries.append((companion_id, companion_state))
|
|
121
|
+
|
|
122
|
+
state.update(overrides)
|
|
123
|
+
widget_state = {**_identity(spec), **state}
|
|
124
|
+
return [(root_id, widget_state), *companion_entries]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def int_slider(value: int = 0, min: int = 0, max: int = 100, **overrides: Any) -> list[ModelEntry]:
|
|
128
|
+
"""A real ``@jupyter-widgets/controls`` ``IntSliderModel`` + its style/layout companions."""
|
|
129
|
+
return _build("int_slider", {"value": value, "min": min, "max": max, **overrides})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def dropdown(options: Any, value: Any = None, **overrides: Any) -> list[ModelEntry]:
|
|
133
|
+
"""A real ``@jupyter-widgets/controls`` ``DropdownModel`` + its style/layout companions.
|
|
134
|
+
|
|
135
|
+
``options``/``value`` are the ergonomic, ipywidgets-``Dropdown``-constructor-shaped
|
|
136
|
+
inputs; on the wire only ``_options_labels`` (the label strings) and ``index`` are
|
|
137
|
+
synced traits (``Dropdown``'s own ``options``/``value``/``label`` traits carry no
|
|
138
|
+
``sync=True`` tag in ipywidgets' source — confirmed empirically: a naive catalog entry
|
|
139
|
+
that put ``options``/``value`` straight into state rendered an empty, unselectable
|
|
140
|
+
dropdown in a real browser). Pass ``index=`` directly in ``overrides`` to bypass the
|
|
141
|
+
``value``-to-``index`` lookup (e.g. when ``value`` is unhashable or repeated).
|
|
142
|
+
"""
|
|
143
|
+
labels = [str(option) for option in options]
|
|
144
|
+
index = overrides.pop("index", None)
|
|
145
|
+
if index is None and value is not None:
|
|
146
|
+
str_value = str(value)
|
|
147
|
+
index = labels.index(str_value) if str_value in labels else None
|
|
148
|
+
return _build("dropdown", {"_options_labels": labels, "index": index, **overrides})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _box(catalog_key: str, children: list[list[ModelEntry]], **overrides: Any) -> list[ModelEntry]:
|
|
152
|
+
child_refs = [f"IPY_MODEL_{child[0][0]}" for child in children]
|
|
153
|
+
own = _build(catalog_key, {"children": child_refs, **overrides})
|
|
154
|
+
descendants = [entry for child in children for entry in child]
|
|
155
|
+
return own + descendants
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def vbox(children: list[list[ModelEntry]], **overrides: Any) -> list[ModelEntry]:
|
|
159
|
+
"""A real ``@jupyter-widgets/controls`` ``VBoxModel`` laying out ``children`` vertically.
|
|
160
|
+
|
|
161
|
+
``children`` is a list of previously built entries-lists (e.g. the output of
|
|
162
|
+
:func:`int_slider`/:func:`dropdown`/:func:`vbox`/:func:`hbox`) — this widget's
|
|
163
|
+
``children`` trait references each child's root id, and the returned entries list
|
|
164
|
+
flattens in every descendant so the whole tree serializes as one document.
|
|
165
|
+
"""
|
|
166
|
+
return _box("vbox", children, **overrides)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def hbox(children: list[list[ModelEntry]], **overrides: Any) -> list[ModelEntry]:
|
|
170
|
+
"""A real ``@jupyter-widgets/controls`` ``HBoxModel`` laying out ``children`` horizontally.
|
|
171
|
+
|
|
172
|
+
See :func:`vbox` for the ``children`` composition contract (identical, horizontal
|
|
173
|
+
layout only).
|
|
174
|
+
"""
|
|
175
|
+
return _box("hbox", children, **overrides)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Wrap an *existing* ipywidgets tree as a cositos :data:`~cositos.serialize.Document`.
|
|
2
|
+
|
|
3
|
+
The reuse insight this module packages: ``ipywidgets.embed.embed_data(views=[w])`` already
|
|
4
|
+
walks a widget and its transitive children and returns a ``manager_state`` that is, byte
|
|
5
|
+
for byte, a cositos v2 Widget-State :data:`~cositos.serialize.Document`. So any app or
|
|
6
|
+
library built on ipywidgets — including anywidget-based plotting widgets (plotly
|
|
7
|
+
``FigureWidget``, altair ``JupyterChart``) — can be serialized, round-tripped, and
|
|
8
|
+
statically embedded through cositos with **no core changes**. This is the thin,
|
|
9
|
+
Python-only, optional wrapper over that call; it never enters the pure core.
|
|
10
|
+
|
|
11
|
+
``embed_data`` is the current, supported entry point; the older ``Widget.widgets`` global
|
|
12
|
+
registry is deprecated and must not be used.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from cositos.embed import embed_html
|
|
20
|
+
from cositos.serialize import Document
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
23
|
+
from ipywidgets import Widget
|
|
24
|
+
|
|
25
|
+
__all__ = ["harvest", "harvest_html"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _embed_data(widgets: tuple[Any, ...]) -> dict[str, Any]:
|
|
29
|
+
"""Call ``ipywidgets.embed.embed_data`` for ``widgets`` with clear failures.
|
|
30
|
+
|
|
31
|
+
Always passes ``drop_defaults=False``. ``embed_data``'s own default
|
|
32
|
+
(``drop_defaults=True``) compares each synced trait's *current* value against its
|
|
33
|
+
*class-level default* and omits it when they match — but a widget that bundles its
|
|
34
|
+
own frontend commonly declares that content (``_esm``/``_css``) as the trait's class
|
|
35
|
+
default rather than setting it per instance (Plotly's ``FigureWidget`` and Altair's
|
|
36
|
+
``JupyterChart`` both do this). ``drop_defaults=True`` then silently drops ``_esm``
|
|
37
|
+
from the harvested state, and the CDN anywidget runtime's ``AnyView`` throws
|
|
38
|
+
(``isHref(undefined)``) instead of rendering (cositos-b2t). ``harvest``'s own
|
|
39
|
+
docstring promise — a verbatim capture — requires the non-dropping default anyway.
|
|
40
|
+
|
|
41
|
+
Raises
|
|
42
|
+
------
|
|
43
|
+
ImportError
|
|
44
|
+
If ipywidgets is not installed (this is an optional dependency of contrib).
|
|
45
|
+
ValueError
|
|
46
|
+
If no widget was given (an empty document is never what a caller wants here).
|
|
47
|
+
"""
|
|
48
|
+
if not widgets:
|
|
49
|
+
raise ValueError("harvest requires at least one widget to serialize")
|
|
50
|
+
try:
|
|
51
|
+
from ipywidgets.embed import embed_data
|
|
52
|
+
except ImportError as exc: # pragma: no cover - exercised only without ipywidgets
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"cositos.contrib.harvest requires ipywidgets; install it with "
|
|
55
|
+
"`pip install ipywidgets` (or the cositos 'oracle' extra)"
|
|
56
|
+
) from exc
|
|
57
|
+
data: dict[str, Any] = embed_data(views=list(widgets), drop_defaults=False)
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def harvest(*widgets: Widget) -> Document:
|
|
62
|
+
"""Return a cositos :data:`~cositos.serialize.Document` for existing ipywidgets ``widgets``.
|
|
63
|
+
|
|
64
|
+
Captures each widget and its transitive children (children are held by reference as
|
|
65
|
+
``"IPY_MODEL_<id>"`` strings, exactly like a natively built cositos document). The
|
|
66
|
+
result round-trips through :func:`cositos.serialize.load_document` /
|
|
67
|
+
:func:`~cositos.serialize.dump_document` and renders via
|
|
68
|
+
:func:`cositos.embed.embed_html`.
|
|
69
|
+
|
|
70
|
+
For static export prefer :func:`harvest_html`, which renders only the top-level
|
|
71
|
+
widgets as views (a widget expands to extra layout/style models that have no view).
|
|
72
|
+
"""
|
|
73
|
+
document: Document = _embed_data(widgets)["manager_state"]
|
|
74
|
+
return document
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def harvest_html(*widgets: Widget, **embed_kwargs: Any) -> str:
|
|
78
|
+
"""Serialize ``widgets`` and render them to a self-contained static HTML page.
|
|
79
|
+
|
|
80
|
+
Only the passed top-level ``widgets`` become rendered views; their auxiliary
|
|
81
|
+
layout/style models are embedded as state but not turned into (viewless) view scripts.
|
|
82
|
+
``embed_kwargs`` are forwarded to :func:`cositos.embed.embed_html` (e.g. ``title``).
|
|
83
|
+
"""
|
|
84
|
+
data = _embed_data(widgets)
|
|
85
|
+
view_ids = [spec["model_id"] for spec in data["view_specs"]]
|
|
86
|
+
return embed_html(data["manager_state"], views=view_ids, **embed_kwargs)
|
cositos/embed.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Static-HTML export: render a serialized widget :data:`Document` to a self-contained page.
|
|
2
|
+
|
|
3
|
+
Reuses the stock ipywidgets embed format \u2014 a ``application/vnd.jupyter.widget-state+json``
|
|
4
|
+
block plus per-view ``widget-view+json`` scripts \u2014 rendered by the CDN-hosted
|
|
5
|
+
``@jupyter-widgets/html-manager``. The anywidget model/view module resolves from jsDelivr
|
|
6
|
+
at render time, so no frontend is bundled and no kernel is needed to display a saved UI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from cositos.protocol import ANYWIDGET_MODULE_VERSION, view_identity
|
|
18
|
+
from cositos.serialize import Document
|
|
19
|
+
|
|
20
|
+
STATE_MIMETYPE = "application/vnd.jupyter.widget-state+json"
|
|
21
|
+
VIEW_MIMETYPE = "application/vnd.jupyter.widget-view+json"
|
|
22
|
+
|
|
23
|
+
_DEFAULT_HTML_MANAGER_VERSION = "1"
|
|
24
|
+
_REQUIREJS_URL = "https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js"
|
|
25
|
+
_EMBED_AMD_URL = "https://cdn.jsdelivr.net/npm/@jupyter-widgets/html-manager@{v}/dist/embed-amd.js"
|
|
26
|
+
_EMBED_URL = "https://cdn.jsdelivr.net/npm/@jupyter-widgets/html-manager@{v}/dist/embed.js"
|
|
27
|
+
|
|
28
|
+
# Neutralise the only sequences that can break out of a <script> element, per
|
|
29
|
+
# https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
|
|
30
|
+
_SCRIPT_ESCAPE = re.compile(r"<(script|/script|!--)", re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _escape_script(text: str) -> str:
|
|
34
|
+
return _SCRIPT_ESCAPE.sub(r"\\u003c\1", text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def with_view_identity(document: Document) -> Document:
|
|
38
|
+
"""Return a copy of ``document`` with anywidget *view* identity merged into each
|
|
39
|
+
model's ``state`` — the form the CDN html-manager needs to render.
|
|
40
|
+
|
|
41
|
+
Every static-render surface (this module's :func:`embed_html`, and the
|
|
42
|
+
nbconvert/Quarto/JupyterBook builder) routes its document through here.
|
|
43
|
+
|
|
44
|
+
The pure serialization codec (:func:`cositos.serialize.dump_document`) stays a lossless
|
|
45
|
+
save/restore of user state and carries no rendering identity — exactly like the live
|
|
46
|
+
comm path keeps view identity out of stored state. Static rendering is the analog of
|
|
47
|
+
the live ``build_comm_open`` path, so this is where the html-manager's required view
|
|
48
|
+
identity (``_view_name`` etc.) is injected, else the CDN manager cannot pick a view
|
|
49
|
+
class and the page renders only a JS error (cositos-mx7). Host-set state wins over the
|
|
50
|
+
injected defaults.
|
|
51
|
+
"""
|
|
52
|
+
records = document.get("state", {})
|
|
53
|
+
enriched = {}
|
|
54
|
+
for model_id, record in records.items():
|
|
55
|
+
version = record.get("model_module_version", ANYWIDGET_MODULE_VERSION)
|
|
56
|
+
enriched[model_id] = {
|
|
57
|
+
**record,
|
|
58
|
+
"state": {**view_identity(version), **record.get("state", {})},
|
|
59
|
+
}
|
|
60
|
+
return {**document, "state": enriched}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def embed_snippet(
|
|
64
|
+
document: Document,
|
|
65
|
+
*,
|
|
66
|
+
views: Iterable[str] | None = None,
|
|
67
|
+
requirejs: bool = True,
|
|
68
|
+
html_manager_version: str = _DEFAULT_HTML_MANAGER_VERSION,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Render the embed *snippet* (loader + state + view scripts), without an HTML wrapper.
|
|
71
|
+
|
|
72
|
+
Use this to drop a widget into an existing page (a Quarto/blog cell, a template). See
|
|
73
|
+
:func:`embed_html` for a complete standalone page.
|
|
74
|
+
"""
|
|
75
|
+
document = with_view_identity(document)
|
|
76
|
+
model_ids = list(views) if views is not None else list(document.get("state", {}))
|
|
77
|
+
state_block = _escape_script(json.dumps(document, indent=2))
|
|
78
|
+
view_blocks = "\n".join(
|
|
79
|
+
f'<script type="{VIEW_MIMETYPE}">\n'
|
|
80
|
+
+ _escape_script(json.dumps({"version_major": 2, "version_minor": 0, "model_id": mid}))
|
|
81
|
+
+ "\n</script>"
|
|
82
|
+
for mid in model_ids
|
|
83
|
+
)
|
|
84
|
+
loader = _loader(requirejs, html_manager_version)
|
|
85
|
+
return f'{loader}\n<script type="{STATE_MIMETYPE}">\n{state_block}\n</script>\n{view_blocks}\n'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def embed_html(
|
|
89
|
+
document: Document,
|
|
90
|
+
*,
|
|
91
|
+
views: Iterable[str] | None = None,
|
|
92
|
+
title: str = "cositos widgets",
|
|
93
|
+
requirejs: bool = True,
|
|
94
|
+
html_manager_version: str = _DEFAULT_HTML_MANAGER_VERSION,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Render ``document`` (a :func:`cositos.serialize.dump_document` result) to a full page.
|
|
97
|
+
|
|
98
|
+
``views`` selects which model ids get a rendered view (default: every model in the
|
|
99
|
+
document). The full state is always embedded so a referenced model is present for a
|
|
100
|
+
container to resolve. Note that a reference (``"IPY_MODEL_<id>"``) only resolves to a
|
|
101
|
+
child model when the *holding* model declares a widget-reference trait — plain
|
|
102
|
+
anywidget (``AnyModel``) widgets do not, so refs between them stay literal strings. See
|
|
103
|
+
``examples/composition/`` for the controls-container recipe that does resolve.
|
|
104
|
+
"""
|
|
105
|
+
snippet = embed_snippet(
|
|
106
|
+
document, views=views, requirejs=requirejs, html_manager_version=html_manager_version
|
|
107
|
+
)
|
|
108
|
+
return (
|
|
109
|
+
'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
|
110
|
+
' <meta charset="UTF-8">\n'
|
|
111
|
+
f" <title>{title}</title>\n"
|
|
112
|
+
"</head>\n<body>\n"
|
|
113
|
+
f"{snippet}"
|
|
114
|
+
"</body>\n</html>\n"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_html(path: str | Path, document: Document, **kwargs: Any) -> None:
|
|
119
|
+
"""Write :func:`embed_html` output for ``document`` to ``path``."""
|
|
120
|
+
Path(path).write_text(embed_html(document, **kwargs))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _loader(requirejs: bool, version: str) -> str:
|
|
124
|
+
embed_url = (_EMBED_AMD_URL if requirejs else _EMBED_URL).format(v=version)
|
|
125
|
+
script = f'<script src="{embed_url}" crossorigin="anonymous"></script>'
|
|
126
|
+
if requirejs:
|
|
127
|
+
return f'<script src="{_REQUIREJS_URL}" crossorigin="anonymous"></script>\n{script}'
|
|
128
|
+
return script
|
cositos/jupyter.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Jupyter host adapter: a :class:`~cositos.transport.Transport` over the ``comm`` package.
|
|
2
|
+
|
|
3
|
+
This is the piece that plugs the pure protocol core into a real Jupyter kernel
|
|
4
|
+
(ipykernel, and anything else that provides ``comm``). It mirrors what
|
|
5
|
+
anywidget's ``open_comm``/``ReprMimeBundle`` do, but stays a thin Transport so the core
|
|
6
|
+
remains kernel-agnostic.
|
|
7
|
+
|
|
8
|
+
Usage in a kernel::
|
|
9
|
+
|
|
10
|
+
from cositos import Widget
|
|
11
|
+
from cositos.jupyter import CommTransport
|
|
12
|
+
|
|
13
|
+
state = {"_esm": ESM, "value": 0}
|
|
14
|
+
store = dict(state)
|
|
15
|
+
widget = Widget(
|
|
16
|
+
CommTransport(),
|
|
17
|
+
get_state=lambda: dict(store),
|
|
18
|
+
set_state=store.update,
|
|
19
|
+
)
|
|
20
|
+
widget.open() # sends comm_open over the kernel's iopub
|
|
21
|
+
widget.mimebundle() # -> display via _repr_mimebundle_
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
29
|
+
from comm.base_comm import BaseComm
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CommTransport:
|
|
33
|
+
"""Adapt the ``comm`` package to the cositos :class:`Transport` protocol.
|
|
34
|
+
|
|
35
|
+
The comm is created lazily on the first ``comm_open`` send, carrying the initial
|
|
36
|
+
state exactly as the protocol requires (no separate open + update round-trip).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
supports_receive: bool = True
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._comm: BaseComm | None = None
|
|
43
|
+
self._pending: Callable[[dict[str, Any], list[Any]], None] | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def comm_id(self) -> str:
|
|
47
|
+
"""The comm id (widget ``model_id``); empty until opened."""
|
|
48
|
+
return self._comm.comm_id if self._comm is not None else ""
|
|
49
|
+
|
|
50
|
+
def send(
|
|
51
|
+
self,
|
|
52
|
+
msg_type: str,
|
|
53
|
+
content: dict[str, Any],
|
|
54
|
+
buffers: list[Any] | None = None,
|
|
55
|
+
metadata: dict[str, Any] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if msg_type == "comm_open":
|
|
58
|
+
self._open(content, buffers or [], metadata or {})
|
|
59
|
+
elif msg_type == "comm_msg":
|
|
60
|
+
self._require_comm().send(data=content, buffers=buffers or None)
|
|
61
|
+
elif msg_type == "comm_close":
|
|
62
|
+
self._require_comm().close()
|
|
63
|
+
else: # pragma: no cover - defensive
|
|
64
|
+
raise ValueError(f"Unknown comm message type: {msg_type!r}")
|
|
65
|
+
|
|
66
|
+
def on_message(self, callback: Callable[[dict[str, Any], list[Any]], None]) -> None:
|
|
67
|
+
if self._comm is None:
|
|
68
|
+
# Registered before open() created the comm; wire it up on open.
|
|
69
|
+
self._pending = callback
|
|
70
|
+
return
|
|
71
|
+
self._bind(callback)
|
|
72
|
+
|
|
73
|
+
# -- internals -------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _open(self, content: dict[str, Any], buffers: list[Any], metadata: dict[str, Any]) -> None:
|
|
76
|
+
import comm # noqa: PLC0415
|
|
77
|
+
|
|
78
|
+
self._comm = comm.create_comm(
|
|
79
|
+
target_name="jupyter.widget",
|
|
80
|
+
data=content,
|
|
81
|
+
metadata=metadata,
|
|
82
|
+
buffers=buffers or None,
|
|
83
|
+
)
|
|
84
|
+
if self._pending is not None:
|
|
85
|
+
self._bind(self._pending)
|
|
86
|
+
self._pending = None
|
|
87
|
+
|
|
88
|
+
def _bind(self, callback: Callable[[dict[str, Any], list[Any]], None]) -> None:
|
|
89
|
+
def _on_msg(msg: dict[str, Any]) -> None:
|
|
90
|
+
data = msg["content"]["data"]
|
|
91
|
+
callback(data, msg.get("buffers", []))
|
|
92
|
+
|
|
93
|
+
self._require_comm().on_msg(_on_msg)
|
|
94
|
+
|
|
95
|
+
def _require_comm(self) -> BaseComm:
|
|
96
|
+
if self._comm is None: # pragma: no cover - defensive
|
|
97
|
+
raise RuntimeError("comm not opened; call Widget.open() first")
|
|
98
|
+
return self._comm
|