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/examples/tpch/config/conf.yaml b/ccflow/examples/tpch/config/conf.yaml index b888697b..dcf6b76c 100644 --- a/ccflow/examples/tpch/config/conf.yaml +++ b/ccflow/examples/tpch/config/conf.yaml @@ -24,19 +24,15 @@ # (``load_config(overrides=["tpch.backend.scale_factor=1.0"])``) reconfigures # every table, answer and query consistently. -# --------------------------------------------------------------------------- # Shared DuckDB backend. Plain ``ccflow.BaseModel`` — not callable itself, # but registered so all providers share one connection and one ``dbgen`` call. -# --------------------------------------------------------------------------- tpch: backend: _target_: ccflow.examples.tpch.TPCHDuckDBBackend scale_factor: 0.1 -# --------------------------------------------------------------------------- # Per-table providers. One instance per TPC-H table; the output schema of # each instance is fixed by its ``table`` field. -# --------------------------------------------------------------------------- table: customer: _target_: ccflow.examples.tpch.TPCHTableProvider @@ -71,10 +67,8 @@ table: backend: /tpch/backend table: supplier -# --------------------------------------------------------------------------- # Reference answers, one per query, served straight from DuckDB's # ``tpch_answers()`` table at the configured scale factor. -# --------------------------------------------------------------------------- answer: Q1: _target_: ccflow.examples.tpch.TPCHAnswerProvider @@ -165,12 +159,10 @@ answer: backend: /tpch/backend query_id: 22 -# --------------------------------------------------------------------------- # The 22 TPC-H queries. Each ``TPCHQuery`` is the same Python class with a # different ``query_id`` and a different tuple of table-provider inputs. # Wiring the inputs in YAML makes each query's table dependencies explicit # and overridable per-query. -# --------------------------------------------------------------------------- query: Q1: _target_: ccflow.examples.tpch.TPCHQuery diff --git a/ccflow/flow_model.py b/ccflow/flow_model.py index 2119c3d9..da2951ce 100644 --- a/ccflow/flow_model.py +++ b/ccflow/flow_model.py @@ -118,11 +118,6 @@ _AnyCallable = Callable[..., Any] -# --------------------------------------------------------------------------- -# Internal data structures -# --------------------------------------------------------------------------- - - class _UnsetFlowInput: def __repr__(self) -> str: return "" @@ -393,11 +388,6 @@ class _LocalFlowModelPicklePayload(NamedTuple): factory_kwargs: dict[str, Any] -# --------------------------------------------------------------------------- -# Small value helpers -# --------------------------------------------------------------------------- - - def _context_values(context: ContextBase) -> dict[str, Any]: return dict(context) @@ -469,11 +459,6 @@ def _concrete_context_type(context_type: Any) -> type[ContextBase] | None: return None -# --------------------------------------------------------------------------- -# Type coercion, lazy thunks, and registry references -# --------------------------------------------------------------------------- - - def _remember_type_adapter(cache: "OrderedDict[Any, Any]", key: Any, value: Any) -> Any: cache[key] = value cache.move_to_end(key) @@ -670,11 +655,6 @@ def _ensure_named_python_function(fn: _AnyCallable, *, decorator_name: str) -> N raise TypeError(f"{decorator_name} only supports named Python functions.") -# --------------------------------------------------------------------------- -# Context-transform serialization and generated-model persistence -# --------------------------------------------------------------------------- - - def _serialize_context_transform_config(config: _FlowModelConfig) -> str: payload = cloudpickle.dumps(_serialize_flow_model_config(config), protocol=5) return b64encode(payload).decode("ascii") @@ -867,11 +847,6 @@ def _register_generated_model_class(config: _FlowModelConfig, generated_cls: typ ) -# --------------------------------------------------------------------------- -# Runtime context contracts and dependency projection -# --------------------------------------------------------------------------- - - def _runtime_context_for_model(model: CallableModel, values: dict[str, Any]) -> ContextBase: """Build the runtime context object expected by ``model`` from raw values.""" @@ -1026,11 +1001,6 @@ def _missing_regular_param_names(model: "_GeneratedFlowModelBase", config: _Flow return missing -# --------------------------------------------------------------------------- -# Generated model input resolution -# --------------------------------------------------------------------------- - - def _resolve_regular_param_value(model: "_GeneratedFlowModelBase", param: _FlowModelParam, context: ContextBase) -> Any: value = getattr(model, param.name, _UNSET_FLOW_INPUT) if _is_unset_flow_input(value): @@ -1470,10 +1440,6 @@ def _coerce_model_context_value(model: CallableModel, field_name: str, value: An return _coerce_value(field_name, value, contract.input_types[field_name], source) -# --------------------------------------------------------------------------- -# Effective identity helpers -# --------------------------------------------------------------------------- - # Identity terms used below: # - config identity: stable hash of the analyzed Flow.model contract, fixed at # generated-class construction time and carried through local restore. @@ -1843,11 +1809,6 @@ def _generated_model_identity_payload( ) -# --------------------------------------------------------------------------- -# Static binding resolution and with_context normalization -# --------------------------------------------------------------------------- - - def _resolved_static_contextual_values( model: "_GeneratedFlowModelBase", config: _FlowModelConfig, @@ -2104,11 +2065,6 @@ def _normalize_with_context(model: CallableModel, patches: tuple[Any, ...], fiel return _validate_static_context_spec_declared_context(model, context_spec) -# --------------------------------------------------------------------------- -# Bound context application and compute context construction -# --------------------------------------------------------------------------- - - def _context_from_values_preserving_private_state(context: ContextBase, values: dict[str, Any]) -> ContextBase: """Validate updated public values while preserving private context state.""" @@ -2537,11 +2493,6 @@ def _recursive_dependency_specs_for_flow( active.remove(model_id) -# --------------------------------------------------------------------------- -# model.flow API and BoundModel wrapper -# --------------------------------------------------------------------------- - - class FlowAPI: """API namespace exposed as ``model.flow``. @@ -3158,11 +3109,6 @@ def _evaluation_identity_payload( return _generated_model_identity_payload(self, context) -# --------------------------------------------------------------------------- -# Generated model method builders and decorators -# --------------------------------------------------------------------------- - - def _make_call_impl(config: _FlowModelConfig) -> _AnyCallable: """Create the ``__call__`` implementation for one generated model class.""" 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..53ffcd8d --- /dev/null +++ b/ccflow/tests/ui/spaday/test_cli.py @@ -0,0 +1,188 @@ +"""Unit tests for ccflow.ui.spaday.cli module.""" + +import importlib +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, 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"] + + +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): + registry = ModelRegistry(name="test") + registry.add("widget", SimpleModel(name="widget")) + app = serve_registry(registry, title="T", run=False) + # The tree route serializes the viewer; the model path should appear in it. + tree_route = next(r for r in app.routes if getattr(r, "path", None) == "/tree.json") + assert tree_route is not None + + 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_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..eba0da6c --- /dev/null +++ b/ccflow/tests/ui/spaday/test_model.py @@ -0,0 +1,147 @@ +"""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 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): + registry = ModelRegistry(name="test") + dep = SimpleModel(name="dep") + registry.add("dep", dep) + holder = MyCallable() + registry.add("holder", holder) + # A model that depends on another shows its registry dependencies (if any). + node = model_config_view(holder, "holder").to_node() + assert node["tag"] == "spa-stack" + + +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..fd37a41a --- /dev/null +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -0,0 +1,199 @@ +"""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_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""" -
-
Class Documentation:
-
{escaped}
-
- """ - - # Fields - fields = getattr(model_cls, "model_fields", {}) - field_items = [] - - for name, field in fields.items(): - field_type = display_as_type(field.annotation) - desc = field.description or "" - name_html = f'{html.escape(name)}' - type_html = f'{html.escape(field_type)}' - desc_html = f' — {html.escape(desc)}' if desc else "" - field_items.append(f'
  • {name_html} ({type_html}){desc_html}
  • ') - - fields_html = "" - if field_items: - fields_html = f""" -
    -
    Fields:
    - -
    - """ - - self._pane.object = f""" -
    -
    - Type: - {html.escape(type_name)} -
    - {docs_html} - {fields_html} -
    - """ - - -class ModelConfigViewer(param.Parameterized): - """ - Displays instance-level metadata (description + dependencies). - """ - - model = param.Parameter(default=None) - - def __init__(self, **params): - super().__init__(**params) - - self.model_path = "" - self._metadata = pn.pane.HTML("", sizing_mode="stretch_width") - - self._layout = pn.Column( - self._metadata, - sizing_mode="stretch_width", - ) - - self.param.watch(self._on_model_change, "model") - - def __panel__(self): - return self._layout - - def _render_dependencies(self, model): - deps = model.get_registry_dependencies() - if not deps: - return "" - - # Collect all values, deduplicate, and sort - all_paths = [] - for group in deps: - if len(group) == 1: - all_paths.append(group[0]) - else: - all_paths.append(" | ".join(group)) - - # Unique elements, sorted - rows = sorted(set(all_paths)) - - items = "".join(f'
  • {html.escape(row)}
  • ' for row in rows) - - return f""" -
    -
    - Registry Dependencies -
    - -
    - """ - - def _on_model_change(self, event): - model = event.new - if model is None: - self._metadata.object = "" - return - - path_html = "" - if self.model_path: - path_html = f""" -
    -
    Registry Path
    - {html.escape(self.model_path)} -
    - """ - - description = model.meta.description.strip() if hasattr(model, "meta") and model.meta.description else "" - - desc_html = "" - if description: - try: - import bleach - - description = bleach.linkify(html.escape(description)) - except ImportError: - description = html.escape(description) - desc_html = f""" -
    -
    Instance Description
    -
    {description}
    -
    - """ - - self._metadata.object = path_html + desc_html + self._render_dependencies(model) - - -class ModelViewer(param.Parameterized): - """ - Displays a tabbed view of a ccflow Model instance, including description, registry dependencies, docstrings and json representation. - """ - - model = param.Parameter(default=None) - - def __init__(self, **params): - super().__init__(**params) - - self.model_path = "" - - # Sub-viewers (no JSONEditor inside) - self._config_viewer = ModelConfigViewer() - self._type_viewer = ModelTypeViewer() - self._context_type_viewer = ModelTypeViewer() - self._result_type_viewer = ModelTypeViewer() - - # Material UI Tabs (metadata only) - self._tabs = pmui.Tabs( - active=0, - sizing_mode="stretch_width", - ) - - # JSON editor (stable, but hidden until a model is selected) - self._json_editor = pn.widgets.JSONEditor( - value={}, - mode="view", - menu=False, - sizing_mode="stretch_width", - min_width=400, - ) - - self._json_container = pn.Column( - "## Parameters", - self._json_editor, - visible=False, # hidden initially - sizing_mode="stretch_width", - ) - - self._layout = pn.Column( - "## Model Viewer", - self._tabs, - pn.Spacer(height=12), - self._json_container, - sizing_mode="stretch_width", - ) - - self.param.watch(self._on_model_change, "model") - - def __panel__(self): - return self._layout - - def _on_model_change(self, event): - model = event.new - self._tabs.clear() - - if model is None: - # hide JSON editor if no model - self._json_editor.value = {} - self._json_container.visible = False - return - - # Config tab - self._config_viewer.model_path = self.model_path - self._config_viewer.model = model - self._tabs.append(("Summary", self._config_viewer)) - - # Model Type tab - self._type_viewer.model_type = type(model) - self._tabs.append(("Model Type", self._type_viewer)) - - # CallableModel extras - if isinstance(model, ccflow.CallableModel): - self._context_type_viewer.model_type = model.context_type - self._tabs.append(("Context Type", self._context_type_viewer)) - - self._result_type_viewer.model_type = model.result_type - self._tabs.append(("Result Type", self._result_type_viewer)) - - # Default to Config tab - self._tabs.active = 0 - - # Update & show JSONEditor - self._json_editor.value = model.__pydantic_serializer__.to_python(model, fallback=str, mode="json") - self._json_container.visible = True +from .panel.model import * diff --git a/ccflow/ui/panel/__init__.py b/ccflow/ui/panel/__init__.py new file mode 100644 index 00000000..417aeab3 --- /dev/null +++ b/ccflow/ui/panel/__init__.py @@ -0,0 +1,3 @@ +from .cli import * +from .model import * +from .registry import * diff --git a/ccflow/ui/panel/cli.py b/ccflow/ui/panel/cli.py new file mode 100644 index 00000000..4991a8ad --- /dev/null +++ b/ccflow/ui/panel/cli.py @@ -0,0 +1,95 @@ +"""CLI for serving ModelRegistryViewer as a Panel application.""" + +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. + + Args: + 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, + ) diff --git a/ccflow/ui/panel/model.py b/ccflow/ui/panel/model.py new file mode 100644 index 00000000..95b4d126 --- /dev/null +++ b/ccflow/ui/panel/model.py @@ -0,0 +1,280 @@ +import html + +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""" +
    +
    Class Documentation:
    +
    {escaped}
    +
    + """ + + # Fields + fields = getattr(model_cls, "model_fields", {}) + field_items = [] + + for name, field in fields.items(): + field_type = display_as_type(field.annotation) + desc = field.description or "" + name_html = f'{html.escape(name)}' + type_html = f'{html.escape(field_type)}' + desc_html = f' — {html.escape(desc)}' if desc else "" + field_items.append(f'
  • {name_html} ({type_html}){desc_html}
  • ') + + fields_html = "" + if field_items: + fields_html = f""" +
    +
    Fields:
    + +
    + """ + + self._pane.object = f""" +
    +
    + Type: + {html.escape(type_name)} +
    + {docs_html} + {fields_html} +
    + """ + + +class ModelConfigViewer(param.Parameterized): + """ + Displays instance-level metadata (description + dependencies). + """ + + model = param.Parameter(default=None) + + def __init__(self, **params): + super().__init__(**params) + + self.model_path = "" + self._metadata = pn.pane.HTML("", sizing_mode="stretch_width") + + self._layout = pn.Column( + self._metadata, + sizing_mode="stretch_width", + ) + + self.param.watch(self._on_model_change, "model") + + def __panel__(self): + return self._layout + + def _render_dependencies(self, model): + deps = model.get_registry_dependencies() + if not deps: + return "" + + # Collect all values, deduplicate, and sort + all_paths = [] + for group in deps: + if len(group) == 1: + all_paths.append(group[0]) + else: + all_paths.append(" | ".join(group)) + + # Unique elements, sorted + rows = sorted(set(all_paths)) + + items = "".join(f'
  • {html.escape(row)}
  • ' for row in rows) + + return f""" +
    +
    + Registry Dependencies +
    + +
    + """ + + def _on_model_change(self, event): + model = event.new + if model is None: + self._metadata.object = "" + return + + path_html = "" + if self.model_path: + path_html = f""" +
    +
    Registry Path
    + {html.escape(self.model_path)} +
    + """ + + description = model.meta.description.strip() if hasattr(model, "meta") and model.meta.description else "" + + desc_html = "" + if description: + try: + import bleach + + description = bleach.linkify(html.escape(description)) + except ImportError: + description = html.escape(description) + desc_html = f""" +
    +
    Instance Description
    +
    {description}
    +
    + """ + + self._metadata.object = path_html + desc_html + self._render_dependencies(model) + + +class ModelViewer(param.Parameterized): + """ + Displays a tabbed view of a ccflow Model instance, including description, registry dependencies, docstrings and json representation. + """ + + model = param.Parameter(default=None) + + def __init__(self, **params): + super().__init__(**params) + + self.model_path = "" + + # Sub-viewers (no JSONEditor inside) + self._config_viewer = ModelConfigViewer() + self._type_viewer = ModelTypeViewer() + self._context_type_viewer = ModelTypeViewer() + self._result_type_viewer = ModelTypeViewer() + + # Material UI Tabs (metadata only) + self._tabs = pmui.Tabs( + active=0, + sizing_mode="stretch_width", + ) + + # JSON editor (stable, but hidden until a model is selected) + self._json_editor = pn.widgets.JSONEditor( + value={}, + mode="view", + menu=False, + sizing_mode="stretch_width", + min_width=400, + ) + + self._json_container = pn.Column( + "## Parameters", + self._json_editor, + visible=False, # hidden initially + sizing_mode="stretch_width", + ) + + self._layout = pn.Column( + "## Model Viewer", + self._tabs, + pn.Spacer(height=12), + self._json_container, + sizing_mode="stretch_width", + ) + + self.param.watch(self._on_model_change, "model") + + def __panel__(self): + return self._layout + + def _on_model_change(self, event): + model = event.new + self._tabs.clear() + + if model is None: + # hide JSON editor if no model + self._json_editor.value = {} + self._json_container.visible = False + return + + # Config tab + self._config_viewer.model_path = self.model_path + self._config_viewer.model = model + self._tabs.append(("Summary", self._config_viewer)) + + # Model Type tab + self._type_viewer.model_type = type(model) + self._tabs.append(("Model Type", self._type_viewer)) + + # CallableModel extras + if isinstance(model, ccflow.CallableModel): + self._context_type_viewer.model_type = model.context_type + self._tabs.append(("Context Type", self._context_type_viewer)) + + self._result_type_viewer.model_type = model.result_type + self._tabs.append(("Result Type", self._result_type_viewer)) + + # Default to Config tab + self._tabs.active = 0 + + # Update & show JSONEditor + self._json_editor.value = model.__pydantic_serializer__.to_python(model, fallback=str, mode="json") + self._json_container.visible = True diff --git a/ccflow/ui/panel/registry.py b/ccflow/ui/panel/registry.py new file mode 100644 index 00000000..225a1fa9 --- /dev/null +++ b/ccflow/ui/panel/registry.py @@ -0,0 +1,183 @@ +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 .model import ModelViewer + +pn.extension() + +__all__ = ("ModelRegistryViewer", "RegistryBrowser") + + +class RegistryBrowser(param.Parameterized): + selected_model = param.Parameter(default=None) + + sort_children = param.Boolean( + default=True, + doc="If True, sort child entries alphabetically by name at every registry level. Defaults to insertion order when False.", + ) + + def __init__(self, registry, **params): + super().__init__(**params) + self._registry = registry + self.selected_path = "" + + self._tree_items = self._build_tree(registry) + self._node_index = self._build_node_index(self._tree_items) + + self._tree = pmui.Tree( + items=self._tree_items, + multi_select=False, + ) + + self._search = pn.widgets.AutocompleteInput( + name="Search", + options=sorted(self._node_index.keys()), + placeholder="Search full path…", + case_sensitive=False, + search_strategy="includes", + min_characters=1, + sizing_mode="stretch_width", + ) + + self._search.param.watch(self._on_search_select, "value") + self._tree.param.watch(self._on_tree_select, "value") + + self._layout = pn.Column( + "## Registry", + self._search, + self._tree, + ) + + def __panel__(self): + return self._layout + + # Tree construction + + def _build_tree(self, registry, index_prefix=()): + import ccflow + + model_items = registry.models.items() + if self.sort_children: + # Subregistries first, then leaf models; each group sorted alphabetically. + model_items = sorted(model_items, key=lambda kv: (not isinstance(kv[1], ccflow.ModelRegistry), kv[0])) + + items = [] + for i, (name, model) in enumerate(model_items): + index_path = index_prefix + (i,) + entry = { + "label": name, + "_index_path": index_path, + } + + if isinstance(model, ccflow.ModelRegistry): + entry["items"] = self._build_tree(model, index_prefix=index_path) + else: + entry["model"] = model + + items.append(entry) + + return items + + def _build_node_index(self, tree_items): + index = {} + + def walk(items, prefix=""): + for node in items: + path = f"{prefix}/{node['label']}" if prefix else node["label"] + node["_path"] = path + if "model" in node: + index[path] = node + walk(node.get("items", []), path) + + walk(tree_items) + return index + + @staticmethod + def _expanded_from_index_path(index_path): + return [index_path[:i] for i in range(1, len(index_path))] + + # Callbacks + + def _on_search_select(self, event): + path = event.new + if not path: + return + node = self._node_index.get(path) + if not node: + return + self._tree.expanded = self._expanded_from_index_path(node["_index_path"]) + self._tree.value = [node] + self._search.value = "" + + def _on_tree_select(self, event): + if event.new: + node = event.new[0] + model = node.get("model") + self.selected_path = node.get("_path", "") if model is not None else "" + self.selected_model = model + else: + self.selected_path = "" + self.selected_model = None + + +class ModelRegistryViewer(param.Parameterized): + """ + Top-level viewer that composes the RegistryBrowser and ModelViewer + into a viewport-filling page with a resizable sidebar. + """ + + # Layout parameters + browser_width = param.Integer( + default=400, + bounds=(200, None), + doc="Initial width of the registry browser sidebar (px). User can drag to resize at runtime.", + ) + + title = param.String( + default="ccflow Model Registry", + doc="Title shown in the page header.", + ) + + model = param.Parameter( + default=None, + doc="The currently selected model from the registry browser", + ) + + sort_children = param.Boolean( + default=True, + doc="If True, sort registry child entries alphabetically by name at every level. Defaults to insertion order when False.", + ) + + def __init__(self, registry, **params): + super().__init__(**params) + + # Core components + self._browser = RegistryBrowser(registry, sort_children=self.sort_children) + self._viewer = ModelViewer() + + # Wire browser → viewer and model param + def _on_selection(e): + self.model = e.new + self._viewer.model_path = self._browser.selected_path + self._viewer.model = e.new + + self._browser.param.watch(_on_selection, "selected_model") + + # Wrap browser in a scrolling Column so large registries remain navigable. + sidebar_panel = pn.Column( + self._browser, + sizing_mode="stretch_both", + scroll=True, + ) + + self._layout = pmui.Page( + sidebar=[sidebar_panel], + main=[self._viewer], + sidebar_width=self.browser_width, + title=self.title, + ) + + def __panel__(self): + return self._layout diff --git a/ccflow/ui/registry.py b/ccflow/ui/registry.py index 225a1fa9..e8162ca3 100644 --- a/ccflow/ui/registry.py +++ b/ccflow/ui/registry.py @@ -1,183 +1,3 @@ -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 +"""Compatibility imports for Panel registry views.""" -from .model import ModelViewer - -pn.extension() - -__all__ = ("ModelRegistryViewer", "RegistryBrowser") - - -class RegistryBrowser(param.Parameterized): - selected_model = param.Parameter(default=None) - - sort_children = param.Boolean( - default=True, - doc="If True, sort child entries alphabetically by name at every registry level. Defaults to insertion order when False.", - ) - - def __init__(self, registry, **params): - super().__init__(**params) - self._registry = registry - self.selected_path = "" - - self._tree_items = self._build_tree(registry) - self._node_index = self._build_node_index(self._tree_items) - - self._tree = pmui.Tree( - items=self._tree_items, - multi_select=False, - ) - - self._search = pn.widgets.AutocompleteInput( - name="Search", - options=sorted(self._node_index.keys()), - placeholder="Search full path…", - case_sensitive=False, - search_strategy="includes", - min_characters=1, - sizing_mode="stretch_width", - ) - - self._search.param.watch(self._on_search_select, "value") - self._tree.param.watch(self._on_tree_select, "value") - - self._layout = pn.Column( - "## Registry", - self._search, - self._tree, - ) - - def __panel__(self): - return self._layout - - # Tree construction - - def _build_tree(self, registry, index_prefix=()): - import ccflow - - model_items = registry.models.items() - if self.sort_children: - # Subregistries first, then leaf models; each group sorted alphabetically. - model_items = sorted(model_items, key=lambda kv: (not isinstance(kv[1], ccflow.ModelRegistry), kv[0])) - - items = [] - for i, (name, model) in enumerate(model_items): - index_path = index_prefix + (i,) - entry = { - "label": name, - "_index_path": index_path, - } - - if isinstance(model, ccflow.ModelRegistry): - entry["items"] = self._build_tree(model, index_prefix=index_path) - else: - entry["model"] = model - - items.append(entry) - - return items - - def _build_node_index(self, tree_items): - index = {} - - def walk(items, prefix=""): - for node in items: - path = f"{prefix}/{node['label']}" if prefix else node["label"] - node["_path"] = path - if "model" in node: - index[path] = node - walk(node.get("items", []), path) - - walk(tree_items) - return index - - @staticmethod - def _expanded_from_index_path(index_path): - return [index_path[:i] for i in range(1, len(index_path))] - - # Callbacks - - def _on_search_select(self, event): - path = event.new - if not path: - return - node = self._node_index.get(path) - if not node: - return - self._tree.expanded = self._expanded_from_index_path(node["_index_path"]) - self._tree.value = [node] - self._search.value = "" - - def _on_tree_select(self, event): - if event.new: - node = event.new[0] - model = node.get("model") - self.selected_path = node.get("_path", "") if model is not None else "" - self.selected_model = model - else: - self.selected_path = "" - self.selected_model = None - - -class ModelRegistryViewer(param.Parameterized): - """ - Top-level viewer that composes the RegistryBrowser and ModelViewer - into a viewport-filling page with a resizable sidebar. - """ - - # Layout parameters - browser_width = param.Integer( - default=400, - bounds=(200, None), - doc="Initial width of the registry browser sidebar (px). User can drag to resize at runtime.", - ) - - title = param.String( - default="ccflow Model Registry", - doc="Title shown in the page header.", - ) - - model = param.Parameter( - default=None, - doc="The currently selected model from the registry browser", - ) - - sort_children = param.Boolean( - default=True, - doc="If True, sort registry child entries alphabetically by name at every level. Defaults to insertion order when False.", - ) - - def __init__(self, registry, **params): - super().__init__(**params) - - # Core components - self._browser = RegistryBrowser(registry, sort_children=self.sort_children) - self._viewer = ModelViewer() - - # Wire browser → viewer and model param - def _on_selection(e): - self.model = e.new - self._viewer.model_path = self._browser.selected_path - self._viewer.model = e.new - - self._browser.param.watch(_on_selection, "selected_model") - - # Wrap browser in a scrolling Column so large registries remain navigable. - sidebar_panel = pn.Column( - self._browser, - sizing_mode="stretch_both", - scroll=True, - ) - - self._layout = pmui.Page( - sidebar=[sidebar_panel], - main=[self._viewer], - sidebar_width=self.browser_width, - title=self.title, - ) - - def __panel__(self): - return self._layout +from .panel.registry import * diff --git a/ccflow/ui/spaday/__init__.py b/ccflow/ui/spaday/__init__.py new file mode 100644 index 00000000..417aeab3 --- /dev/null +++ b/ccflow/ui/spaday/__init__.py @@ -0,0 +1,3 @@ +from .cli import * +from .model import * +from .registry import * diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py new file mode 100644 index 00000000..cf2d1b97 --- /dev/null +++ b/ccflow/ui/spaday/cli.py @@ -0,0 +1,198 @@ +"""CLI for serving the ccflow ModelRegistry as a spaday application. + +Mirrors :mod:`ccflow.ui.panel.cli` but renders the spaday viewer and serves it with Starlette + uvicorn +instead of Panel. ``serve_registry`` is the importable entry point; ``registry_viewer_cli`` is the +hydra-config-driven command wrapped by the ``ccflow-ui-spaday`` console script. +""" + +import argparse +import asyncio +import logging +import os +from collections.abc import Callable + +from spaday_dagre import package as dagre_package +from spaday_trees import package as trees_package +from spaday_webawesome import package as webawesome_package + +from ccflow import ModelRegistry +from ccflow.utils.hydra import add_hydra_config_args, load_config, resolve_config_paths + +from .model import MATERIALIZE_ENDPOINT +from .registry import CARD_ENDPOINT, DARK_FIELD, SELECTED_FIELD, model_card, registry_store, registry_viewer + +__all__ = ("main", "registry_viewer_cli", "serve_registry") + +log = logging.getLogger(__name__) + +#: Component packages whose assets the page needs (webawesome controls, the tree, the dependency graph). +_PACKAGES = (webawesome_package, trees_package, dagre_package) + +#: spaday-trees follows ``wa-dark``/``wa-light`` itself; the page only ever sets ``wa-dark``, so pin the +#: unset case to light at zero specificity, letting the package's own dark rule win when it applies. +_STYLES = ( + ":where(spaday-tree), :where(spaday-tree file-tree-container) { color-scheme: light; }", + # Un-materialized models are drawn as outlines so they read as configuration, not instances. + "spaday-dagre .spaday-dagre-node.pending :is(rect, ellipse, polygon) { stroke-dasharray: 4 3; }", +) + + +def serve_registry( + registry: ModelRegistry, + *, + title: str = "ccflow Model Registry", + browser_width: int = 400, + sort_children: bool = True, + host: str = "127.0.0.1", + port: int = 8080, + run: bool = True, +): + """Build the spaday registry viewer and serve it as a Starlette app. + + Args: + registry: The registry to browse. The page tree is rebuilt per request, so it reflects the + registry's current contents. + title: Title shown in the page header. + browser_width: Initial width of the registry sidebar, in pixels. + sort_children: Sort registry entries alphabetically at every level (subregistries first). + host, port: Interface and port uvicorn binds to (only used when ``run`` is True). + run: When True, start a blocking uvicorn server. When False, return the app without serving. + + Returns: + starlette.applications.Starlette: The mounted spaday application. + """ + try: + import uvicorn + from spaday.backends.starlette import serve + from spaday.bootstrap import tree_json + from starlette.responses import JSONResponse, Response + from starlette.routing import Route + except ImportError: + raise ImportError( + "spaday, starlette and uvicorn must be installed to serve the spaday UI. Pip install ccflow[full] to install all optional dependencies." + ) from None + + def page(): + return registry_viewer(registry, title=title, browser_width=browser_width, sort_children=sort_children) + + async def materialize(request): + """Instantiate a pending (lazily-loaded) model. + + The client refreshes the tree afterwards, so the response only has to report the outcome: a + model that cannot be constructed (a bad ``_target_``, an unavailable dependency) stays pending + and its error is returned for the page to surface. + """ + path = (await request.json()).get("path", "") + if not path: + return JSONResponse({"message": "No model selected."}, status_code=400) + try: + await asyncio.to_thread(registry.__getitem__, path) + except Exception as error: + log.exception("Failed to materialize lazy registry model %r", path) + return JSONResponse({"message": f"Could not materialize {path}: {error}"}, status_code=500) + return JSONResponse({"message": f"Materialized {path}."}) + + def card(request): + """Return one model's detail card, fetched by the page when that model is first shown.""" + path = request.query_params.get("model", "") + component = model_card(registry, path, sort_children=sort_children) + return Response(tree_json(component), media_type="application/json") + + app = serve( + page, + packages=_PACKAGES, + styles=_STYLES, + store=registry_store(), + url={SELECTED_FIELD: "model"}, + persist={DARK_FIELD: "ccflow-ui-dark"}, + title=title, + routes=[ + Route(MATERIALIZE_ENDPOINT, materialize, methods=["POST"]), + Route(CARD_ENDPOINT, card), + ], + ) + + if run: + uvicorn.run(app, host=host, port=port) + return app + + +def _get_ui_args_parser() -> argparse.ArgumentParser: + """Create the argument parser for the spaday viewer server.""" + parser = argparse.ArgumentParser( + add_help=True, + description="Serve the ccflow ModelRegistry viewer as a spaday application", + ) + + add_hydra_config_args(parser) + + parser.add_argument("--host", type=str, default="127.0.0.1", help="Host interface to bind the server to (default: 127.0.0.1).") + parser.add_argument("--port", type=int, default=8080, help="Port to bind the server to (default: 8080).") + parser.add_argument( + "--browser-width", + type=int, + default=400, + help="Initial width of the registry browser sidebar in px (default: 400).", + ) + parser.add_argument( + "--title", + type=str, + default="ccflow Model Registry", + help="Title shown in the page header (default: 'ccflow Model Registry').", + ) + parser.add_argument( + "--no-sort-children", + dest="sort_children", + action="store_false", + help="Keep registry entries in insertion order instead of sorting them alphabetically.", + ) + + return parser + + +def registry_viewer_cli( + config_path: str = "", + config_name: str = "", + hydra_main: Callable | None = None, +): + """CLI entry point for serving the spaday ModelRegistry viewer. + + Args: + 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() + + root_config_dir, root_config_name = resolve_config_paths(args, config_path, config_name, hydra_main) + # hydra's initialize_config_dir requires an absolute directory; resolve a relative --config-path + # against the current working directory. + root_config_dir = os.path.abspath(root_config_dir) + + 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, + ) + + registry = ModelRegistry.root() + registry.load_config(cfg=result.cfg, overwrite=True) + + serve_registry( + registry, + title=args.title, + browser_width=args.browser_width, + sort_children=args.sort_children, + host=args.host, + port=args.port, + ) + + +def main(): + """Console-script entry point (``ccflow-ui-spaday``).""" + registry_viewer_cli() diff --git a/ccflow/ui/spaday/graph.py b/ccflow/ui/spaday/graph.py new file mode 100644 index 00000000..53c0c70b --- /dev/null +++ b/ccflow/ui/spaday/graph.py @@ -0,0 +1,130 @@ +"""Per-model dependency graphs, rendered with ``spaday-dagre``. + +ccflow models declare which other registered models they contain via +:meth:`ccflow.BaseModel.get_registry_dependencies`. That relation is a DAG over registry paths; each +model's detail card shows only the part reachable from that model, so the graph stays about the model +in front of you. A node opens a context menu, from which the model can be selected. +""" + +from collections.abc import Mapping + +from spaday import Component, element +from spaday.actions import Sequence, SetField, by_id, close_popup, event_value, field, open_popup +from spaday.components import Column, Popup +from spaday_dagre import Dagre +from spaday_webawesome import WaButton, WaCard, WaDivider + +__all__ = ("MENU_PATH_FIELD", "dependency_edges", "model_dependency_graph", "model_dependency_view") + +#: Dependencies flow left to right into the model. +_LAYOUT = {"rankdir": "LR"} + +#: Full registry paths make good labels but poor node widths; wider ones ellipsise. +_MAX_LABEL_WIDTH = 220 + +#: The signal-store field holding the node a context menu was opened on. +MENU_PATH_FIELD = "menu_path" + +_MENU_ID = "dependency-node-menu" + + +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 _normalize(name: str) -> str: + """Registered names are root-relative and leading-slashed ("/a/b"); leaf paths are not.""" + return name.removeprefix("/") + + +def dependency_edges(leaves: list[tuple[str, object]]) -> dict[str, list[str]]: + """Map each leaf path to the paths it depends on. + + Only dependencies present in ``leaves`` are kept, so a reference to something outside the browsed + registry does not introduce a dangling node. Pending (lazy) models report none, because resolving + them would instantiate the model. + """ + known = {path for path, _ in leaves} + adjacency: dict[str, list[str]] = {} + for path, model in leaves: + targets: list[str] = [] + if not _is_pending(model): + for group in model.get_registry_dependencies(): + # A group holds equivalent names for one dependency; the first is the canonical path. + target = _normalize(group[0]) + if target in known and target != path and target not in targets: + targets.append(target) + adjacency[path] = targets + return adjacency + + +def model_dependency_graph(path: str, adjacency: dict[str, list[str]]) -> dict: + """The dagre ``{nodes, edges}`` config for everything reachable from ``path``. + + Edges point from a dependency to the model that uses it, so the graph reads in dataflow order and + the model in front of you is the last node. + """ + if path not in adjacency: + return {"nodes": [], "edges": []} + + order: list[str] = [] + seen = {path} + queue = [path] + while queue: + current = queue.pop(0) + order.append(current) + for target in adjacency.get(current, ()): + if target not in seen: + seen.add(target) + queue.append(target) + + nodes = [{"id": node, "label": node} for node in order] + edges = [{"source": dependency, "target": node} for node in order for dependency in adjacency.get(node, ())] + return {"nodes": nodes, "edges": edges} + + +def _node_menu(*, selected_field: str) -> Popup: + """The context menu shown for a graph node, bound to whichever node opened it.""" + open_model = Sequence( + SetField(selected_field, field(MENU_PATH_FIELD)), + close_popup(by_id(_MENU_ID)), + ) + body = Column( + element("code").bind("textContent", MENU_PATH_FIELD).style(font_size="0.85em", overflow_wrap="anywhere"), + WaDivider(), + WaButton(appearance="filled", size="s").text("Open model").on("click", open_model), + gap="0.4rem", + ) + return Popup(WaCard(appearance="outlined").child(body).style(min_width="14rem"), id=_MENU_ID) + + +def model_dependency_view(path: str, adjacency: dict[str, list[str]], *, selected_field: str) -> Component | None: + """The model's dependency graph, or ``None`` when it depends on nothing worth drawing. + + A node opens a context menu rather than navigating on the spot; choosing "Open model" from it sets + ``selected_field``, which routes the page and reveals the model in the sidebar tree. + """ + graph = model_dependency_graph(path, adjacency) + if len(graph["nodes"]) < 2: + return None + + show_menu = open_popup( + by_id(_MENU_ID), + x=event_value("x"), + y=event_value("y"), + context_field=MENU_PATH_FIELD, + context=event_value("id"), + ) + + dagre = ( + Dagre(zoomable=True, controls=True, max_label_width=_MAX_LABEL_WIDTH, emphasis=path) + .prop("graph", graph) + .prop("layout", _LAYOUT) + .on("dagre-node-click", show_menu) + .on("dagre-node-contextmenu", show_menu) + .style(display="block", height="22rem") + ) + + menu = _node_menu(selected_field=selected_field) + return Column(dagre, menu, gap="0") diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py new file mode 100644 index 00000000..933439ad --- /dev/null +++ b/ccflow/ui/spaday/model.py @@ -0,0 +1,172 @@ +"""Model-detail components for the spaday registry viewer. + +Each function builds a piece of the model inspector as a :class:`spaday.Component` tree (rendered to the +browser by the spaday runtime), mirroring the tabs of the Panel viewer in :mod:`ccflow.ui.panel.model`: +an instance summary, the model / context / result types with their fields, and the serialized parameters. +""" + +import json + +from pydantic._internal._repr import display_as_type +from spaday import Component, Strong, Text, element +from spaday.actions import CallEndpoint, Expr, RefreshTree, Sequence, obj +from spaday.components import Column, Row +from spaday_webawesome import Tabs, WaBadge, WaButton, WaCard, WaDivider + +import ccflow + +#: Path of the endpoint (served by :func:`ccflow.ui.spaday.cli.serve_registry`) that materializes a +#: pending model server-side. +MATERIALIZE_ENDPOINT = "/materialize" + +#: Signal-store field holding the materialize call's ``{status, ok, body}`` outcome. +MATERIALIZE_RESULT_FIELD = "materialize_result" + +__all__ = ( + "MATERIALIZE_ENDPOINT", + "MATERIALIZE_RESULT_FIELD", + "model_config_view", + "model_type_view", + "model_view", + "pending_model_view", +) + +_PRE_STYLE = { + "white_space": "pre-wrap", + "font_family": "monospace", + "background": "#f6f8fa", + "padding": "8px", + "margin": "0", + "border_radius": "4px", + "overflow_wrap": "anywhere", +} + + +def _labeled(label: str, *body: Component) -> Component: + """A bold label above its content.""" + return Column(Strong(label), *body, gap="0.25rem") + + +def _code(text: str | Expr, *, color: str = "") -> Component: + """An inline ```` 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..3c2cb007 --- /dev/null +++ b/ccflow/ui/spaday/registry.py @@ -0,0 +1,168 @@ +"""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: + loaded = registry.get_loaded(name) + items.append((name, loaded if loaded is not None else registry.get_pending_config(name))) + 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/ccflow/utils/tokenize.py b/ccflow/utils/tokenize.py index 91444488..919e54fd 100644 --- a/ccflow/utils/tokenize.py +++ b/ccflow/utils/tokenize.py @@ -441,11 +441,6 @@ def compute_cache_token(*, data_values: Iterable[Any] = (), behavior_classes: It ) -# --------------------------------------------------------------------------- -# Behavior hashing — bytecode-based fingerprinting of class methods -# --------------------------------------------------------------------------- - - def _unwrap_function(func: object) -> Callable | None: """Unwrap descriptors and decorator chains to get the underlying function. diff --git a/pyproject.toml b/pyproject.toml index 5faf7c63..a772632d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,12 @@ full = [ "ray", "scipy", "smart_open", + "spaday>=0.7.9", + "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", + "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"