diff --git a/ccflow/base.py b/ccflow/base.py
index e59a01a7..56d461e5 100644
--- a/ccflow/base.py
+++ b/ccflow/base.py
@@ -329,7 +329,7 @@ def __panel__(self):
Requires ccflow UI dependencies (panel, panel_material_ui).
"""
try:
- from ccflow.ui.model import ModelViewer
+ from ccflow.ui.panel.model import ModelViewer
except ImportError:
raise ImportError(
"panel and other optional dependencies must be installed to use ModelViewer. Pip install ccflow[full] to install all optional dependencies."
@@ -522,7 +522,7 @@ def __panel__(self):
Requires ccflow UI dependencies (panel, panel_material_ui).
"""
- from ccflow.ui.registry import ModelRegistryViewer
+ from ccflow.ui.panel.registry import ModelRegistryViewer
return ModelRegistryViewer(self)
diff --git a/ccflow/tests/test_base.py b/ccflow/tests/test_base.py
index 2f3c475c..132234f6 100644
--- a/ccflow/tests/test_base.py
+++ b/ccflow/tests/test_base.py
@@ -175,8 +175,8 @@ def test_widget(self):
def test_panel(self):
from ccflow import ModelRegistry
- from ccflow.ui.model import ModelViewer
- from ccflow.ui.registry import ModelRegistryViewer
+ from ccflow.ui.panel.model import ModelViewer
+ from ccflow.ui.panel.registry import ModelRegistryViewer
m = ModelA(x="foo")
panel_obj = m.__panel__()
diff --git a/ccflow/tests/ui/panel/__init__.py b/ccflow/tests/ui/panel/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ccflow/tests/ui/test_cli.py b/ccflow/tests/ui/panel/test_cli.py
similarity index 94%
rename from ccflow/tests/ui/test_cli.py
rename to ccflow/tests/ui/panel/test_cli.py
index c399fb73..45353553 100644
--- a/ccflow/tests/ui/test_cli.py
+++ b/ccflow/tests/ui/panel/test_cli.py
@@ -1,6 +1,6 @@
-"""Unit tests for ccflow.ui.cli module."""
+"""Unit tests for ccflow.ui.panel.cli module."""
-from ccflow.ui.cli import _get_ui_args_parser
+from ccflow.ui.panel.cli import _get_ui_args_parser
class TestGetUIArgsParser:
diff --git a/ccflow/tests/ui/test_model.py b/ccflow/tests/ui/panel/test_model.py
similarity index 99%
rename from ccflow/tests/ui/test_model.py
rename to ccflow/tests/ui/panel/test_model.py
index 043dbd63..7cc15687 100644
--- a/ccflow/tests/ui/test_model.py
+++ b/ccflow/tests/ui/panel/test_model.py
@@ -1,10 +1,10 @@
-"""Unit tests for ccflow.ui.model module."""
+"""Unit tests for ccflow.ui.panel.model module."""
import panel as pn
from pydantic import Field
from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, MetaData, ModelRegistry
-from ccflow.ui.model import ModelConfigViewer, ModelTypeViewer, ModelViewer
+from ccflow.ui.panel.model import ModelConfigViewer, ModelTypeViewer, ModelViewer
from .utils import find_components_by_type
diff --git a/ccflow/tests/ui/test_registry.py b/ccflow/tests/ui/panel/test_registry.py
similarity index 99%
rename from ccflow/tests/ui/test_registry.py
rename to ccflow/tests/ui/panel/test_registry.py
index d9b1dd8a..a5f0f744 100644
--- a/ccflow/tests/ui/test_registry.py
+++ b/ccflow/tests/ui/panel/test_registry.py
@@ -1,11 +1,11 @@
-"""Unit tests for ccflow.ui.registry module."""
+"""Unit tests for ccflow.ui.panel.registry module."""
from unittest import mock
import panel as pn
from ccflow import BaseModel, ModelRegistry
-from ccflow.ui.registry import ModelRegistryViewer, RegistryBrowser
+from ccflow.ui.panel.registry import ModelRegistryViewer, RegistryBrowser
from .utils import find_components_by_type
diff --git a/ccflow/tests/ui/utils.py b/ccflow/tests/ui/panel/utils.py
similarity index 100%
rename from ccflow/tests/ui/utils.py
rename to ccflow/tests/ui/panel/utils.py
diff --git a/ccflow/tests/ui/spaday/__init__.py b/ccflow/tests/ui/spaday/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ccflow/tests/ui/spaday/test_cli.py b/ccflow/tests/ui/spaday/test_cli.py
new file mode 100644
index 00000000..059c3ae2
--- /dev/null
+++ b/ccflow/tests/ui/spaday/test_cli.py
@@ -0,0 +1,281 @@
+"""Unit tests for ccflow.ui.spaday.cli module."""
+
+import importlib
+import sys
+from pathlib import Path
+
+import pytest
+from spaday.bootstrap import _ASSETS, _layout, bundles_dir
+
+from ccflow import BaseModel, LazyRegistry, ModelRegistry
+from ccflow.ui.spaday.cli import _get_ui_args_parser, registry_viewer_cli, serve_registry
+
+
+class SimpleModel(BaseModel):
+ name: str
+ value: int = 0
+
+
+class TestGetUIArgsParser:
+ def test_parser_composition(self):
+ parser = _get_ui_args_parser()
+ args = parser.parse_args([])
+
+ # From add_hydra_config_args
+ assert hasattr(args, "overrides")
+ assert hasattr(args, "config_path")
+ assert hasattr(args, "config_name")
+
+ # Server + viewer-specific
+ assert hasattr(args, "host")
+ assert hasattr(args, "port")
+ assert hasattr(args, "browser_width")
+ assert hasattr(args, "title")
+ assert hasattr(args, "sort_children")
+
+ def test_defaults(self):
+ args = _get_ui_args_parser().parse_args([])
+ assert args.host == "127.0.0.1"
+ assert args.port == 8080
+ assert args.browser_width == 400
+ assert args.title == "ccflow Model Registry"
+ assert args.sort_children is True
+
+ def test_custom_values(self):
+ args = _get_ui_args_parser().parse_args(["--host", "0.0.0.0", "--port", "9000", "--browser-width", "500", "--title", "Mine"])
+ assert args.host == "0.0.0.0"
+ assert args.port == 9000
+ assert args.browser_width == 500
+ assert args.title == "Mine"
+
+ def test_no_sort_children_flag(self):
+ args = _get_ui_args_parser().parse_args(["--no-sort-children"])
+ assert args.sort_children is False
+
+ def test_overrides_positional(self):
+ args = _get_ui_args_parser().parse_args(["key1=value1", "key2=value2"])
+ assert args.overrides == ["key1=value1", "key2=value2"]
+
+ @pytest.mark.parametrize(
+ ("argv", "expected"),
+ [([], "--config-path"), (["--config-path", "cfg"], "--config-name")],
+ )
+ def test_missing_config_args_report_usage(self, monkeypatch, capsys, argv, expected):
+ monkeypatch.setattr(sys, "argv", ["ccflow-ui-spaday", *argv])
+
+ with pytest.raises(SystemExit) as excinfo:
+ registry_viewer_cli()
+
+ # argparse usage error, not a traceback out of resolve_config_paths.
+ assert excinfo.value.code == 2
+ assert expected in capsys.readouterr().err
+
+ def test_config_dir_is_optional(self):
+ args = _get_ui_args_parser().parse_args(["--config-path", "cfg", "--config-name", "conf"])
+ assert args.config_dir is None
+ assert args.config_dir_config_name is None
+
+ def test_config_dir_and_name_load_without_config_path(self, monkeypatch):
+ from ccflow.ui.spaday import cli
+
+ example = Path(cli.__file__).parents[2] / "examples" / "calculator"
+ monkeypatch.setattr(sys, "argv", ["ccflow-ui-spaday", "-cd", "config", "-cn", "base", "--basepath", str(example)])
+ monkeypatch.chdir(example)
+ served = {}
+ monkeypatch.setattr(cli, "serve_registry", lambda registry, **kwargs: served.update(registry=registry))
+ ModelRegistry.root().clear()
+
+ cli.registry_viewer_cli()
+
+ # The config dir stood in for the root config, so the registry actually got populated.
+ assert served["registry"] is ModelRegistry.root()
+ assert served["registry"].models
+
+ def test_config_dir_without_config_name_reports_usage(self, monkeypatch, capsys):
+ monkeypatch.setattr(sys, "argv", ["ccflow-ui-spaday", "-cd", "config"])
+
+ with pytest.raises(SystemExit) as excinfo:
+ registry_viewer_cli()
+
+ assert excinfo.value.code == 2
+ assert "--config-name" in capsys.readouterr().err
+
+
+class TestServeRegistry:
+ def test_builds_app_without_running(self):
+ registry = ModelRegistry(name="test")
+ registry.add("m", SimpleModel(name="m", value=1))
+ app = serve_registry(registry, run=False)
+ paths = {getattr(route, "path", None) for route in app.routes}
+ assert "/" in paths
+ assert "/tree.json" in paths
+
+ def test_selection_is_url_bound_and_theme_persists(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = ModelRegistry(name="test")
+ registry.add("widget", SimpleModel(name="widget"))
+ app = serve_registry(registry, run=False)
+
+ page = starlette_testclient.TestClient(app).get("/").text
+
+ # The selection rides a query parameter, so a model is linkable and back/forward navigate.
+ assert '"selected"' in page and '"model"' in page
+ assert "ccflow-ui-dark" in page
+
+ def test_card_endpoint_serves_one_model(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = ModelRegistry(name="test")
+ registry.add("widget", SimpleModel(name="widget", value=7))
+ registry.add("other", SimpleModel(name="other"))
+ client = starlette_testclient.TestClient(serve_registry(registry, run=False))
+
+ response = client.get("/card", params={"model": "widget"})
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["tag"]
+ # Only the requested model's card, so the page can defer the rest.
+ assert "widget" in response.text
+ assert "other" not in response.text
+
+ def test_card_endpoint_handles_unknown_model(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ client = starlette_testclient.TestClient(serve_registry(ModelRegistry(name="test"), run=False))
+
+ response = client.get("/card", params={"model": "nope"})
+
+ assert response.status_code == 200
+ assert "Unknown model" in response.text
+
+ def test_tree_route_reflects_registry(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = ModelRegistry(name="test")
+ registry.add("widget", SimpleModel(name="widget"))
+ client = starlette_testclient.TestClient(serve_registry(registry, title="T", run=False))
+
+ response = client.get("/tree.json")
+
+ assert response.status_code == 200
+ assert "widget" in response.text
+
+ def test_materialize_route_present(self):
+ registry = ModelRegistry(name="test")
+ registry.add("m", SimpleModel(name="m"))
+ app = serve_registry(registry, run=False)
+ paths = {getattr(route, "path", None) for route in app.routes}
+ assert "/materialize" in paths
+
+ @pytest.mark.parametrize("module", ["ccflow.ui.cli", "ccflow.ui.model", "ccflow.ui.registry"])
+ def test_panel_module_compatibility_imports(self, module):
+ assert importlib.import_module(module)
+
+
+class TestMaterializeEndpoint:
+ def _lazy_registry(self):
+ return LazyRegistry(
+ name="root",
+ group={"model": {"_target_": "ccflow.tests.ui.spaday.test_cli.SimpleModel", "name": "pending"}},
+ )
+
+ def test_materialize_instantiates_pending_model(self, mocker):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ from ccflow.ui.spaday import cli
+
+ to_thread = mocker.spy(cli.asyncio, "to_thread")
+ registry = self._lazy_registry()
+ app = serve_registry(registry, run=False)
+ assert not registry["group"].is_loaded("model")
+
+ client = starlette_testclient.TestClient(app)
+ response = client.post("/materialize", json={"path": "group/model"})
+
+ assert response.status_code == 200
+ assert "group/model" in response.json()["message"]
+ assert registry["group"].is_loaded("model")
+ to_thread.assert_awaited_once()
+
+ def test_materialize_missing_path_is_rejected(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = self._lazy_registry()
+ app = serve_registry(registry, run=False)
+
+ client = starlette_testclient.TestClient(app)
+ response = client.post("/materialize", json={})
+
+ assert response.status_code == 400
+ assert response.json()["message"]
+
+ def test_materialize_rejects_path_escaping_served_registry(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ # A leading slash resolves against the process-global root rather than the served registry.
+ root = ModelRegistry.root()
+ root.clear()
+ served = self._lazy_registry()
+ root.add("served", served)
+
+ client = starlette_testclient.TestClient(serve_registry(served, run=False))
+ response = client.post("/materialize", json={"path": "/served/group/model"})
+
+ assert response.status_code == 404
+ assert not served["group"].is_loaded("model")
+
+ def test_materialize_requires_json_content_type(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = self._lazy_registry()
+ client = starlette_testclient.TestClient(serve_registry(registry, run=False))
+
+ # A form post is a CSRF-able "simple request", so it never reaches the registry.
+ response = client.post("/materialize", data={"path": "group/model"})
+
+ assert response.status_code == 415
+ assert not registry["group"].is_loaded("model")
+
+ @pytest.mark.parametrize("content_type", ["application/json", "Application/JSON", "application/json; charset=utf-8"])
+ def test_materialize_accepts_content_type_case_insensitively(self, content_type):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = self._lazy_registry()
+ client = starlette_testclient.TestClient(serve_registry(registry, run=False))
+
+ response = client.post("/materialize", content='{"path": "group/model"}', headers={"content-type": content_type})
+
+ assert response.status_code == 200
+ assert registry["group"].is_loaded("model")
+
+ @pytest.mark.parametrize("body", ["not json", "null", "[]"])
+ def test_materialize_rejects_malformed_body(self, body):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ client = starlette_testclient.TestClient(serve_registry(self._lazy_registry(), run=False))
+
+ response = client.post("/materialize", content=body, headers={"content-type": "application/json"})
+
+ assert response.status_code == 400
+
+ def test_materialize_reports_failure(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ registry = LazyRegistry(name="lazy", group={"broken": {"_target_": "not_a_module.Nope"}})
+ app = serve_registry(registry, run=False)
+
+ client = starlette_testclient.TestClient(app)
+ response = client.post("/materialize", json={"path": "group/broken"})
+
+ # The page surfaces this message in a toast, so it has to name the model and the cause.
+ assert response.status_code == 500
+ assert "group/broken" in response.json()["message"]
+ assert not registry["group"].is_loaded("broken")
+
+ def test_materialize_rejects_get(self):
+ starlette_testclient = pytest.importorskip("starlette.testclient")
+ app = serve_registry(self._lazy_registry(), run=False)
+
+ response = starlette_testclient.TestClient(app).get("/materialize", params={"path": "group/model"})
+
+ assert response.status_code == 405
+
+
+class TestAssetLayout:
+ def test_resolved_layout_has_runtime_asset(self):
+ # Guards the 404 regression: an unrelated top-level ``js`` package must not push spaday to the
+ # "source" layout, whose bundle directory would then lack the runtime asset.
+ layout = _layout(None)
+ runtime = _ASSETS[layout]["runtime"].lstrip("/")
+ assert (Path(bundles_dir(layout)) / runtime).is_file()
diff --git a/ccflow/tests/ui/spaday/test_graph.py b/ccflow/tests/ui/spaday/test_graph.py
new file mode 100644
index 00000000..39f58b84
--- /dev/null
+++ b/ccflow/tests/ui/spaday/test_graph.py
@@ -0,0 +1,179 @@
+"""Unit tests for ccflow.ui.spaday.graph module."""
+
+from spaday.validate import validate
+
+from ccflow import BaseModel, LazyRegistry, ModelRegistry
+from ccflow.ui.spaday.graph import dependency_edges, model_dependency_graph, model_dependency_view
+from ccflow.ui.spaday.registry import registry_leaves
+
+from .utils import event_action, nodes_with_tag, prop_value
+
+
+def _view(path, adjacency):
+ return model_dependency_view(path, adjacency, selected_field="selected")
+
+
+class Leaf(BaseModel):
+ """A model with no registry dependencies."""
+
+ name: str = "leaf"
+
+
+class Holder(BaseModel):
+ """A model that contains another registered model."""
+
+ child: Leaf
+
+
+class Outer(BaseModel):
+ """A model that contains a model which itself has a dependency."""
+
+ inner: Holder
+
+
+def _registry_with_dependency():
+ root = ModelRegistry.root()
+ root.clear()
+ leaf = Leaf(name="a")
+ sub = ModelRegistry(name="sub")
+ sub.add("alpha", leaf)
+ root.add("sub", sub)
+ root.add("holder", Holder(child=leaf))
+ return root
+
+
+class TestDependencyEdges:
+ def test_empty_registry(self):
+ assert dependency_edges([]) == {}
+
+ def test_edge_from_dependent_to_dependency(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ assert adjacency == {"sub/alpha": [], "holder": ["sub/alpha"]}
+
+ def test_no_edges_without_dependencies(self):
+ root = ModelRegistry.root()
+ root.clear()
+ root.add("one", Leaf(name="one"))
+ root.add("two", Leaf(name="two"))
+ assert dependency_edges(registry_leaves(root)) == {"one": [], "two": []}
+
+ def test_dependencies_outside_registry_are_dropped(self):
+ root = ModelRegistry.root()
+ root.clear()
+ leaf = Leaf(name="hidden")
+ root.add("hidden", leaf)
+ holder_registry = ModelRegistry(name="holder_only")
+ holder_registry.add("holder", Holder(child=leaf))
+ # Browsing a registry that does not contain the dependency must not invent a dangling node.
+ assert dependency_edges(registry_leaves(holder_registry)) == {"holder": []}
+
+ def test_pending_models_report_no_dependencies(self):
+ lazy = LazyRegistry(
+ name="lazy",
+ group={"model": {"_target_": "ccflow.tests.ui.spaday.test_graph.Leaf", "name": "pending"}},
+ )
+ assert dependency_edges(registry_leaves(lazy)) == {"group/model": []}
+ assert not lazy["group"].is_loaded("model")
+
+
+class TestModelDependencyGraph:
+ def test_unknown_path(self):
+ assert model_dependency_graph("nope", {}) == {"nodes": [], "edges": []}
+
+ def test_model_without_dependencies_is_a_single_node(self):
+ graph = model_dependency_graph("sub/alpha", {"sub/alpha": [], "holder": ["sub/alpha"]})
+ assert [node["id"] for node in graph["nodes"]] == ["sub/alpha"]
+ assert graph["edges"] == []
+
+ def test_graph_is_local_to_the_model(self):
+ adjacency = {"holder": ["sub/alpha"], "sub/alpha": [], "unrelated": []}
+ graph = model_dependency_graph("holder", adjacency)
+ # "unrelated" is in the registry but not reachable from "holder", so it is not drawn.
+ assert [node["id"] for node in graph["nodes"]] == ["holder", "sub/alpha"]
+ # The edge points from the dependency into the model that uses it.
+ assert graph["edges"] == [{"source": "sub/alpha", "target": "holder"}]
+
+ def test_nodes_carry_no_styling_classes(self):
+ graph = model_dependency_graph("holder", {"holder": ["sub/alpha"], "sub/alpha": []})
+ # Emphasis is a bindable prop on the component, so the graph stays pure structure.
+ assert all("class" not in node for node in graph["nodes"])
+
+ def test_transitive_dependencies_are_included(self):
+ adjacency = {"outer": ["holder"], "holder": ["leaf"], "leaf": []}
+ graph = model_dependency_graph("outer", adjacency)
+ assert [node["id"] for node in graph["nodes"]] == ["outer", "holder", "leaf"]
+ # leaf -> holder -> outer: the chain reads towards the model being inspected.
+ assert graph["edges"] == [
+ {"source": "holder", "target": "outer"},
+ {"source": "leaf", "target": "holder"},
+ ]
+
+ def test_cycles_terminate(self):
+ graph = model_dependency_graph("a", {"a": ["b"], "b": ["a"]})
+ assert [node["id"] for node in graph["nodes"]] == ["a", "b"]
+ assert len(graph["edges"]) == 2
+
+ def test_labels_show_the_full_registry_path(self):
+ graph = model_dependency_graph("holder", {"holder": ["sub/alpha"], "sub/alpha": []})
+ assert [node["label"] for node in graph["nodes"]] == ["holder", "sub/alpha"]
+
+
+class TestModelDependencyView:
+ def test_none_without_dependencies(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ assert _view("sub/alpha", adjacency) is None
+
+ def test_renders_dagre_component(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ node = _view("holder", adjacency).to_node()
+ dagre = nodes_with_tag(node, "spaday-dagre")
+ assert len(dagre) == 1
+ assert prop_value(dagre[0], "graph")["edges"] == [{"source": "sub/alpha", "target": "holder"}]
+
+ def test_node_events_open_the_menu_instead_of_navigating(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ node = _view("holder", adjacency).to_node()
+ dagre = nodes_with_tag(node, "spaday-dagre")[0]
+ for event in ("dagre-node-click", "dagre-node-contextmenu"):
+ action = event_action(dagre, event)
+ # open_popup is a Sequence that captures the node, then positions and opens the popup.
+ assert action["kind"] == "seq"
+ writes = [step for step in action["actions"] if step["kind"] == "set-field"]
+ assert writes[0]["field"] == "menu_path"
+ assert writes[0]["value"] == {"expr": "event", "path": "id"}
+
+ def test_menu_is_shared_and_bound_to_the_clicked_node(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ node = _view("holder", adjacency).to_node()
+ assert len(nodes_with_tag(node, "spa-popup")) == 1
+ # One body for every node, bound to the store rather than one gated copy per node.
+ assert not nodes_with_tag(node, "spa-show")
+ code = [n for n in nodes_with_tag(node, "code") if "textContent" in n.get("bindings", {})]
+ assert code[0]["bindings"]["textContent"]["field"] == "menu_path"
+
+ def test_open_model_sets_selection_and_reveals_in_tree(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ node = _view("holder", adjacency).to_node()
+ button = next(n for n in nodes_with_tag(node, "wa-button") if "click" in n.get("events", {}))
+ writes = {step["field"]: step["value"] for step in button["events"]["click"]["actions"] if step["kind"] == "set-field"}
+ # Setting the selection is enough: the tree derives its reveal from it.
+ assert writes["selected"] == {"expr": "field", "name": "menu_path"}
+
+ def test_long_labels_are_capped(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ dagre = nodes_with_tag(_view("holder", adjacency).to_node(), "spaday-dagre")[0]
+ assert prop_value(dagre, "maxLabelWidth") == 220
+
+ def test_inspected_model_is_emphasised(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ dagre = nodes_with_tag(_view("holder", adjacency).to_node(), "spaday-dagre")[0]
+ assert prop_value(dagre, "emphasis") == "holder"
+
+ def test_layout_is_left_to_right(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ dagre = nodes_with_tag(_view("holder", adjacency).to_node(), "spaday-dagre")[0]
+ assert prop_value(dagre, "layout") == {"rankdir": "LR"}
+
+ def test_validates(self):
+ adjacency = dependency_edges(registry_leaves(_registry_with_dependency()))
+ validate(_view("holder", adjacency).to_node())
diff --git a/ccflow/tests/ui/spaday/test_model.py b/ccflow/tests/ui/spaday/test_model.py
new file mode 100644
index 00000000..20aa9f36
--- /dev/null
+++ b/ccflow/tests/ui/spaday/test_model.py
@@ -0,0 +1,161 @@
+"""Unit tests for ccflow.ui.spaday.model module."""
+
+from pydantic import Field
+from spaday.actions import field
+from spaday.validate import validate
+
+from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, ModelRegistry
+from ccflow.ui.spaday.model import (
+ MATERIALIZE_ENDPOINT,
+ MATERIALIZE_RESULT_FIELD,
+ model_config_view,
+ model_type_view,
+ model_view,
+ pending_model_view,
+)
+
+from .utils import all_text, nodes_with_tag, text_of
+
+
+class SimpleModel(BaseModel):
+ """A documented test model."""
+
+ name: str = Field(description="the display name")
+ value: int = 0
+
+
+class Ctx(ContextBase):
+ """A test context."""
+
+ a: int = 1
+
+
+class Holder(BaseModel):
+ """A model that depends on another registered model."""
+
+ child: SimpleModel
+
+
+class MyCallable(CallableModel):
+ """A callable test model."""
+
+ x: str = "hi"
+
+ @property
+ def context_type(self) -> type[Ctx]:
+ return Ctx
+
+ @Flow.call
+ def __call__(self, context: Ctx) -> GenericResult:
+ return GenericResult(value=self.x)
+
+
+class TestModelTypeView:
+ def test_none_is_empty(self):
+ node = model_type_view(None).to_node()
+ assert node["tag"] == "spa-stack"
+ assert node.get("slots", {}) == {}
+
+ def test_type_name_in_badge(self):
+ node = model_type_view(SimpleModel).to_node()
+ badges = nodes_with_tag(node, "wa-badge")
+ assert any(text_of(b) == "SimpleModel" for b in badges)
+
+ def test_lists_fields(self):
+ text = " ".join(all_text(model_type_view(SimpleModel).to_node()))
+ assert "name" in text
+ assert "value" in text
+
+ def test_includes_field_description(self):
+ text = " ".join(all_text(model_type_view(SimpleModel).to_node()))
+ assert "the display name" in text
+
+ def test_includes_docstring(self):
+ text = " ".join(all_text(model_type_view(SimpleModel).to_node()))
+ assert "A documented test model." in text
+
+
+class TestModelConfigView:
+ def test_includes_path(self):
+ model = SimpleModel(name="m")
+ text = " ".join(all_text(model_config_view(model, "reg/m").to_node()))
+ assert "reg/m" in text
+
+ def test_no_metadata_message_when_empty(self):
+ model = SimpleModel(name="m")
+ text = " ".join(all_text(model_config_view(model).to_node()))
+ assert "No additional metadata." in text
+
+ def test_dependencies_rendered(self):
+ # Dependencies resolve to names registered in the root registry.
+ registry = ModelRegistry.root()
+ registry.clear()
+ dep = SimpleModel(name="dep")
+ registry.add("dep", dep)
+ registry.add("holder", Holder(child=dep))
+
+ text = " ".join(all_text(model_config_view(registry["holder"], "holder").to_node()))
+
+ # The dependency is listed by its registered name, not just rendered as a nested blob.
+ assert "Registry Dependencies" in text
+ assert "dep" in text.replace("Registry Dependencies", "")
+
+ def test_no_dependencies_section_without_dependencies(self):
+ text = " ".join(all_text(model_config_view(SimpleModel(name="m"), "m").to_node()))
+ assert "Registry Dependencies" not in text
+
+
+class TestModelView:
+ def test_is_card(self):
+ node = model_view(SimpleModel(name="m"), "m").to_node()
+ assert node["tag"] == "wa-card"
+
+ def test_has_core_tabs(self):
+ text = all_text(model_view(SimpleModel(name="m"), "m").to_node())
+ assert "Summary" in text
+ assert "Model Type" in text
+ assert "Parameters" in text
+
+ def test_plain_model_has_no_callable_tabs(self):
+ text = all_text(model_view(SimpleModel(name="m"), "m").to_node())
+ assert "Context Type" not in text
+ assert "Result Type" not in text
+
+ def test_callable_model_has_callable_tabs(self):
+ text = all_text(model_view(MyCallable(), "m").to_node())
+ assert "Context Type" in text
+ assert "Result Type" in text
+
+ def test_parameters_include_field_values(self):
+ text = " ".join(all_text(model_view(SimpleModel(name="widget", value=7), "m").to_node()))
+ assert "widget" in text
+
+ def test_validates(self):
+ validate(model_view(MyCallable(), "m").to_node())
+
+
+class TestPendingModelView:
+ def test_is_card(self):
+ node = pending_model_view("group/model").to_node()
+ assert node["tag"] == "wa-card"
+
+ def test_shows_pending_badge_and_path(self):
+ text = " ".join(all_text(pending_model_view("group/model").to_node()))
+ assert "Pending" in text
+ assert "group/model" in text
+
+ def test_materialize_button_calls_endpoint_then_refreshes(self):
+ node = pending_model_view(field("selected")).to_node()
+ button = next(n for n in nodes_with_tag(node, "wa-button") if "click" in n.get("events", {}))
+ steps = button["events"]["click"]["actions"]
+ call, refresh = steps[0], steps[1]
+ assert call["kind"] == "call"
+ assert call["method"] == "POST"
+ assert call["url"] == MATERIALIZE_ENDPOINT
+ assert call["result"] == MATERIALIZE_RESULT_FIELD
+ assert call["body"]["fields"]["path"] == {"expr": "field", "name": "selected"}
+ # The refreshed tree replaces the pending card in place, so there is no page reload.
+ assert refresh["kind"] == "refresh"
+
+ def test_validates(self):
+ validate(pending_model_view(field("selected")).to_node())
diff --git a/ccflow/tests/ui/spaday/test_registry.py b/ccflow/tests/ui/spaday/test_registry.py
new file mode 100644
index 00000000..9637a1cf
--- /dev/null
+++ b/ccflow/tests/ui/spaday/test_registry.py
@@ -0,0 +1,208 @@
+"""Unit tests for ccflow.ui.spaday.registry module."""
+
+from spaday.validate import validate
+
+from ccflow import BaseModel, LazyRegistry, ModelRegistry
+from ccflow.ui.spaday.registry import (
+ DARK_FIELD,
+ SELECTED_FIELD,
+ model_card,
+ registry_leaves,
+ registry_store,
+ registry_tree,
+ registry_viewer,
+)
+
+from .utils import all_text, event_action, nodes_with_tag, prop_str, prop_value
+
+
+class SimpleModel(BaseModel):
+ """A simple test model."""
+
+ name: str
+ value: int = 0
+
+
+class AnotherModel(BaseModel):
+ """Another test model."""
+
+ data: str = ""
+
+
+class HolderModel(BaseModel):
+ """A test model that contains another registered model."""
+
+ child: SimpleModel
+
+
+def _registry():
+ root = ModelRegistry(name="root")
+ sub = ModelRegistry(name="sub")
+ sub.add("alpha", SimpleModel(name="a", value=1))
+ root.add("sub", sub)
+ root.add("zeta", AnotherModel(data="z"))
+ return root
+
+
+class TestRegistryStore:
+ def test_default_store(self):
+ store = registry_store()
+ assert store[SELECTED_FIELD] == ""
+ assert store[DARK_FIELD] is False
+
+
+class TestRegistryLeaves:
+ def test_empty_registry(self):
+ assert registry_leaves(ModelRegistry(name="empty")) == []
+
+ def test_flat_registry(self):
+ registry = ModelRegistry(name="test")
+ model = SimpleModel(name="m", value=1)
+ registry.add("my_model", model)
+ assert registry_leaves(registry) == [("my_model", model)]
+
+ def test_nested_paths(self):
+ leaves = registry_leaves(_registry())
+ paths = [path for path, _ in leaves]
+ assert paths == ["sub/alpha", "zeta"]
+
+ def test_entry_removed_between_lookups_is_skipped(self, mocker):
+ lazy = LazyRegistry(name="lazy", group={"model": {"_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel", "name": "m"}})
+ group = lazy["group"]
+ # Neither loaded nor pending: the entry went away mid-traversal.
+ mocker.patch.object(LazyRegistry, "get_loaded", return_value=None)
+ mocker.patch.object(LazyRegistry, "get_pending_config", return_value=None)
+
+ assert registry_leaves(group) == []
+
+ def test_sort_children_orders_subregistries_first(self):
+ root = ModelRegistry(name="root")
+ root.add("zzz_leaf", SimpleModel(name="leaf"))
+ sub = ModelRegistry(name="sub")
+ sub.add("inner", SimpleModel(name="inner"))
+ root.add("aaa_sub", sub)
+ # Subregistries sort before leaf models regardless of name.
+ assert [p for p, _ in registry_leaves(root)] == ["aaa_sub/inner", "zzz_leaf"]
+
+ def test_insertion_order_when_not_sorted(self):
+ root = ModelRegistry(name="root")
+ root.add("zebra", SimpleModel(name="z"))
+ root.add("alpha", SimpleModel(name="a"))
+ assert [p for p, _ in registry_leaves(root, sort_children=False)] == ["zebra", "alpha"]
+
+
+class TestRegistryTree:
+ def test_paths_cover_all_leaves(self):
+ node = registry_tree(_registry()).to_node()
+ assert node["tag"] == "spaday-tree"
+ assert prop_value(node, "paths") == ["sub/alpha", "zeta"]
+
+ def test_selection_change_sets_selected_field(self):
+ node = registry_tree(_registry()).to_node()
+ action = event_action(node, "selection-change")
+ assert action["kind"] == "set-field"
+ assert action["field"] == SELECTED_FIELD
+ # The event detail is {paths: [...]}; the first entry is the newly selected model.
+ assert action["value"] == {"expr": "event", "path": "paths.0"}
+
+ def test_empty_registry_has_no_paths(self):
+ node = registry_tree(ModelRegistry(name="empty")).to_node()
+ assert prop_value(node, "paths") == []
+
+ def test_selected_paths_derived_so_the_tree_reveals_the_selection(self):
+ node = registry_tree(_registry()).to_node()
+ # A URL-seeded selection has to expand the tree to it, so the reveal is computed, not seeded.
+ computed = node["bindings"]["selected_paths"]["compute"]
+ assert computed["expr"] == "cond"
+ assert computed["then"] == {"expr": "arr", "of": [{"expr": "field", "name": SELECTED_FIELD}]}
+
+
+class TestRegistryViewer:
+ def test_returns_app(self):
+ app = registry_viewer(_registry())
+ assert app.to_node()["tag"] == "spa-app"
+
+ def test_validates(self):
+ validate(registry_viewer(_registry()).to_node())
+
+ def test_title_in_header(self):
+ node = registry_viewer(_registry(), title="My Registry").to_node()
+ assert "My Registry" in all_text(node)
+
+ def test_show_panel_per_leaf(self):
+ node = registry_viewer(_registry()).to_node()
+ switch = nodes_with_tag(node, "spa-switch")[0]
+ # One routed case per leaf plus the no-selection default, keyed by registry path.
+ assert switch["bindings"]["on"]["field"] == SELECTED_FIELD
+ assert set(switch["slots"]) == {"sub/alpha", "zeta", "default"}
+
+ def test_cards_are_deferred_not_inlined(self):
+ node = registry_viewer(_registry()).to_node()
+ # Each case is a placeholder that fetches its card, so the page does not carry every model.
+ lazies = nodes_with_tag(node, "spa-lazy")
+ assert {prop_str(n, "src") for n in lazies} == {"/card?model=sub/alpha", "/card?model=zeta"}
+ assert not nodes_with_tag(node, "wa-card")
+
+ def test_dependency_graph_is_model_local(self):
+ root = ModelRegistry.root()
+ root.clear()
+ leaf = SimpleModel(name="leaf")
+ root.add("leaf", leaf)
+ root.add("holder", HolderModel(child=leaf))
+ # The graph lives in the card, and only for the model that has dependencies.
+ assert len(nodes_with_tag(model_card(root, "holder").to_node(), "spaday-dagre")) == 1
+ assert not nodes_with_tag(model_card(root, "leaf").to_node(), "spaday-dagre")
+
+ def test_card_for_unknown_model(self):
+ node = model_card(_registry(), "nope").to_node()
+ assert "Unknown model" in all_text(node)
+
+ def test_card_for_pending_model_does_not_materialize(self):
+ lazy = LazyRegistry(
+ name="lazy",
+ group={"model": {"_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel", "name": "pending"}},
+ )
+ assert "Materialize" in all_text(model_card(lazy, "group/model").to_node())
+ assert not lazy["group"].is_loaded("model")
+
+ def test_browser_width_sets_gutter(self):
+ node = registry_viewer(_registry(), browser_width=500).to_node()
+ gutters = nodes_with_tag(node, "spa-gutter")
+ assert prop_str(gutters[0], "width") == "500px"
+
+ def test_empty_registry_renders(self):
+ node = registry_viewer(ModelRegistry(name="empty")).to_node()
+ # Only the placeholder case, and no graph to draw.
+ assert set(nodes_with_tag(node, "spa-switch")[0]["slots"]) == {"default"}
+ assert not nodes_with_tag(node, "spaday-dagre")
+
+ def test_lazy_registry_renders_without_materializing_models(self):
+ lazy = LazyRegistry(
+ name="lazy",
+ group={
+ "model": {
+ "_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel",
+ "name": "pending",
+ },
+ "other": {
+ "_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel",
+ "name": "other",
+ },
+ },
+ )
+
+ node = registry_viewer(lazy).to_node()
+
+ assert not lazy["group"].is_loaded("model")
+ assert not lazy["group"].is_loaded("other")
+ assert set(nodes_with_tag(node, "spa-switch")[0]["slots"]) == {"group/model", "group/other", "default"}
+
+ def test_pending_models_are_flagged_in_the_tree(self):
+ lazy = LazyRegistry(
+ name="lazy",
+ group={"model": {"_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel", "name": "pending"}},
+ )
+ decorations = prop_value(registry_tree(lazy).to_node(), "decorations")
+ assert set(decorations) == {"group/model"}
+ assert decorations["group/model"]["badge"] == "lazy"
+ assert not lazy["group"].is_loaded("model")
diff --git a/ccflow/tests/ui/spaday/utils.py b/ccflow/tests/ui/spaday/utils.py
new file mode 100644
index 00000000..a152c645
--- /dev/null
+++ b/ccflow/tests/ui/spaday/utils.py
@@ -0,0 +1,75 @@
+"""Helpers for inspecting the serialized spaday component tree in tests."""
+
+
+def iter_nodes(node):
+ """Yield ``node`` and every descendant node (depth-first) of a ``to_node()`` dict."""
+ yield node
+ for children in node.get("slots", {}).values():
+ for child in children:
+ yield from iter_nodes(child)
+
+
+def nodes_with_tag(node, tag):
+ """All nodes in the tree with the given element ``tag``."""
+ return [n for n in iter_nodes(node) if n.get("tag") == tag]
+
+
+def text_of(node):
+ """The node's ``textContent`` string, or None."""
+ tc = node.get("props", {}).get("textContent")
+ return tc.get("Str") if isinstance(tc, dict) else None
+
+
+def all_text(node):
+ """Every ``textContent`` string found in the tree."""
+ return [t for t in (text_of(n) for n in iter_nodes(node)) if t is not None]
+
+
+def prop_str(node, name):
+ """A node prop serialized as a string (the ``{"Str": value}`` tag), or None."""
+ value = node.get("props", {}).get(name)
+ return value.get("Str") if isinstance(value, dict) else None
+
+
+def untag(value):
+ """Convert a tagged prop value (``{"Str": …}``, ``{"List": […]}``, …) back to plain Python."""
+ if value == "Null":
+ return None
+ if not isinstance(value, dict) or len(value) != 1:
+ return value
+ ((kind, inner),) = value.items()
+ if kind == "List":
+ return [untag(v) for v in inner]
+ if kind == "Map":
+ return {k: untag(v) for k, v in inner.items()}
+ return inner
+
+
+def prop_value(node, name):
+ """A node prop as plain Python, or None when the prop is absent."""
+ props = node.get("props", {})
+ return untag(props[name]) if name in props else None
+
+
+def event_action(node, event):
+ """The serialized action bound to ``event`` on the node, or None."""
+ return node.get("events", {}).get(event)
+
+
+def click_set_field(node):
+ """The literal value written by a ``click`` SetField action on the node, or None."""
+ event = node.get("events", {}).get("click")
+ if event and event.get("kind") == "set-field":
+ return event["value"]["value"]
+ return None
+
+
+def show_when_value(node):
+ """The literal a ``spa-show`` compares ``selected`` against in its ``when`` binding, or None."""
+ when = node.get("bindings", {}).get("when")
+ if not when or "compute" not in when:
+ return None
+ expr = when["compute"]
+ if expr.get("expr") == "eq":
+ return expr["b"].get("value")
+ return None
diff --git a/ccflow/ui/__init__.py b/ccflow/ui/__init__.py
index 417aeab3..29d62b42 100644
--- a/ccflow/ui/__init__.py
+++ b/ccflow/ui/__init__.py
@@ -1,3 +1 @@
-from .cli import *
-from .model import *
-from .registry import *
+from .panel import *
diff --git a/ccflow/ui/cli.py b/ccflow/ui/cli.py
index 7d39bb7e..b7657617 100644
--- a/ccflow/ui/cli.py
+++ b/ccflow/ui/cli.py
@@ -1,99 +1,3 @@
-"""CLI for serving ModelRegistryViewer as a Panel application."""
+"""Compatibility imports for the Panel UI CLI."""
-import argparse
-from collections.abc import Callable
-
-import panel as pn
-
-from ccflow import ModelRegistry
-from ccflow.utils.hydra import add_hydra_config_args, add_panel_server_args, load_config, resolve_config_paths
-
-from .registry import ModelRegistryViewer
-
-__all__ = ("registry_viewer_cli",)
-
-
-def _get_ui_args_parser() -> argparse.ArgumentParser:
- """Create argument parser with UI server configuration options."""
- parser = argparse.ArgumentParser(
- add_help=True,
- description="Serve ModelRegistryViewer as a Panel application",
- )
-
- # Standard hydra config loading arguments
- add_hydra_config_args(parser)
-
- # Standard Panel server arguments
- add_panel_server_args(parser)
-
- # Viewer-specific arguments
- parser.add_argument(
- "--browser-width",
- type=int,
- default=400,
- help="Initial width of the registry browser sidebar (default: 400). User can drag to resize.",
- )
- parser.add_argument(
- "--title",
- type=str,
- default="ccflow Model Registry",
- help="Title shown in the page header (default: 'ccflow Model Registry')",
- )
-
- return parser
-
-
-def registry_viewer_cli(
- config_path: str = "",
- config_name: str = "",
- hydra_main: Callable | None = None,
-):
- """CLI entry point for serving ModelRegistryViewer.
-
- Parameters
- ----------
- config_path
- The config_path specified in hydra.main()
- config_name
- The config_name specified in hydra.main()
- hydra_main
- The function decorated with hydra.main(). Used to resolve config_path
- relative to the decorated function's file location.
- """
- parser = _get_ui_args_parser()
- args = parser.parse_args()
-
- # Resolve config paths using shared helper
- root_config_dir, root_config_name = resolve_config_paths(args, config_path, config_name, hydra_main)
-
- # Load config using hydra utilities
- result = load_config(
- root_config_dir=root_config_dir,
- root_config_name=root_config_name,
- config_dir=args.config_dir,
- config_name=args.config_dir_config_name,
- overrides=args.overrides,
- basepath=args.basepath,
- )
-
- # Load registry from config
- registry = ModelRegistry.root()
- registry.load_config(cfg=result.cfg, overwrite=True)
-
- # Create app factory for per-session instances
- def create_app():
- viewer = ModelRegistryViewer(
- registry,
- browser_width=args.browser_width,
- title=args.title,
- )
- return viewer.__panel__()
-
- # Serve the panel app (callable = fresh instance per session)
- pn.serve(
- create_app,
- address=args.address,
- port=args.port,
- allow_websocket_origin=args.allow_websocket_origin,
- show=args.show,
- )
+from .panel.cli import *
diff --git a/ccflow/ui/model.py b/ccflow/ui/model.py
index 95b4d126..59a6365b 100644
--- a/ccflow/ui/model.py
+++ b/ccflow/ui/model.py
@@ -1,280 +1,3 @@
-import html
+"""Compatibility imports for Panel model views."""
-import panel as pn
-import panel_material_ui # noqa: F401 Must be imported like this to register the extension
-import panel_material_ui as pmui
-import param
-from pydantic._internal._repr import display_as_type
-
-import ccflow
-
-pn.extension()
-pn.extension("jsoneditor")
-
-
-__all__ = ("ModelConfigViewer", "ModelTypeViewer", "ModelViewer")
-
-
-_FIELD_STYLES = {
- "name": "color:#0550ae;", # blue
- "type": "color:#8250df;", # purple
- "description": "color:#57606a;font-style:italic;", # muted gray
-}
-
-
-class ModelTypeViewer(param.Parameterized):
- """
- Displays type name, class docstring, and fields for a Pydantic model type.
- """
-
- model_type = param.Parameter(default=None)
-
- def __init__(self, **params):
- super().__init__(**params)
-
- self._pane = pn.pane.HTML("", sizing_mode="stretch_width")
- self._layout = pn.Column(
- self._pane,
- sizing_mode="stretch_width",
- )
-
- self.param.watch(self._on_type_change, "model_type")
-
- def __panel__(self):
- return self._layout
-
- def _on_type_change(self, event):
- model_cls = event.new
- if model_cls is None:
- self._pane.object = ""
- return
-
- type_name = display_as_type(model_cls)
-
- # Class documentation
- docs = (model_cls.__doc__ or "").strip()
- docs_html = ""
- if docs:
- escaped = html.escape(docs).replace("\n", "
")
- docs_html = f"""
-
{escaped}
- {html.escape(name)}'
- type_html = f'{html.escape(field_type)}'
- desc_html = f' — {html.escape(desc)}' if desc else ""
- field_items.append(f'{html.escape(type_name)}
- {html.escape(row)}{html.escape(self.model_path)}
- {escaped}
+ {html.escape(name)}'
+ type_html = f'{html.escape(field_type)}'
+ desc_html = f' — {html.escape(desc)}' if desc else ""
+ field_items.append(f'{html.escape(type_name)}
+ {html.escape(row)}{html.escape(self.model_path)}
+ `` element that wraps long identifiers."""
+ node = element("code").text(text).style(overflow_wrap="anywhere")
+ return node.style(color=color) if color else node
+
+
+def _pre(text: str) -> Component:
+ """A preformatted code block."""
+ return element("pre").text(text).style(**_PRE_STYLE)
+
+
+def model_type_view(model_cls) -> Component:
+ """Show a Pydantic model type's name, class docstring, and fields."""
+ if model_cls is None:
+ return Column()
+
+ children = [Row(Strong("Type:"), WaBadge(variant="brand").text(display_as_type(model_cls)), gap="0.5rem", align="center")]
+
+ docs = (model_cls.__doc__ or "").strip()
+ if docs:
+ children.append(_labeled("Class Documentation", _pre(docs)))
+
+ fields = getattr(model_cls, "model_fields", {})
+ if fields:
+ items = element("ul").style(margin="0", padding_left="18px")
+ for name, field in fields.items():
+ entry = element("li").style(overflow_wrap="anywhere")
+ entry.child(_code(name, color="#0550ae"))
+ entry.child(Text(f" ({display_as_type(field.annotation)})"))
+ if field.description:
+ entry.child(Text(f" — {field.description}"))
+ items.child(entry)
+ children.append(_labeled("Fields", items))
+
+ return Column(*children, gap="0.75rem")
+
+
+def _dependencies_view(model) -> Component:
+ """A bulleted list of the model's registry dependencies, or ``None`` if it has none."""
+ deps = model.get_registry_dependencies()
+ if not deps:
+ return None
+
+ rows = sorted({group[0] if len(group) == 1 else " | ".join(group) for group in deps})
+ items = element("ul").style(margin="0", padding_left="18px")
+ for row in rows:
+ items.child(element("li").child(_code(row)))
+ return _labeled("Registry Dependencies", items)
+
+
+def model_config_view(model, path: str = "") -> Component:
+ """Show instance-level metadata: registry path, description, and dependencies."""
+ children = []
+
+ if path:
+ children.append(_labeled("Registry Path", _code(path)))
+
+ description = model.meta.description.strip() if hasattr(model, "meta") and model.meta.description else ""
+ if description:
+ children.append(_labeled("Instance Description", element("div").text(description)))
+
+ dependencies = _dependencies_view(model)
+ if dependencies is not None:
+ children.append(dependencies)
+
+ if not children:
+ children.append(Text("No additional metadata."))
+
+ return Column(*children, gap="0.75rem")
+
+
+def model_view(model, path: str = "", dependency_view: Component | None = None) -> Component:
+ """A card with tabs inspecting a single ccflow model instance.
+
+ ``dependency_view`` is this model's dependency graph, added as a tab when it has one.
+ """
+ type_name = display_as_type(type(model))
+
+ tabs = Tabs(active="summary")
+ tabs.tab("Summary", model_config_view(model, path), name="summary")
+ tabs.tab("Model Type", model_type_view(type(model)), name="model-type")
+ if isinstance(model, ccflow.CallableModel):
+ tabs.tab("Context Type", model_type_view(model.context_type), name="context-type")
+ tabs.tab("Result Type", model_type_view(model.result_type), name="result-type")
+ if dependency_view is not None:
+ tabs.tab("Dependencies", dependency_view, name="dependencies")
+
+ params = model.__pydantic_serializer__.to_python(model, fallback=str, mode="json")
+ tabs.tab("Parameters", _pre(json.dumps(params, indent=2, default=str)), name="parameters")
+
+ header = Row(WaBadge(variant="brand").text(type_name), Strong(path or type_name), gap="0.5rem", align="center")
+ return WaCard(appearance="outlined").child(Column(header, WaDivider(), tabs, gap="0.75rem"))
+
+
+def _materialize_button(path: str | Expr) -> Component:
+ """Instantiate the pending model server-side, then diff the refreshed tree into the page."""
+ materialize = Sequence(
+ CallEndpoint("POST", MATERIALIZE_ENDPOINT, obj({"path": path}), result=MATERIALIZE_RESULT_FIELD),
+ RefreshTree(),
+ )
+ return WaButton(variant="brand").text("Materialize").on("click", materialize)
+
+
+def pending_model_view(path: str | Expr) -> Component:
+ """A card for a selected model that has not been instantiated.
+
+ ``Materialize`` instantiates it on the server and refreshes the tree in place, so the card becomes
+ the full :func:`model_view` detail without a reload. Failures are reported from
+ :data:`MATERIALIZE_RESULT_FIELD`.
+ """
+ tabs = Tabs(active="summary")
+ tabs.tab(
+ "Summary",
+ Column(
+ _labeled("Registry Path", _code(path)),
+ Text("This model has not been instantiated. Materialize it to inspect its type, context, result, and parameters."),
+ _materialize_button(path),
+ gap="0.75rem",
+ ),
+ name="summary",
+ )
+ header = Row(WaBadge(variant="neutral").text("Pending"), Strong("Pending model"), gap="0.5rem", align="center")
+ return WaCard(appearance="outlined").child(Column(header, WaDivider(), tabs, gap="0.75rem"))
diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py
new file mode 100644
index 00000000..cffbd153
--- /dev/null
+++ b/ccflow/ui/spaday/registry.py
@@ -0,0 +1,176 @@
+"""Registry browser and top-level viewer as a spaday component tree.
+
+Selection is driven client-side through the runtime's signal store: picking a leaf in the
+``spaday-tree`` writes the model's path to the ``selected`` field, and a :class:`Switch` routes to that
+model's detail card. ``selected`` is bound to a URL query parameter by the server, so a model is
+linkable and back/forward navigate between models.
+"""
+
+from collections.abc import Mapping
+from urllib.parse import quote
+
+from spaday import Component, Strong, Text, element
+from spaday.actions import SetField, arr, cond, event_value, field, lit
+from spaday.components import App, Body, Column, Gutter, Lazy, Main, Nav, Row, Switch, Toast
+from spaday_trees import Tree
+from spaday_webawesome import WaSwitch
+
+import ccflow
+
+from .graph import MENU_PATH_FIELD, dependency_edges, model_dependency_view
+from .model import MATERIALIZE_RESULT_FIELD, model_view, pending_model_view
+
+__all__ = (
+ "CARD_ENDPOINT",
+ "DARK_FIELD",
+ "SELECTED_FIELD",
+ "model_card",
+ "registry_leaves",
+ "registry_store",
+ "registry_tree",
+ "registry_viewer",
+)
+
+#: Path of the endpoint (served by :func:`ccflow.ui.spaday.cli.serve_registry`) returning one model's
+#: card, so the initial page carries a placeholder per model rather than every card.
+CARD_ENDPOINT = "/card"
+
+#: The signal-store field holding the selected model's registry path ("" when nothing is selected).
+SELECTED_FIELD = "selected"
+
+#: The signal-store field driving the ``wa-dark`` page theme.
+DARK_FIELD = "dark"
+
+
+def registry_store() -> dict:
+ """The initial signal-store state the viewer is mounted with."""
+ return {
+ SELECTED_FIELD: "",
+ MENU_PATH_FIELD: "",
+ MATERIALIZE_RESULT_FIELD: {},
+ DARK_FIELD: False,
+ }
+
+
+def _sorted_items(registry, sort_children: bool):
+ """Registry entries, optionally with subregistries first and each group sorted alphabetically."""
+ if isinstance(registry, ccflow.LazyRegistry):
+ items = []
+ for name in registry.models:
+ model = registry.get_loaded(name)
+ if model is None:
+ model = registry.get_pending_config(name)
+ if model is None:
+ # Materialized between the two lookups, so it is no longer pending.
+ model = registry.get_loaded(name)
+ if model is None:
+ # Removed between the lookups; there is nothing left to show.
+ continue
+ items.append((name, model))
+ else:
+ items = list(registry.models.items())
+ if sort_children:
+ items = sorted(items, key=lambda kv: (not isinstance(kv[1], ccflow.ModelRegistry), kv[0]))
+ return list(items)
+
+
+def registry_leaves(registry, *, sort_children: bool = True, _prefix: str = "") -> list[tuple[str, object]]:
+ """Return ``(path, model)`` for every leaf model in the registry, depth-first."""
+ leaves: list[tuple[str, object]] = []
+ for name, model in _sorted_items(registry, sort_children):
+ path = f"{_prefix}/{name}" if _prefix else name
+ if isinstance(model, ccflow.ModelRegistry):
+ leaves.extend(registry_leaves(model, sort_children=sort_children, _prefix=path))
+ else:
+ leaves.append((path, model))
+ return leaves
+
+
+def _is_pending(model) -> bool:
+ """Whether the entry is an un-instantiated (lazy) registry config rather than a model."""
+ return isinstance(model, Mapping) and "_target_" in model
+
+
+def registry_tree(registry, *, sort_children: bool = True) -> Tree:
+ """Build the registry browser: a path-driven tree whose leaf selection sets ``selected``.
+
+ ``spaday-tree`` derives the hierarchy from the ``/``-separated paths itself and provides its own
+ search box, so the whole registry is described by the flat leaf-path list.
+ """
+ leaves = registry_leaves(registry, sort_children=sort_children)
+ decorations = {
+ path: {"badge": "lazy", "tone": "warning", "tooltip": "Configured but not yet instantiated"} for path, model in leaves if _is_pending(model)
+ }
+ # The tree virtualizes its rows, so it renders nothing unless it is given a height to fill.
+ return (
+ Tree(paths=[path for path, _ in leaves], decorations=decorations, id="registry-tree")
+ # Revealing the selection is what expands the tree to a deep-linked model.
+ .compute("selected_paths", cond(field(SELECTED_FIELD), arr(field(SELECTED_FIELD)), lit([])))
+ .on("selection-change", SetField(SELECTED_FIELD, event_value("paths.0")))
+ .style(display="block", flex="1", min_height="70vh")
+ )
+
+
+def _placeholder() -> Component:
+ """The main-area hint shown when no model is selected."""
+ return Column(
+ Strong("Select a model"),
+ Text("Choose a model from the registry on the left to inspect its configuration, type, and parameters."),
+ gap="0.5rem",
+ )
+
+
+def _loading() -> Component:
+ """Shown while a card is being fetched."""
+ return Text("Loading…")
+
+
+def _code(text: str) -> Component:
+ return element("code").text(text).style(overflow_wrap="anywhere")
+
+
+def model_card(registry, path: str, *, sort_children: bool = True) -> Component:
+ """The detail card for one model, built on demand for :data:`CARD_ENDPOINT`.
+
+ Dependencies are resolved against the whole registry, so the graph is the same as it would be if
+ the card had been inlined.
+ """
+ leaves = registry_leaves(registry, sort_children=sort_children)
+ model = next((candidate for leaf_path, candidate in leaves if leaf_path == path), None)
+ if model is None:
+ return Column(Strong("Unknown model"), _code(path), gap="0.5rem")
+ if _is_pending(model):
+ return pending_model_view(path)
+ dependency_view = model_dependency_view(path, dependency_edges(leaves), selected_field=SELECTED_FIELD)
+ return model_view(model, path, dependency_view)
+
+
+def _details_view(leaves: list[tuple[str, object]]) -> Component:
+ """Route to the selected model's card, each deferred so only the visible one is ever fetched."""
+ cases = {path: Lazy(_loading(), src=f"{CARD_ENDPOINT}?model={quote(path)}") for path, _ in leaves}
+ return Switch(SELECTED_FIELD, cases, default=_placeholder())
+
+
+def registry_viewer(registry, *, title: str = "ccflow Model Registry", browser_width: int = 400, sort_children: bool = True) -> App:
+ """Compose the full page: a sidebar registry tree and the selected model's detail card."""
+ leaves = registry_leaves(registry, sort_children=sort_children)
+
+ sidebar = Gutter(
+ Column(Strong("Registry"), registry_tree(registry, sort_children=sort_children), gap="0.75rem"),
+ width=f"{browser_width}px",
+ gap="0.75rem",
+ )
+
+ theme = Row(WaSwitch().text("Dark").bind("checked", DARK_FIELD, mode="two-way"), gap="0.5rem", align="center")
+
+ # A materialize that fails server-side (a bad _target_, a missing dependency) reports here.
+ toasts = Toast(tone="danger", timeout=0, id="materialize-toasts").compute(
+ "message",
+ cond(field(f"{MATERIALIZE_RESULT_FIELD}.ok"), lit(""), field(f"{MATERIALIZE_RESULT_FIELD}.body.message")),
+ )
+
+ return App(
+ Nav(Row(Strong(title), theme, gap="1rem", align="center", justify="space-between")),
+ Body(sidebar, Main(_details_view(leaves))),
+ toasts,
+ ).bind_root_class("wa-dark", DARK_FIELD)
diff --git a/ccflow/utils/hydra.py b/ccflow/utils/hydra.py
index bc07dea6..946b98e5 100644
--- a/ccflow/utils/hydra.py
+++ b/ccflow/utils/hydra.py
@@ -350,28 +350,19 @@ def resolve_config_paths(
This helper extracts the common logic for resolving config paths from either
CLI arguments or default values provided by the decorated hydra.main function.
- Parameters
- ----------
- args
- Parsed argparse namespace containing config_path and config_name attributes
- config_path
- Default config_path, typically from hydra.main() decorator
- config_name
- Default config_name, typically from hydra.main() decorator
- hydra_main
- The function decorated with hydra.main(). Used to resolve config_path
- relative to the decorated function's file location.
-
- Returns
- -------
- tuple
- (root_config_dir, root_config_name)
-
- Raises
- ------
- ValueError
- If neither args.config_path nor hydra_main+config_path are provided
- If neither args.config_name nor config_name are provided
+ Args:
+ args: Parsed argparse namespace containing config_path and config_name attributes
+ config_path: Default config_path, typically from hydra.main() decorator
+ config_name: Default config_name, typically from hydra.main() decorator
+ hydra_main: The function decorated with hydra.main(). Used to resolve config_path
+ relative to the decorated function's file location.
+
+ Returns:
+ tuple: (root_config_dir, root_config_name)
+
+ Raises:
+ ValueError: If neither args.config_path nor hydra_main+config_path are provided
+ If neither args.config_name nor config_name are provided
"""
if args.config_path:
root_config_dir = args.config_path
diff --git a/pyproject.toml b/pyproject.toml
index f3de296f..0667a95d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -64,6 +64,12 @@ full = [
"ray",
"scipy",
"smart_open",
+ "spaday>=0.7.9,<1",
+ "spaday-dagre>=0.2.1",
+ "spaday-trees>=0.2.3",
+ "spaday-webawesome",
+ "starlette",
+ "uvicorn",
"xarray",
]
otel = [
@@ -96,6 +102,12 @@ develop = [
"ray",
"scipy",
"smart_open",
+ "spaday>=0.7.9,<1",
+ "spaday-dagre>=0.2.1",
+ "spaday-trees>=0.2.3",
+ "spaday-webawesome",
+ "starlette",
+ "uvicorn",
"xarray",
# Reporting deps
"opentelemetry-api",
@@ -119,6 +131,7 @@ test = [
]
[project.scripts]
+ccflow-ui-spaday = "ccflow.ui.spaday.cli:main"
[project.urls]
Repository = "https://github.com/Point72/ccflow"