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 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