From 5f849bace28658af194aa2e4ada65c5bb5df8d7f Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:17:01 -0400 Subject: [PATCH 1/8] Add spaday-based model registry browser Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/base.py | 4 +- ccflow/examples/tpch/config/conf.yaml | 8 - ccflow/flow_model.py | 55 ------- ccflow/tests/test_base.py | 4 +- ccflow/tests/ui/panel/__init__.py | 0 ccflow/tests/ui/{ => panel}/test_cli.py | 4 +- ccflow/tests/ui/{ => panel}/test_model.py | 4 +- ccflow/tests/ui/{ => panel}/test_registry.py | 4 +- ccflow/tests/ui/{ => panel}/utils.py | 0 ccflow/tests/ui/spaday/__init__.py | 0 ccflow/tests/ui/spaday/test_cli.py | 81 ++++++++++ ccflow/tests/ui/spaday/test_model.py | 114 ++++++++++++++ ccflow/tests/ui/spaday/test_registry.py | 137 ++++++++++++++++ ccflow/tests/ui/spaday/utils.py | 50 ++++++ ccflow/ui/__init__.py | 4 +- ccflow/ui/panel/__init__.py | 3 + ccflow/ui/{ => panel}/cli.py | 14 +- ccflow/ui/{ => panel}/model.py | 0 ccflow/ui/{ => panel}/registry.py | 0 ccflow/ui/spaday/__init__.py | 3 + ccflow/ui/spaday/cli.py | 157 +++++++++++++++++++ ccflow/ui/spaday/model.py | 120 ++++++++++++++ ccflow/ui/spaday/registry.py | 97 ++++++++++++ ccflow/utils/hydra.py | 35 ++--- ccflow/utils/tokenize.py | 5 - pyproject.toml | 7 + 26 files changed, 798 insertions(+), 112 deletions(-) create mode 100644 ccflow/tests/ui/panel/__init__.py rename ccflow/tests/ui/{ => panel}/test_cli.py (94%) rename ccflow/tests/ui/{ => panel}/test_model.py (99%) rename ccflow/tests/ui/{ => panel}/test_registry.py (99%) rename ccflow/tests/ui/{ => panel}/utils.py (100%) create mode 100644 ccflow/tests/ui/spaday/__init__.py create mode 100644 ccflow/tests/ui/spaday/test_cli.py create mode 100644 ccflow/tests/ui/spaday/test_model.py create mode 100644 ccflow/tests/ui/spaday/test_registry.py create mode 100644 ccflow/tests/ui/spaday/utils.py create mode 100644 ccflow/ui/panel/__init__.py rename ccflow/ui/{ => panel}/cli.py (89%) rename ccflow/ui/{ => panel}/model.py (100%) rename ccflow/ui/{ => panel}/registry.py (100%) create mode 100644 ccflow/ui/spaday/__init__.py create mode 100644 ccflow/ui/spaday/cli.py create mode 100644 ccflow/ui/spaday/model.py create mode 100644 ccflow/ui/spaday/registry.py 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..488cd993 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 "" @@ -392,12 +387,6 @@ class _LocalFlowModelPicklePayload(NamedTuple): serialized_config: Any factory_kwargs: dict[str, Any] - -# --------------------------------------------------------------------------- -# Small value helpers -# --------------------------------------------------------------------------- - - def _context_values(context: ContextBase) -> dict[str, Any]: return dict(context) @@ -469,11 +458,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 +654,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 +846,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 +1000,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 +1439,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 +1808,6 @@ def _generated_model_identity_payload( ) -# --------------------------------------------------------------------------- -# Static binding resolution and with_context normalization -# --------------------------------------------------------------------------- - - def _resolved_static_contextual_values( model: "_GeneratedFlowModelBase", config: _FlowModelConfig, @@ -2104,11 +2064,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 +2492,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 +3108,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..976b6855 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_cli.py @@ -0,0 +1,81 @@ +"""Unit tests for ccflow.ui.spaday.cli module.""" + +from pathlib import Path + +from spaday.bootstrap import _ASSETS, bundles_dir + +from ccflow import BaseModel, ModelRegistry +from ccflow.ui.spaday.cli import _asset_layout, _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, "address") + 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.address == "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(["--address", "0.0.0.0", "--port", "9000", "--browser-width", "500", "--title", "Mine"]) + assert args.address == "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_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 + + +class TestAssetLayout: + def test_selected_layout_has_runtime_asset(self): + # Guards the 404 regression: an unrelated top-level ``js`` package must not push us to the + # "source" layout, whose bundle directory would then lack spaday's runtime asset. + layout = _asset_layout() + runtime = _ASSETS[layout]["runtime"].lstrip("/") + assert (Path(bundles_dir(layout)) / runtime).is_file() diff --git a/ccflow/tests/ui/spaday/test_model.py b/ccflow/tests/ui/spaday/test_model.py new file mode 100644 index 00000000..1338d764 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_model.py @@ -0,0 +1,114 @@ +"""Unit tests for ccflow.ui.spaday.model module.""" + +from typing import Type + +from pydantic import Field +from spaday.validate import validate + +from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, ModelRegistry +from ccflow.ui.spaday.model import model_config_view, model_type_view, 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()) diff --git a/ccflow/tests/ui/spaday/test_registry.py b/ccflow/tests/ui/spaday/test_registry.py new file mode 100644 index 00000000..13989c15 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -0,0 +1,137 @@ +"""Unit tests for ccflow.ui.spaday.registry module.""" + +from spaday.validate import validate + +from ccflow import BaseModel, ModelRegistry +from ccflow.ui.spaday.registry import ( + SELECTED_FIELD, + registry_leaves, + registry_store, + registry_tree, + registry_viewer, +) + +from .utils import click_set_field, nodes_with_tag, prop_str, show_when_value + + +class SimpleModel(BaseModel): + """A simple test model.""" + + name: str + value: int = 0 + + +class AnotherModel(BaseModel): + """Another test model.""" + + data: str = "" + + +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): + assert registry_store() == {SELECTED_FIELD: ""} + + +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_leaf_items_carry_selection_action(self): + nodes = registry_tree(_registry()) + # Serialize the whole set of tree items and collect leaf selection targets. + selected = set() + for item in nodes: + for node in nodes_with_tag(item.to_node(), "wa-tree-item"): + value = click_set_field(node) + if value is not None: + selected.add(value) + assert selected == {"sub/alpha", "zeta"} + + def test_branch_items_have_no_selection_action(self): + nodes = registry_tree(_registry()) + # The top-level "sub" node is a branch; it must not carry a click action. + sub_item = next(n for n in nodes if any(t == "sub" for t in _labels(n.to_node()))) + assert click_set_field(sub_item.to_node()) is None + + +def _labels(node): + from .utils import text_of + + return [text_of(n) for n in node.get("slots", {}).get("default", [])] + + +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): + from .utils import all_text + + 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() + show_targets = {show_when_value(n) for n in nodes_with_tag(node, "spa-show")} + # A panel per leaf plus the empty-selection placeholder. + assert "sub/alpha" in show_targets + assert "zeta" in show_targets + assert "" in show_targets + + def test_search_options_cover_all_leaves(self): + node = registry_viewer(_registry()).to_node() + options = [prop_str(n, "value") for n in nodes_with_tag(node, "wa-option")] + # First option is the empty placeholder; the rest are sorted leaf paths. + assert options[0] == "" + assert options[1:] == sorted(["sub/alpha", "zeta"]) + + 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 show panel, no model panels. + assert [show_when_value(n) for n in nodes_with_tag(node, "spa-show")] == [""] diff --git a/ccflow/tests/ui/spaday/utils.py b/ccflow/tests/ui/spaday/utils.py new file mode 100644 index 00000000..6da9b73e --- /dev/null +++ b/ccflow/tests/ui/spaday/utils.py @@ -0,0 +1,50 @@ +"""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 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..a09aa86a 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 * # noqa: F401,F403 Back-compat: the Panel UI remains the default and is re-exported here. 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/cli.py b/ccflow/ui/panel/cli.py similarity index 89% rename from ccflow/ui/cli.py rename to ccflow/ui/panel/cli.py index 7d39bb7e..4991a8ad 100644 --- a/ccflow/ui/cli.py +++ b/ccflow/ui/panel/cli.py @@ -50,15 +50,11 @@ def registry_viewer_cli( ): """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. + 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() diff --git a/ccflow/ui/model.py b/ccflow/ui/panel/model.py similarity index 100% rename from ccflow/ui/model.py rename to ccflow/ui/panel/model.py diff --git a/ccflow/ui/registry.py b/ccflow/ui/panel/registry.py similarity index 100% rename from ccflow/ui/registry.py rename to ccflow/ui/panel/registry.py diff --git a/ccflow/ui/spaday/__init__.py b/ccflow/ui/spaday/__init__.py new file mode 100644 index 00000000..076d582e --- /dev/null +++ b/ccflow/ui/spaday/__init__.py @@ -0,0 +1,3 @@ +from .cli import * # noqa: F401,F403 +from .model import * # noqa: F401,F403 +from .registry import * # noqa: F401,F403 diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py new file mode 100644 index 00000000..03bc54d0 --- /dev/null +++ b/ccflow/ui/spaday/cli.py @@ -0,0 +1,157 @@ +"""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 os +from pathlib import Path +from typing import Callable, Optional + +from ccflow import ModelRegistry +from ccflow.utils.hydra import add_hydra_config_args, load_config, resolve_config_paths + +from .registry import registry_store, registry_viewer + +__all__ = ("serve_registry", "registry_viewer_cli", "main") + + +def _asset_layout() -> str: + """Select spaday's asset layout ("source" vs "installed"). + + spaday auto-detects this from whether ``/../js`` is a directory, but an unrelated + top-level ``js`` package on ``sys.path`` (common in site-packages) makes it wrongly choose the + "source" layout, whose bundle URLs then 404. Only a real spaday source checkout ships ``js/dist``, + so require that before trusting the source layout; otherwise use the packaged extension assets. + """ + import spaday + + source_js = Path(spaday.__file__).resolve().parent.parent / "js" + return "source" if (source_js / "dist").is_dir() else "installed" + + +def serve_registry( + registry: ModelRegistry, + *, + title: str = "ccflow Model Registry", + browser_width: int = 400, + sort_children: bool = True, + address: 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). + address, 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 + 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 + + app = serve( + lambda: registry_viewer(registry, title=title, browser_width=browser_width, sort_children=sort_children), + bundles=["webawesome"], + store=registry_store(), + title=title, + layout=_asset_layout(), + ) + if run: + uvicorn.run(app, host=address, 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("--address", type=str, default="127.0.0.1", help="Address 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: Optional[Callable] = 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, + address=args.address, + port=args.port, + ) + + +def main(): + """Console-script entry point (``ccflow-ui-spaday``).""" + registry_viewer_cli() diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py new file mode 100644 index 00000000..ba2c61c7 --- /dev/null +++ b/ccflow/ui/spaday/model.py @@ -0,0 +1,120 @@ +"""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.components import Column, Row, Tabs, WaBadge, WaCard, WaDivider + +import ccflow + +__all__ = ("model_type_view", "model_config_view", "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, *, 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 = "") -> Component: + """A card with tabs inspecting a single ccflow model instance.""" + 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") + + 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")) diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py new file mode 100644 index 00000000..406e1266 --- /dev/null +++ b/ccflow/ui/spaday/registry.py @@ -0,0 +1,97 @@ +"""Registry browser and top-level viewer as a spaday component tree. + +Selection is driven entirely client-side through the runtime's signal store: clicking a leaf in the +``wa-tree`` (or picking it from the search ``wa-select``) writes the model's path to the ``selected`` +field, and each model's detail card is wrapped in a :class:`~spaday.components.shell.Show` that mounts +only when ``selected`` equals its path. No round-trip to Python is needed to change the selection. +""" + +from typing import List, Tuple + +from spaday import Component, Strong, Text +from spaday.actions import SetField, eq, field, lit +from spaday.components import App, Body, Column, Gutter, Main, Nav, Show, WaOption, WaSelect, WaTree, WaTreeItem + +import ccflow + +from .model import model_view + +__all__ = ("SELECTED_FIELD", "registry_store", "registry_leaves", "registry_tree", "registry_viewer") + +#: The signal-store field holding the selected model's registry path ("" when nothing is selected). +SELECTED_FIELD = "selected" + + +def registry_store() -> dict: + """The initial signal-store state the viewer is mounted with.""" + return {SELECTED_FIELD: ""} + + +def _sorted_items(registry, sort_children: bool): + """Registry entries, optionally with subregistries first and each group sorted alphabetically.""" + items = 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 registry_tree(registry, *, sort_children: bool = True, _prefix: str = "") -> List[WaTreeItem]: + """Build the ``wa-tree-item`` nodes for the registry; leaf clicks select the model by path.""" + nodes: List[WaTreeItem] = [] + for name, model in _sorted_items(registry, sort_children): + path = f"{_prefix}/{name}" if _prefix else name + if isinstance(model, ccflow.ModelRegistry): + children = registry_tree(model, sort_children=sort_children, _prefix=path) + nodes.append(WaTreeItem(Text(name), *children)) + else: + nodes.append(WaTreeItem(Text(name)).on("click", SetField(SELECTED_FIELD, lit(path)))) + return nodes + + +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 _search(leaves: List[Tuple[str, object]]) -> WaSelect: + """A select of every model path, two-way bound to the selection so it both jumps and reflects.""" + options = [WaOption(value="").text("— jump to a model —")] + options += [WaOption(value=path).text(path) for path, _ in sorted(leaves)] + return WaSelect(placeholder="Search / jump to model", with_clear=True).child(*options).bind("value", SELECTED_FIELD, mode="two-way") + + +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 + search, and the selected model's detail card.""" + leaves = registry_leaves(registry, sort_children=sort_children) + tree = WaTree(*registry_tree(registry, sort_children=sort_children), selection="leaf") + + sidebar = Gutter( + Column(Strong("Registry"), _search(leaves), tree, gap="0.75rem"), + width=f"{browser_width}px", + gap="0.75rem", + ) + + panels: List[Component] = [Show(_placeholder(), when=eq(field(SELECTED_FIELD), lit("")))] + for path, model in leaves: + panels.append(Show(model_view(model, path), when=eq(field(SELECTED_FIELD), lit(path)))) + + return App( + Nav(Strong(title)), + Body(sidebar, Main(Column(*panels, gap="1rem"))), + ) 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..f12f807a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,9 @@ full = [ "ray", "scipy", "smart_open", + "spaday", + "starlette", + "uvicorn", "xarray", ] otel = [ @@ -96,6 +99,9 @@ develop = [ "ray", "scipy", "smart_open", + "spaday", + "starlette", + "uvicorn", "xarray", # Reporting deps "opentelemetry-api", @@ -119,6 +125,7 @@ test = [ ] [project.scripts] +ccflow-ui-spaday = "ccflow.ui.spaday.cli:main" [project.urls] Repository = "https://github.com/Point72/ccflow" From 69ca5f137ae6eafa4d84fae98cffdfde9dddc0ef Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:50:15 -0400 Subject: [PATCH 2/8] Support lazy registries in Spaday browser Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/tests/ui/spaday/test_registry.py | 19 ++++++++++++++++++- ccflow/ui/spaday/model.py | 20 +++++++++++++++++++- ccflow/ui/spaday/registry.py | 13 ++++++++++--- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/ccflow/tests/ui/spaday/test_registry.py b/ccflow/tests/ui/spaday/test_registry.py index 13989c15..6d2998bf 100644 --- a/ccflow/tests/ui/spaday/test_registry.py +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -2,7 +2,7 @@ from spaday.validate import validate -from ccflow import BaseModel, ModelRegistry +from ccflow import BaseModel, LazyRegistry, ModelRegistry from ccflow.ui.spaday.registry import ( SELECTED_FIELD, registry_leaves, @@ -135,3 +135,20 @@ def test_empty_registry_renders(self): node = registry_viewer(ModelRegistry(name="empty")).to_node() # Only the placeholder show panel, no model panels. assert [show_when_value(n) for n in nodes_with_tag(node, "spa-show")] == [""] + + 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", + } + }, + ) + + node = registry_viewer(lazy).to_node() + + assert not lazy["group"].is_loaded("model") + show_targets = {show_when_value(item) for item in nodes_with_tag(node, "spa-show")} + assert "group/model" in show_targets diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py index ba2c61c7..8901135b 100644 --- a/ccflow/ui/spaday/model.py +++ b/ccflow/ui/spaday/model.py @@ -13,7 +13,7 @@ import ccflow -__all__ = ("model_type_view", "model_config_view", "model_view") +__all__ = ("model_type_view", "model_config_view", "model_view", "pending_model_view") _PRE_STYLE = { "white_space": "pre-wrap", @@ -118,3 +118,21 @@ def model_view(model, path: str = "") -> Component: 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 pending_model_view(config, path: str) -> Component: + """A card showing configuration for a model that has not been instantiated.""" + target = str(config.get("_target_", "Pending model")) + tabs = Tabs(active="summary") + tabs.tab( + "Summary", + Column( + _labeled("Registry Path", _code(path)), + Text("This model will be instantiated when accessed from Python."), + gap="0.75rem", + ), + name="summary", + ) + tabs.tab("Configuration", _pre(json.dumps(config, indent=2, default=str)), name="configuration") + header = Row(WaBadge(variant="neutral").text("Pending"), Strong(target), 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 index 406e1266..519c37dc 100644 --- a/ccflow/ui/spaday/registry.py +++ b/ccflow/ui/spaday/registry.py @@ -14,7 +14,7 @@ import ccflow -from .model import model_view +from .model import model_view, pending_model_view __all__ = ("SELECTED_FIELD", "registry_store", "registry_leaves", "registry_tree", "registry_viewer") @@ -29,7 +29,13 @@ def registry_store() -> dict: def _sorted_items(registry, sort_children: bool): """Registry entries, optionally with subregistries first and each group sorted alphabetically.""" - items = registry.models.items() + 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) @@ -89,7 +95,8 @@ def registry_viewer(registry, *, title: str = "ccflow Model Registry", browser_w panels: List[Component] = [Show(_placeholder(), when=eq(field(SELECTED_FIELD), lit("")))] for path, model in leaves: - panels.append(Show(model_view(model, path), when=eq(field(SELECTED_FIELD), lit(path)))) + detail = pending_model_view(model, path) if isinstance(model, dict) and "_target_" in model else model_view(model, path) + panels.append(Show(detail, when=eq(field(SELECTED_FIELD), lit(path)))) return App( Nav(Strong(title)), From bfb6bc102151f41a606e6ca407b94176eaee1fce Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:21:40 -0400 Subject: [PATCH 3/8] Materialize when navigating Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/tests/ui/spaday/test_cli.py | 50 +++++++++++++++++++++++++++- ccflow/tests/ui/spaday/test_model.py | 31 +++++++++++++++-- ccflow/ui/spaday/cli.py | 44 ++++++++++++++++++++++-- ccflow/ui/spaday/model.py | 25 +++++++++++--- 4 files changed, 140 insertions(+), 10 deletions(-) diff --git a/ccflow/tests/ui/spaday/test_cli.py b/ccflow/tests/ui/spaday/test_cli.py index 976b6855..c77ab026 100644 --- a/ccflow/tests/ui/spaday/test_cli.py +++ b/ccflow/tests/ui/spaday/test_cli.py @@ -2,9 +2,10 @@ from pathlib import Path +import pytest from spaday.bootstrap import _ASSETS, bundles_dir -from ccflow import BaseModel, ModelRegistry +from ccflow import BaseModel, LazyRegistry, ModelRegistry from ccflow.ui.spaday.cli import _asset_layout, _get_ui_args_parser, serve_registry @@ -71,6 +72,53 @@ def test_tree_route_reflects_registry(self): 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 + + +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): + starlette_testclient = pytest.importorskip("starlette.testclient") + registry = self._lazy_registry() + app = serve_registry(registry, run=False) + assert not registry["group"].is_loaded("model") + + client = starlette_testclient.TestClient(app) + response = client.get("/materialize", params={"path": "group/model"}, follow_redirects=False) + + assert response.status_code == 303 + assert "sel=group/model" in response.headers["location"] + assert registry["group"].is_loaded("model") + + def test_materialize_missing_path_redirects_without_error(self): + starlette_testclient = pytest.importorskip("starlette.testclient") + registry = self._lazy_registry() + app = serve_registry(registry, run=False) + + client = starlette_testclient.TestClient(app) + response = client.get("/materialize", follow_redirects=False) + + assert response.status_code == 303 + + def test_homepage_seeds_selected_model(self): + starlette_testclient = pytest.importorskip("starlette.testclient") + registry = self._lazy_registry() + app = serve_registry(registry, run=False) + + client = starlette_testclient.TestClient(app) + assert "group/model" in client.get("/", params={"sel": "group/model"}).text + assert "group/model" not in client.get("/").text + class TestAssetLayout: def test_selected_layout_has_runtime_asset(self): diff --git a/ccflow/tests/ui/spaday/test_model.py b/ccflow/tests/ui/spaday/test_model.py index 1338d764..a6120210 100644 --- a/ccflow/tests/ui/spaday/test_model.py +++ b/ccflow/tests/ui/spaday/test_model.py @@ -6,9 +6,9 @@ from spaday.validate import validate from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, ModelRegistry -from ccflow.ui.spaday.model import model_config_view, model_type_view, model_view +from ccflow.ui.spaday.model import MATERIALIZE_ENDPOINT, model_config_view, model_type_view, model_view, pending_model_view -from .utils import all_text, nodes_with_tag, text_of +from .utils import all_text, nodes_with_tag, prop_str, text_of class SimpleModel(BaseModel): @@ -112,3 +112,30 @@ def test_parameters_include_field_values(self): def test_validates(self): validate(model_view(MyCallable(), "m").to_node()) + + +class TestPendingModelView: + _config = {"_target_": "ccflow.tests.ui.spaday.test_model.SimpleModel", "name": "pending"} + + def test_is_card(self): + node = pending_model_view(self._config, "group/model").to_node() + assert node["tag"] == "wa-card" + + def test_shows_pending_badge_and_target(self): + text = " ".join(all_text(pending_model_view(self._config, "group/model").to_node())) + assert "Pending" in text + assert "SimpleModel" in text + + def test_configuration_tab_shows_target(self): + text = " ".join(all_text(pending_model_view(self._config, "group/model").to_node())) + assert "_target_" in text + + def test_materialize_button_links_to_endpoint_with_path(self): + from urllib.parse import urlencode + + node = pending_model_view(self._config, "group/model").to_node() + hrefs = [prop_str(button, "href") for button in nodes_with_tag(node, "wa-button")] + assert f"{MATERIALIZE_ENDPOINT}?{urlencode({'path': 'group/model'})}" in hrefs + + def test_validates(self): + validate(pending_model_view(self._config, "group/model").to_node()) diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py index 03bc54d0..ce76448f 100644 --- a/ccflow/ui/spaday/cli.py +++ b/ccflow/ui/spaday/cli.py @@ -6,17 +6,22 @@ """ import argparse +import logging import os from pathlib import Path from typing import Callable, Optional +from urllib.parse import quote from ccflow import ModelRegistry from ccflow.utils.hydra import add_hydra_config_args, load_config, resolve_config_paths -from .registry import registry_store, registry_viewer +from .model import MATERIALIZE_ENDPOINT +from .registry import SELECTED_FIELD, registry_store, registry_viewer __all__ = ("serve_registry", "registry_viewer_cli", "main") +log = logging.getLogger(__name__) + def _asset_layout() -> str: """Select spaday's asset layout ("source" vs "installed"). @@ -59,18 +64,51 @@ def serve_registry( try: import uvicorn from spaday.backends.starlette import serve + from spaday.bootstrap import bootstrap + from starlette.responses import HTMLResponse, RedirectResponse + 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 + layout = _asset_layout() + + 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, then redirect back with it selected. + + Materialization is best-effort: if the model cannot be constructed (e.g. it needs live data + or an unavailable dependency) the failure is logged and the page still reloads, leaving the + entry pending so it can be retried. + """ + path = request.query_params.get("path", "") + if path: + try: + registry[path] + except Exception: + log.exception("Failed to materialize lazy registry model %r", path) + return RedirectResponse(url=f"/?sel={quote(path)}", status_code=303) + + def homepage(request): + """Serve the page with the ``?sel=`` model preselected (used by the materialize redirect).""" + selected = request.query_params.get("sel", "") + return HTMLResponse(bootstrap(bundles=["webawesome"], store={SELECTED_FIELD: selected}, title=title, layout=layout)) + app = serve( - lambda: registry_viewer(registry, title=title, browser_width=browser_width, sort_children=sort_children), + page, bundles=["webawesome"], store=registry_store(), title=title, - layout=_asset_layout(), + layout=layout, + routes=[Route(MATERIALIZE_ENDPOINT, materialize, methods=["GET"])], ) + # Prepend a homepage that seeds the selection from ?sel= so the freshly materialized model's detail + # card is shown immediately after the materialize redirect (Starlette matches routes in order). + app.routes.insert(0, Route("/", homepage, methods=["GET"])) + if run: uvicorn.run(app, host=address, port=port) return app diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py index 8901135b..2b118bf9 100644 --- a/ccflow/ui/spaday/model.py +++ b/ccflow/ui/spaday/model.py @@ -6,14 +6,19 @@ """ import json +from urllib.parse import urlencode from pydantic._internal._repr import display_as_type from spaday import Component, Strong, Text, element -from spaday.components import Column, Row, Tabs, WaBadge, WaCard, WaDivider +from spaday.components import Column, Row, Tabs, WaBadge, WaButton, WaCard, WaDivider import ccflow -__all__ = ("model_type_view", "model_config_view", "model_view", "pending_model_view") +#: Path of the endpoint (served by :func:`ccflow.ui.spaday.cli.serve_registry`) that materializes a +#: pending model server-side and redirects back with it selected. +MATERIALIZE_ENDPOINT = "/materialize" + +__all__ = ("MATERIALIZE_ENDPOINT", "model_type_view", "model_config_view", "model_view", "pending_model_view") _PRE_STYLE = { "white_space": "pre-wrap", @@ -120,15 +125,27 @@ def model_view(model, path: str = "") -> Component: return WaCard(appearance="outlined").child(Column(header, WaDivider(), tabs, gap="0.75rem")) +def _materialize_button(path: str) -> Component: + """A link that asks the server to instantiate the pending model and reselect it once loaded.""" + href = f"{MATERIALIZE_ENDPOINT}?{urlencode({'path': path})}" + return WaButton(variant="brand", href=href).text("Materialize") + + def pending_model_view(config, path: str) -> Component: - """A card showing configuration for a model that has not been instantiated.""" + """A card showing configuration for a model that has not been instantiated. + + The model is only inspected as its unresolved config here; the ``Materialize`` action instantiates + it on the server (in a try/except) and reloads the page with the now-loaded model selected, so its + full :func:`model_view` detail is shown. + """ target = str(config.get("_target_", "Pending model")) tabs = Tabs(active="summary") tabs.tab( "Summary", Column( _labeled("Registry Path", _code(path)), - Text("This model will be instantiated when accessed from Python."), + 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", From a03f022b915c65616051188189e615f27d63639d Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:53:31 -0400 Subject: [PATCH 4/8] Update Spaday integration for latest dependencies Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/flow_model.py | 1 + ccflow/tests/ui/spaday/test_model.py | 6 +++--- ccflow/ui/__init__.py | 2 +- ccflow/ui/spaday/__init__.py | 6 +++--- ccflow/ui/spaday/cli.py | 11 ++++++----- ccflow/ui/spaday/model.py | 5 +++-- ccflow/ui/spaday/registry.py | 19 +++++++++---------- pyproject.toml | 2 ++ 8 files changed, 28 insertions(+), 24 deletions(-) diff --git a/ccflow/flow_model.py b/ccflow/flow_model.py index 488cd993..da2951ce 100644 --- a/ccflow/flow_model.py +++ b/ccflow/flow_model.py @@ -387,6 +387,7 @@ class _LocalFlowModelPicklePayload(NamedTuple): serialized_config: Any factory_kwargs: dict[str, Any] + def _context_values(context: ContextBase) -> dict[str, Any]: return dict(context) diff --git a/ccflow/tests/ui/spaday/test_model.py b/ccflow/tests/ui/spaday/test_model.py index a6120210..12dfa0d4 100644 --- a/ccflow/tests/ui/spaday/test_model.py +++ b/ccflow/tests/ui/spaday/test_model.py @@ -1,6 +1,6 @@ """Unit tests for ccflow.ui.spaday.model module.""" -from typing import Type +from typing import ClassVar from pydantic import Field from spaday.validate import validate @@ -30,7 +30,7 @@ class MyCallable(CallableModel): x: str = "hi" @property - def context_type(self) -> Type[Ctx]: + def context_type(self) -> type[Ctx]: return Ctx @Flow.call @@ -115,7 +115,7 @@ def test_validates(self): class TestPendingModelView: - _config = {"_target_": "ccflow.tests.ui.spaday.test_model.SimpleModel", "name": "pending"} + _config: ClassVar = {"_target_": "ccflow.tests.ui.spaday.test_model.SimpleModel", "name": "pending"} def test_is_card(self): node = pending_model_view(self._config, "group/model").to_node() diff --git a/ccflow/ui/__init__.py b/ccflow/ui/__init__.py index a09aa86a..29d62b42 100644 --- a/ccflow/ui/__init__.py +++ b/ccflow/ui/__init__.py @@ -1 +1 @@ -from .panel import * # noqa: F401,F403 Back-compat: the Panel UI remains the default and is re-exported here. +from .panel import * diff --git a/ccflow/ui/spaday/__init__.py b/ccflow/ui/spaday/__init__.py index 076d582e..417aeab3 100644 --- a/ccflow/ui/spaday/__init__.py +++ b/ccflow/ui/spaday/__init__.py @@ -1,3 +1,3 @@ -from .cli import * # noqa: F401,F403 -from .model import * # noqa: F401,F403 -from .registry import * # noqa: F401,F403 +from .cli import * +from .model import * +from .registry import * diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py index ce76448f..e946457c 100644 --- a/ccflow/ui/spaday/cli.py +++ b/ccflow/ui/spaday/cli.py @@ -8,17 +8,18 @@ import argparse import logging import os +from collections.abc import Callable from pathlib import Path -from typing import Callable, Optional from urllib.parse import quote from ccflow import ModelRegistry from ccflow.utils.hydra import add_hydra_config_args, load_config, resolve_config_paths +from spaday_webawesome import package as webawesome_package from .model import MATERIALIZE_ENDPOINT from .registry import SELECTED_FIELD, registry_store, registry_viewer -__all__ = ("serve_registry", "registry_viewer_cli", "main") +__all__ = ("main", "registry_viewer_cli", "serve_registry") log = logging.getLogger(__name__) @@ -95,11 +96,11 @@ async def materialize(request): def homepage(request): """Serve the page with the ``?sel=`` model preselected (used by the materialize redirect).""" selected = request.query_params.get("sel", "") - return HTMLResponse(bootstrap(bundles=["webawesome"], store={SELECTED_FIELD: selected}, title=title, layout=layout)) + return HTMLResponse(bootstrap(packages=webawesome_package, store={SELECTED_FIELD: selected}, title=title, layout=layout)) app = serve( page, - bundles=["webawesome"], + packages=webawesome_package, store=registry_store(), title=title, layout=layout, @@ -150,7 +151,7 @@ def _get_ui_args_parser() -> argparse.ArgumentParser: def registry_viewer_cli( config_path: str = "", config_name: str = "", - hydra_main: Optional[Callable] = None, + hydra_main: Callable | None = None, ): """CLI entry point for serving the spaday ModelRegistry viewer. diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py index 2b118bf9..8dc4590b 100644 --- a/ccflow/ui/spaday/model.py +++ b/ccflow/ui/spaday/model.py @@ -10,7 +10,8 @@ from pydantic._internal._repr import display_as_type from spaday import Component, Strong, Text, element -from spaday.components import Column, Row, Tabs, WaBadge, WaButton, WaCard, WaDivider +from spaday.components import Column, Row +from spaday_webawesome import Tabs, WaBadge, WaButton, WaCard, WaDivider import ccflow @@ -18,7 +19,7 @@ #: pending model server-side and redirects back with it selected. MATERIALIZE_ENDPOINT = "/materialize" -__all__ = ("MATERIALIZE_ENDPOINT", "model_type_view", "model_config_view", "model_view", "pending_model_view") +__all__ = ("MATERIALIZE_ENDPOINT", "model_config_view", "model_type_view", "model_view", "pending_model_view") _PRE_STYLE = { "white_space": "pre-wrap", diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py index 519c37dc..7e13e051 100644 --- a/ccflow/ui/spaday/registry.py +++ b/ccflow/ui/spaday/registry.py @@ -6,17 +6,16 @@ only when ``selected`` equals its path. No round-trip to Python is needed to change the selection. """ -from typing import List, Tuple - from spaday import Component, Strong, Text from spaday.actions import SetField, eq, field, lit -from spaday.components import App, Body, Column, Gutter, Main, Nav, Show, WaOption, WaSelect, WaTree, WaTreeItem +from spaday.components import App, Body, Column, Gutter, Main, Nav, Show +from spaday_webawesome import WaOption, WaSelect, WaTree, WaTreeItem import ccflow from .model import model_view, pending_model_view -__all__ = ("SELECTED_FIELD", "registry_store", "registry_leaves", "registry_tree", "registry_viewer") +__all__ = ("SELECTED_FIELD", "registry_leaves", "registry_store", "registry_tree", "registry_viewer") #: The signal-store field holding the selected model's registry path ("" when nothing is selected). SELECTED_FIELD = "selected" @@ -41,9 +40,9 @@ def _sorted_items(registry, sort_children: bool): return list(items) -def registry_leaves(registry, *, sort_children: bool = True, _prefix: str = "") -> List[Tuple[str, object]]: +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]] = [] + 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): @@ -53,9 +52,9 @@ def registry_leaves(registry, *, sort_children: bool = True, _prefix: str = "") return leaves -def registry_tree(registry, *, sort_children: bool = True, _prefix: str = "") -> List[WaTreeItem]: +def registry_tree(registry, *, sort_children: bool = True, _prefix: str = "") -> list[WaTreeItem]: """Build the ``wa-tree-item`` nodes for the registry; leaf clicks select the model by path.""" - nodes: List[WaTreeItem] = [] + nodes: list[WaTreeItem] = [] for name, model in _sorted_items(registry, sort_children): path = f"{_prefix}/{name}" if _prefix else name if isinstance(model, ccflow.ModelRegistry): @@ -75,7 +74,7 @@ def _placeholder() -> Component: ) -def _search(leaves: List[Tuple[str, object]]) -> WaSelect: +def _search(leaves: list[tuple[str, object]]) -> WaSelect: """A select of every model path, two-way bound to the selection so it both jumps and reflects.""" options = [WaOption(value="").text("— jump to a model —")] options += [WaOption(value=path).text(path) for path, _ in sorted(leaves)] @@ -93,7 +92,7 @@ def registry_viewer(registry, *, title: str = "ccflow Model Registry", browser_w gap="0.75rem", ) - panels: List[Component] = [Show(_placeholder(), when=eq(field(SELECTED_FIELD), lit("")))] + panels: list[Component] = [Show(_placeholder(), when=eq(field(SELECTED_FIELD), lit("")))] for path, model in leaves: detail = pending_model_view(model, path) if isinstance(model, dict) and "_target_" in model else model_view(model, path) panels.append(Show(detail, when=eq(field(SELECTED_FIELD), lit(path)))) diff --git a/pyproject.toml b/pyproject.toml index f12f807a..041f0b59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ full = [ "scipy", "smart_open", "spaday", + "spaday-webawesome", "starlette", "uvicorn", "xarray", @@ -100,6 +101,7 @@ develop = [ "scipy", "smart_open", "spaday", + "spaday-webawesome", "starlette", "uvicorn", "xarray", From d755012fa34c25ae28caa0fd0939351d29b25ac0 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:36:01 -0400 Subject: [PATCH 5/8] Address Spaday review findings Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/tests/ui/spaday/test_cli.py | 23 ++++++++++++++++--- ccflow/tests/ui/spaday/test_model.py | 30 ++++++++++--------------- ccflow/tests/ui/spaday/test_registry.py | 11 ++++++--- ccflow/ui/cli.py | 3 +++ ccflow/ui/model.py | 3 +++ ccflow/ui/registry.py | 3 +++ ccflow/ui/spaday/cli.py | 13 ++++++----- ccflow/ui/spaday/model.py | 27 ++++++++++------------ ccflow/ui/spaday/registry.py | 14 +++++++++--- 9 files changed, 80 insertions(+), 47 deletions(-) create mode 100644 ccflow/ui/cli.py create mode 100644 ccflow/ui/model.py create mode 100644 ccflow/ui/registry.py diff --git a/ccflow/tests/ui/spaday/test_cli.py b/ccflow/tests/ui/spaday/test_cli.py index c77ab026..a02f3543 100644 --- a/ccflow/tests/ui/spaday/test_cli.py +++ b/ccflow/tests/ui/spaday/test_cli.py @@ -1,5 +1,6 @@ """Unit tests for ccflow.ui.spaday.cli module.""" +import importlib from pathlib import Path import pytest @@ -79,6 +80,10 @@ def test_materialize_route_present(self): 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): @@ -87,18 +92,22 @@ def _lazy_registry(self): group={"model": {"_target_": "ccflow.tests.ui.spaday.test_cli.SimpleModel", "name": "pending"}}, ) - def test_materialize_instantiates_pending_model(self): + 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.get("/materialize", params={"path": "group/model"}, follow_redirects=False) + response = client.post("/materialize", data={"path": "group/model"}, follow_redirects=False) assert response.status_code == 303 assert "sel=group/model" in response.headers["location"] assert registry["group"].is_loaded("model") + to_thread.assert_awaited_once() def test_materialize_missing_path_redirects_without_error(self): starlette_testclient = pytest.importorskip("starlette.testclient") @@ -106,10 +115,18 @@ def test_materialize_missing_path_redirects_without_error(self): app = serve_registry(registry, run=False) client = starlette_testclient.TestClient(app) - response = client.get("/materialize", follow_redirects=False) + response = client.post("/materialize", follow_redirects=False) assert response.status_code == 303 + 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 + def test_homepage_seeds_selected_model(self): starlette_testclient = pytest.importorskip("starlette.testclient") registry = self._lazy_registry() diff --git a/ccflow/tests/ui/spaday/test_model.py b/ccflow/tests/ui/spaday/test_model.py index 12dfa0d4..57a5aee6 100644 --- a/ccflow/tests/ui/spaday/test_model.py +++ b/ccflow/tests/ui/spaday/test_model.py @@ -1,8 +1,7 @@ """Unit tests for ccflow.ui.spaday.model module.""" -from typing import ClassVar - from pydantic import Field +from spaday.actions import field from spaday.validate import validate from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, ModelRegistry @@ -115,27 +114,22 @@ def test_validates(self): class TestPendingModelView: - _config: ClassVar = {"_target_": "ccflow.tests.ui.spaday.test_model.SimpleModel", "name": "pending"} - def test_is_card(self): - node = pending_model_view(self._config, "group/model").to_node() + node = pending_model_view("group/model").to_node() assert node["tag"] == "wa-card" - def test_shows_pending_badge_and_target(self): - text = " ".join(all_text(pending_model_view(self._config, "group/model").to_node())) + def test_shows_pending_badge_and_path(self): + text = " ".join(all_text(pending_model_view("group/model").to_node())) assert "Pending" in text - assert "SimpleModel" in text - - def test_configuration_tab_shows_target(self): - text = " ".join(all_text(pending_model_view(self._config, "group/model").to_node())) - assert "_target_" in text + assert "group/model" in text def test_materialize_button_links_to_endpoint_with_path(self): - from urllib.parse import urlencode - - node = pending_model_view(self._config, "group/model").to_node() - hrefs = [prop_str(button, "href") for button in nodes_with_tag(node, "wa-button")] - assert f"{MATERIALIZE_ENDPOINT}?{urlencode({'path': 'group/model'})}" in hrefs + node = pending_model_view(field("selected")).to_node() + forms = nodes_with_tag(node, "form") + assert prop_str(forms[0], "method") == "post" + assert prop_str(forms[0], "action") == MATERIALIZE_ENDPOINT + inputs = nodes_with_tag(node, "input") + assert prop_str(inputs[0], "name") == "path" def test_validates(self): - validate(pending_model_view(self._config, "group/model").to_node()) + 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 index 6d2998bf..b685d661 100644 --- a/ccflow/tests/ui/spaday/test_registry.py +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -143,12 +143,17 @@ def test_lazy_registry_renders_without_materializing_models(self): "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") - show_targets = {show_when_value(item) for item in nodes_with_tag(node, "spa-show")} - assert "group/model" in show_targets + assert not lazy["group"].is_loaded("other") + # Placeholder plus one shared pending-model panel, not one detail card per pending leaf. + assert len(nodes_with_tag(node, "spa-show")) == 2 diff --git a/ccflow/ui/cli.py b/ccflow/ui/cli.py new file mode 100644 index 00000000..b7657617 --- /dev/null +++ b/ccflow/ui/cli.py @@ -0,0 +1,3 @@ +"""Compatibility imports for the Panel UI CLI.""" + +from .panel.cli import * diff --git a/ccflow/ui/model.py b/ccflow/ui/model.py new file mode 100644 index 00000000..59a6365b --- /dev/null +++ b/ccflow/ui/model.py @@ -0,0 +1,3 @@ +"""Compatibility imports for Panel model views.""" + +from .panel.model import * diff --git a/ccflow/ui/registry.py b/ccflow/ui/registry.py new file mode 100644 index 00000000..e8162ca3 --- /dev/null +++ b/ccflow/ui/registry.py @@ -0,0 +1,3 @@ +"""Compatibility imports for Panel registry views.""" + +from .panel.registry import * diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py index e946457c..ae9469b6 100644 --- a/ccflow/ui/spaday/cli.py +++ b/ccflow/ui/spaday/cli.py @@ -6,15 +6,17 @@ """ import argparse +import asyncio import logging import os from collections.abc import Callable from pathlib import Path -from urllib.parse import quote +from urllib.parse import parse_qs, quote + +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 spaday_webawesome import package as webawesome_package from .model import MATERIALIZE_ENDPOINT from .registry import SELECTED_FIELD, registry_store, registry_viewer @@ -85,10 +87,11 @@ async def materialize(request): or an unavailable dependency) the failure is logged and the page still reloads, leaving the entry pending so it can be retried. """ - path = request.query_params.get("path", "") + body = parse_qs((await request.body()).decode()) + path = request.query_params.get("path", "") or body.get("path", [""])[0] if path: try: - registry[path] + await asyncio.to_thread(registry.__getitem__, path) except Exception: log.exception("Failed to materialize lazy registry model %r", path) return RedirectResponse(url=f"/?sel={quote(path)}", status_code=303) @@ -104,7 +107,7 @@ def homepage(request): store=registry_store(), title=title, layout=layout, - routes=[Route(MATERIALIZE_ENDPOINT, materialize, methods=["GET"])], + routes=[Route(MATERIALIZE_ENDPOINT, materialize, methods=["POST"])], ) # Prepend a homepage that seeds the selection from ?sel= so the freshly materialized model's detail # card is shown immediately after the materialize redirect (Starlette matches routes in order). diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py index 8dc4590b..a9e7f616 100644 --- a/ccflow/ui/spaday/model.py +++ b/ccflow/ui/spaday/model.py @@ -6,10 +6,10 @@ """ import json -from urllib.parse import urlencode from pydantic._internal._repr import display_as_type from spaday import Component, Strong, Text, element +from spaday.actions import Expr from spaday.components import Column, Row from spaday_webawesome import Tabs, WaBadge, WaButton, WaCard, WaDivider @@ -37,7 +37,7 @@ def _labeled(label: str, *body: Component) -> Component: return Column(Strong(label), *body, gap="0.25rem") -def _code(text: str, *, color: str = "") -> Component: +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 @@ -126,31 +126,28 @@ def model_view(model, path: str = "") -> Component: return WaCard(appearance="outlined").child(Column(header, WaDivider(), tabs, gap="0.75rem")) -def _materialize_button(path: str) -> Component: - """A link that asks the server to instantiate the pending model and reselect it once loaded.""" - href = f"{MATERIALIZE_ENDPOINT}?{urlencode({'path': path})}" - return WaButton(variant="brand", href=href).text("Materialize") +def _materialize_button() -> Component: + """A form that asks the server to instantiate the pending model and reselect it once loaded.""" + path = element("input", type="hidden", name="path").bind("value", "selected") + return element("form", path, WaButton(variant="brand", type="submit").text("Materialize"), method="post", action=MATERIALIZE_ENDPOINT) -def pending_model_view(config, path: str) -> Component: - """A card showing configuration for a model that has not been instantiated. +def pending_model_view(path: str | Expr) -> Component: + """A shared card for the currently selected model that has not been instantiated. - The model is only inspected as its unresolved config here; the ``Materialize`` action instantiates - it on the server (in a try/except) and reloads the page with the now-loaded model selected, so its - full :func:`model_view` detail is shown. + The ``Materialize`` action instantiates it on the server and reloads the page with the now-loaded + model selected, so its full :func:`model_view` detail is shown. """ - target = str(config.get("_target_", "Pending model")) 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), + _materialize_button(), gap="0.75rem", ), name="summary", ) - tabs.tab("Configuration", _pre(json.dumps(config, indent=2, default=str)), name="configuration") - header = Row(WaBadge(variant="neutral").text("Pending"), Strong(target), gap="0.5rem", align="center") + 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 index 7e13e051..e5c9e4b9 100644 --- a/ccflow/ui/spaday/registry.py +++ b/ccflow/ui/spaday/registry.py @@ -6,8 +6,10 @@ only when ``selected`` equals its path. No round-trip to Python is needed to change the selection. """ +from collections.abc import Mapping + from spaday import Component, Strong, Text -from spaday.actions import SetField, eq, field, lit +from spaday.actions import SetField, any_, eq, field, lit from spaday.components import App, Body, Column, Gutter, Main, Nav, Show from spaday_webawesome import WaOption, WaSelect, WaTree, WaTreeItem @@ -93,9 +95,15 @@ def registry_viewer(registry, *, title: str = "ccflow Model Registry", browser_w ) panels: list[Component] = [Show(_placeholder(), when=eq(field(SELECTED_FIELD), lit("")))] + pending_paths = [] for path, model in leaves: - detail = pending_model_view(model, path) if isinstance(model, dict) and "_target_" in model else model_view(model, path) - panels.append(Show(detail, when=eq(field(SELECTED_FIELD), lit(path)))) + if isinstance(model, Mapping) and "_target_" in model: + pending_paths.append(path) + else: + panels.append(Show(model_view(model, path), when=eq(field(SELECTED_FIELD), lit(path)))) + if pending_paths: + pending_selected = any_(*(eq(field(SELECTED_FIELD), lit(path)) for path in pending_paths)) + panels.append(Show(pending_model_view(field(SELECTED_FIELD)), when=pending_selected)) return App( Nav(Strong(title)), From f548c2eaec15cd02b71e2af6429226f3dcb3d6ae Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:35:57 -0400 Subject: [PATCH 6/8] Use spaday trees and dagre in the registry browser Replace the hand-built wa-tree with spaday-trees, which derives the hierarchy from registry paths and brings its own search box. Add a Dependencies tab rendering the registry dependency DAG with spaday-dagre; clicking a node selects that model, so the graph shares the sidebar's selection state. Registered names are root-relative and leading-slashed, so they are normalized before matching leaf paths. Bridge the tree's color-scheme to the wa-dark page theme and add a dark toggle. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/tests/ui/spaday/test_graph.py | 112 ++++++++++++++++++++++++ ccflow/tests/ui/spaday/test_registry.py | 61 ++++++------- ccflow/tests/ui/spaday/utils.py | 25 ++++++ ccflow/ui/spaday/cli.py | 22 ++++- ccflow/ui/spaday/graph.py | 105 ++++++++++++++++++++++ ccflow/ui/spaday/registry.py | 102 ++++++++++++--------- pyproject.toml | 4 + 7 files changed, 355 insertions(+), 76 deletions(-) create mode 100644 ccflow/tests/ui/spaday/test_graph.py create mode 100644 ccflow/ui/spaday/graph.py diff --git a/ccflow/tests/ui/spaday/test_graph.py b/ccflow/tests/ui/spaday/test_graph.py new file mode 100644 index 00000000..2828f7d4 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_graph.py @@ -0,0 +1,112 @@ +"""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_RANKDIR_FIELD, dependency_graph, dependency_graph_view +from ccflow.ui.spaday.registry import registry_leaves + +from .utils import all_text, event_action, nodes_with_tag, prop_value + + +class Leaf(BaseModel): + """A model with no registry dependencies.""" + + name: str = "leaf" + + +class Holder(BaseModel): + """A model that contains another registered model.""" + + child: Leaf + + +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 TestDependencyGraph: + def test_empty_registry(self): + assert dependency_graph([]) == {"nodes": [], "edges": []} + + def test_nodes_use_leaf_name_as_label(self): + graph = dependency_graph(registry_leaves(_registry_with_dependency())) + labels = {node["id"]: node["label"] for node in graph["nodes"]} + assert labels == {"sub/alpha": "alpha", "holder": "holder"} + + def test_edge_from_dependent_to_dependency(self): + graph = dependency_graph(registry_leaves(_registry_with_dependency())) + assert graph["edges"] == [{"source": "holder", "target": "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")) + graph = dependency_graph(registry_leaves(root)) + assert len(graph["nodes"]) == 2 + assert graph["edges"] == [] + + 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. + graph = dependency_graph(registry_leaves(holder_registry)) + assert [node["id"] for node in graph["nodes"]] == ["holder"] + assert graph["edges"] == [] + + def test_pending_models_are_nodes_without_edges(self): + lazy = LazyRegistry( + name="lazy", + group={"model": {"_target_": "ccflow.tests.ui.spaday.test_graph.Leaf", "name": "pending"}}, + ) + graph = dependency_graph(registry_leaves(lazy)) + assert [node["id"] for node in graph["nodes"]] == ["group/model"] + assert graph["nodes"][0]["class"] == "pending" + assert graph["edges"] == [] + assert not lazy["group"].is_loaded("model") + + +class TestDependencyGraphView: + def test_renders_dagre_component(self): + view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") + node = view.to_node() + dagre = nodes_with_tag(node, "spaday-dagre") + assert len(dagre) == 1 + assert prop_value(dagre[0], "graph")["edges"] == [{"source": "holder", "target": "sub/alpha"}] + + def test_node_click_sets_selection(self): + view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") + dagre = nodes_with_tag(view.to_node(), "spaday-dagre")[0] + action = event_action(dagre, "dagre-node-click") + assert action["kind"] == "set-field" + assert action["field"] == "selected" + # The node-click detail is the node id, i.e. the registry path. + assert action["value"] == {"expr": "event"} + + def test_layout_bound_to_rankdir_field(self): + view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") + dagre = nodes_with_tag(view.to_node(), "spaday-dagre")[0] + layout = dagre["bindings"]["layout"]["compute"] + assert layout["fields"]["rankdir"] == {"expr": "field", "name": DEPENDENCY_RANKDIR_FIELD} + + def test_empty_registry_message(self): + view = dependency_graph_view([], selected_field="selected") + node = view.to_node() + assert not nodes_with_tag(node, "spaday-dagre") + assert "No models" in all_text(node) + + def test_validates(self): + view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") + validate(view.to_node()) diff --git a/ccflow/tests/ui/spaday/test_registry.py b/ccflow/tests/ui/spaday/test_registry.py index b685d661..da658671 100644 --- a/ccflow/tests/ui/spaday/test_registry.py +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -4,14 +4,16 @@ from ccflow import BaseModel, LazyRegistry, ModelRegistry from ccflow.ui.spaday.registry import ( + DARK_FIELD, SELECTED_FIELD, + VIEW_FIELD, registry_leaves, registry_store, registry_tree, registry_viewer, ) -from .utils import click_set_field, nodes_with_tag, prop_str, show_when_value +from .utils import all_text, event_action, nodes_with_tag, prop_str, prop_value, show_when_value class SimpleModel(BaseModel): @@ -38,7 +40,10 @@ def _registry(): class TestRegistryStore: def test_default_store(self): - assert registry_store() == {SELECTED_FIELD: ""} + store = registry_store() + assert store[SELECTED_FIELD] == "" + assert store[VIEW_FIELD] == "details" + assert store[DARK_FIELD] is False class TestRegistryLeaves: @@ -73,28 +78,22 @@ def test_insertion_order_when_not_sorted(self): class TestRegistryTree: - def test_leaf_items_carry_selection_action(self): - nodes = registry_tree(_registry()) - # Serialize the whole set of tree items and collect leaf selection targets. - selected = set() - for item in nodes: - for node in nodes_with_tag(item.to_node(), "wa-tree-item"): - value = click_set_field(node) - if value is not None: - selected.add(value) - assert selected == {"sub/alpha", "zeta"} + 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_branch_items_have_no_selection_action(self): - nodes = registry_tree(_registry()) - # The top-level "sub" node is a branch; it must not carry a click action. - sub_item = next(n for n in nodes if any(t == "sub" for t in _labels(n.to_node()))) - assert click_set_field(sub_item.to_node()) is None + 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 _labels(node): - from .utils import text_of - - return [text_of(n) for n in node.get("slots", {}).get("default", [])] + def test_empty_registry_has_no_paths(self): + node = registry_tree(ModelRegistry(name="empty")).to_node() + assert prop_value(node, "paths") == [] class TestRegistryViewer: @@ -106,25 +105,20 @@ def test_validates(self): validate(registry_viewer(_registry()).to_node()) def test_title_in_header(self): - from .utils import all_text - 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() show_targets = {show_when_value(n) for n in nodes_with_tag(node, "spa-show")} - # A panel per leaf plus the empty-selection placeholder. + # A panel per leaf, plus the placeholder (whose condition is falsy-selection, not an equality). assert "sub/alpha" in show_targets assert "zeta" in show_targets - assert "" in show_targets + assert len(nodes_with_tag(node, "spa-show")) == 3 - def test_search_options_cover_all_leaves(self): + def test_dependency_graph_tab_present(self): node = registry_viewer(_registry()).to_node() - options = [prop_str(n, "value") for n in nodes_with_tag(node, "wa-option")] - # First option is the empty placeholder; the rest are sorted leaf paths. - assert options[0] == "" - assert options[1:] == sorted(["sub/alpha", "zeta"]) + assert nodes_with_tag(node, "spaday-dagre") def test_browser_width_sets_gutter(self): node = registry_viewer(_registry(), browser_width=500).to_node() @@ -133,8 +127,9 @@ def test_browser_width_sets_gutter(self): def test_empty_registry_renders(self): node = registry_viewer(ModelRegistry(name="empty")).to_node() - # Only the placeholder show panel, no model panels. - assert [show_when_value(n) for n in nodes_with_tag(node, "spa-show")] == [""] + # Only the placeholder panel, and no graph to draw. + assert len(nodes_with_tag(node, "spa-show")) == 1 + assert not nodes_with_tag(node, "spaday-dagre") def test_lazy_registry_renders_without_materializing_models(self): lazy = LazyRegistry( diff --git a/ccflow/tests/ui/spaday/utils.py b/ccflow/tests/ui/spaday/utils.py index 6da9b73e..a152c645 100644 --- a/ccflow/tests/ui/spaday/utils.py +++ b/ccflow/tests/ui/spaday/utils.py @@ -31,6 +31,31 @@ def prop_str(node, 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") diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py index ae9469b6..65995273 100644 --- a/ccflow/ui/spaday/cli.py +++ b/ccflow/ui/spaday/cli.py @@ -13,6 +13,8 @@ from pathlib import Path from urllib.parse import parse_qs, quote +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 @@ -25,6 +27,20 @@ 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) + +#: The tree colours itself with CSS ``light-dark()``, which follows ``color-scheme`` rather than +#: webawesome's ``wa-dark`` class, so bridge the two to keep the sidebar in step with the page theme. +#: The inner ``file-tree-container`` sets ``color-scheme`` on its own ``:host``, so it must be targeted +#: directly for the document rule to win. +_STYLES = ( + ( + "spaday-tree, spaday-tree file-tree-container { color-scheme: light; }" + " .wa-dark spaday-tree, .wa-dark spaday-tree file-tree-container { color-scheme: dark; }" + ), +) + def _asset_layout() -> str: """Select spaday's asset layout ("source" vs "installed"). @@ -99,11 +115,13 @@ async def materialize(request): def homepage(request): """Serve the page with the ``?sel=`` model preselected (used by the materialize redirect).""" selected = request.query_params.get("sel", "") - return HTMLResponse(bootstrap(packages=webawesome_package, store={SELECTED_FIELD: selected}, title=title, layout=layout)) + store = {**registry_store(), SELECTED_FIELD: selected} + return HTMLResponse(bootstrap(packages=_PACKAGES, styles=_STYLES, store=store, title=title, layout=layout)) app = serve( page, - packages=webawesome_package, + packages=_PACKAGES, + styles=_STYLES, store=registry_store(), title=title, layout=layout, diff --git a/ccflow/ui/spaday/graph.py b/ccflow/ui/spaday/graph.py new file mode 100644 index 00000000..43b04202 --- /dev/null +++ b/ccflow/ui/spaday/graph.py @@ -0,0 +1,105 @@ +"""Registry dependency graph, 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, which +this module turns into the serializable ``{nodes, edges}`` config the ``spaday-dagre`` component lays +out. Clicking a node selects that model, so the graph doubles as a navigator. +""" + +from collections.abc import Mapping + +from spaday import Component, Strong, Text +from spaday.actions import SetField, event_value, field, obj +from spaday.components import Column, Row +from spaday_dagre import Dagre +from spaday_webawesome import WaButton + +__all__ = ("DEPENDENCY_RANKDIR_FIELD", "dependency_graph", "dependency_graph_view") + +#: The signal-store field holding the dagre ``rankdir`` layout direction. +DEPENDENCY_RANKDIR_FIELD = "rankdir" + + +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_graph(leaves: list[tuple[str, object]]) -> dict: + """Build the dagre ``{nodes, edges}`` config for the registry's dependency relation. + + Only edges between models present in ``leaves`` are emitted, so a dependency on something outside + the browsed registry does not introduce a dangling node. Pending (lazy) models are shown, but + contribute no edges because resolving them would instantiate the model. + """ + known = {path for path, _ in leaves} + nodes = [] + edges = [] + + for path, model in leaves: + pending = _is_pending(model) + node = {"id": path, "label": path.rsplit("/", 1)[-1]} + if pending: + node["class"] = "pending" + nodes.append(node) + if pending: + continue + 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: + edges.append({"source": path, "target": target}) + + # Deduplicate edges while preserving order (a model may reference the same dependency twice). + seen = set() + unique_edges = [] + for edge in edges: + key = (edge["source"], edge["target"]) + if key not in seen: + seen.add(key) + unique_edges.append(edge) + + return {"nodes": nodes, "edges": unique_edges} + + +def _rankdir_button(label: str, rankdir: str) -> WaButton: + return WaButton(appearance="outlined", size="s").text(label).on("click", SetField(DEPENDENCY_RANKDIR_FIELD, rankdir)) + + +def dependency_graph_view(leaves: list[tuple[str, object]], *, selected_field: str) -> Component: + """The dependency graph panel: layout controls plus the graph itself. + + ``selected_field`` is the signal-store field a node click writes to, so the graph drives the same + selection as the sidebar tree. + """ + graph = dependency_graph(leaves) + + if not graph["nodes"]: + return Column(Strong("No models"), Text("This registry has no models to graph."), gap="0.5rem") + + if not graph["edges"]: + header = Text("No registry dependencies between these models.") + else: + header = Text(f"{len(graph['nodes'])} models, {len(graph['edges'])} dependencies. Click a node to inspect it.") + + controls = Row( + _rankdir_button("Left to right", "LR"), + _rankdir_button("Top down", "TB"), + gap="0.5rem", + align="center", + ) + + dagre = ( + Dagre(id="dependency-graph", zoomable=True) + .prop("graph", graph) + .compute("layout", obj({"rankdir": field(DEPENDENCY_RANKDIR_FIELD)})) + .on("dagre-node-click", SetField(selected_field, event_value())) + .style(display="block", min_height="60vh") + ) + + return Column(header, controls, dagre, gap="0.75rem") diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py index e5c9e4b9..7677ba3f 100644 --- a/ccflow/ui/spaday/registry.py +++ b/ccflow/ui/spaday/registry.py @@ -1,31 +1,47 @@ """Registry browser and top-level viewer as a spaday component tree. -Selection is driven entirely client-side through the runtime's signal store: clicking a leaf in the -``wa-tree`` (or picking it from the search ``wa-select``) writes the model's path to the ``selected`` -field, and each model's detail card is wrapped in a :class:`~spaday.components.shell.Show` that mounts -only when ``selected`` equals its path. No round-trip to Python is needed to change the selection. +Selection is driven entirely 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 each model's detail card is +wrapped in a :class:`~spaday.components.shell.Show` that mounts only when ``selected`` equals its path. +No round-trip to Python is needed to change the selection. """ from collections.abc import Mapping from spaday import Component, Strong, Text -from spaday.actions import SetField, any_, eq, field, lit -from spaday.components import App, Body, Column, Gutter, Main, Nav, Show -from spaday_webawesome import WaOption, WaSelect, WaTree, WaTreeItem +from spaday.actions import SetField, any_, eq, event_value, field, lit, not_ +from spaday.components import App, Body, Column, Gutter, Main, Nav, Row, Show +from spaday_trees import Tree +from spaday_webawesome import Tabs, WaSwitch import ccflow +from .graph import DEPENDENCY_RANKDIR_FIELD, dependency_graph_view from .model import model_view, pending_model_view -__all__ = ("SELECTED_FIELD", "registry_leaves", "registry_store", "registry_tree", "registry_viewer") +__all__ = ( + "DARK_FIELD", + "SELECTED_FIELD", + "VIEW_FIELD", + "registry_leaves", + "registry_store", + "registry_tree", + "registry_viewer", +) #: The signal-store field holding the selected model's registry path ("" when nothing is selected). SELECTED_FIELD = "selected" +#: The signal-store field holding the active main-area tab. +VIEW_FIELD = "view" + +#: 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: ""} + return {SELECTED_FIELD: "", VIEW_FIELD: "details", DARK_FIELD: False, DEPENDENCY_RANKDIR_FIELD: "LR"} def _sorted_items(registry, sort_children: bool): @@ -54,17 +70,19 @@ def registry_leaves(registry, *, sort_children: bool = True, _prefix: str = "") return leaves -def registry_tree(registry, *, sort_children: bool = True, _prefix: str = "") -> list[WaTreeItem]: - """Build the ``wa-tree-item`` nodes for the registry; leaf clicks select the model by path.""" - nodes: list[WaTreeItem] = [] - for name, model in _sorted_items(registry, sort_children): - path = f"{_prefix}/{name}" if _prefix else name - if isinstance(model, ccflow.ModelRegistry): - children = registry_tree(model, sort_children=sort_children, _prefix=path) - nodes.append(WaTreeItem(Text(name), *children)) - else: - nodes.append(WaTreeItem(Text(name)).on("click", SetField(SELECTED_FIELD, lit(path)))) - return nodes +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. + """ + paths = [path for path, _ in registry_leaves(registry, sort_children=sort_children)] + # The tree virtualizes its rows, so it renders nothing unless it is given a height to fill. + return ( + Tree(paths=paths, id="registry-tree") + .on("selection-change", SetField(SELECTED_FIELD, event_value("paths.0"))) + .style(display="block", flex="1", min_height="70vh") + ) def _placeholder() -> Component: @@ -76,36 +94,38 @@ def _placeholder() -> Component: ) -def _search(leaves: list[tuple[str, object]]) -> WaSelect: - """A select of every model path, two-way bound to the selection so it both jumps and reflects.""" - options = [WaOption(value="").text("— jump to a model —")] - options += [WaOption(value=path).text(path) for path, _ in sorted(leaves)] - return WaSelect(placeholder="Search / jump to model", with_clear=True).child(*options).bind("value", SELECTED_FIELD, mode="two-way") +def _details_view(leaves: list[tuple[str, object]]) -> Component: + """The per-model detail cards, one mounted at a time based on ``selected``.""" + panels: list[Component] = [Show(_placeholder(), when=not_(field(SELECTED_FIELD)))] + pending_paths = [] + for path, model in leaves: + if isinstance(model, Mapping) and "_target_" in model: + pending_paths.append(path) + else: + panels.append(Show(model_view(model, path), when=eq(field(SELECTED_FIELD), lit(path)))) + if pending_paths: + pending_selected = any_(*(eq(field(SELECTED_FIELD), lit(path)) for path in pending_paths)) + panels.append(Show(pending_model_view(field(SELECTED_FIELD)), when=pending_selected)) + return Column(*panels, gap="1rem") 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 + search, and the selected model's detail card.""" + """Compose the full page: a sidebar registry tree, and the selected model's details or the graph.""" leaves = registry_leaves(registry, sort_children=sort_children) - tree = WaTree(*registry_tree(registry, sort_children=sort_children), selection="leaf") sidebar = Gutter( - Column(Strong("Registry"), _search(leaves), tree, gap="0.75rem"), + Column(Strong("Registry"), registry_tree(registry, sort_children=sort_children), gap="0.75rem"), width=f"{browser_width}px", gap="0.75rem", ) - panels: list[Component] = [Show(_placeholder(), when=eq(field(SELECTED_FIELD), lit("")))] - pending_paths = [] - for path, model in leaves: - if isinstance(model, Mapping) and "_target_" in model: - pending_paths.append(path) - else: - panels.append(Show(model_view(model, path), when=eq(field(SELECTED_FIELD), lit(path)))) - if pending_paths: - pending_selected = any_(*(eq(field(SELECTED_FIELD), lit(path)) for path in pending_paths)) - panels.append(Show(pending_model_view(field(SELECTED_FIELD)), when=pending_selected)) + tabs = Tabs(active="details").bind("active", VIEW_FIELD, mode="two-way") + tabs.tab("Details", _details_view(leaves), name="details") + tabs.tab("Dependencies", dependency_graph_view(leaves, selected_field=SELECTED_FIELD), name="dependencies") + + theme = Row(WaSwitch().text("Dark").bind("checked", DARK_FIELD, mode="two-way"), gap="0.5rem", align="center") return App( - Nav(Strong(title)), - Body(sidebar, Main(Column(*panels, gap="1rem"))), - ) + Nav(Row(Strong(title), theme, gap="1rem", align="center", justify="space-between")), + Body(sidebar, Main(tabs)), + ).bind_root_class("wa-dark", DARK_FIELD) diff --git a/pyproject.toml b/pyproject.toml index 041f0b59..73e2518c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,8 @@ full = [ "scipy", "smart_open", "spaday", + "spaday-dagre", + "spaday-trees", "spaday-webawesome", "starlette", "uvicorn", @@ -101,6 +103,8 @@ develop = [ "scipy", "smart_open", "spaday", + "spaday-dagre", + "spaday-trees", "spaday-webawesome", "starlette", "uvicorn", From ce574c1b4b9bc007503c499179d624d502f7d0f0 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:34:11 -0400 Subject: [PATCH 7/8] Address review notes on the spaday browser Rename the server bind option from --address to --host. Make the dependency graph model-local: each model's card gets a Dependencies tab showing only what is reachable from that model, with the focused node marked, instead of one global graph in a page-level tab. Models with no dependencies get no tab. Bind the tree's selected_paths to the seeded selection so materializing a model reveals it again after the redirect instead of leaving the tree collapsed. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/tests/ui/spaday/test_cli.py | 8 +- ccflow/tests/ui/spaday/test_graph.py | 107 ++++++++++++-------- ccflow/tests/ui/spaday/test_registry.py | 33 +++++- ccflow/ui/spaday/cli.py | 15 ++- ccflow/ui/spaday/graph.py | 128 +++++++++++------------- ccflow/ui/spaday/model.py | 9 +- ccflow/ui/spaday/registry.py | 32 +++--- 7 files changed, 187 insertions(+), 145 deletions(-) diff --git a/ccflow/tests/ui/spaday/test_cli.py b/ccflow/tests/ui/spaday/test_cli.py index a02f3543..9c606248 100644 --- a/ccflow/tests/ui/spaday/test_cli.py +++ b/ccflow/tests/ui/spaday/test_cli.py @@ -26,7 +26,7 @@ def test_parser_composition(self): assert hasattr(args, "config_name") # Server + viewer-specific - assert hasattr(args, "address") + assert hasattr(args, "host") assert hasattr(args, "port") assert hasattr(args, "browser_width") assert hasattr(args, "title") @@ -34,15 +34,15 @@ def test_parser_composition(self): def test_defaults(self): args = _get_ui_args_parser().parse_args([]) - assert args.address == "127.0.0.1" + 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(["--address", "0.0.0.0", "--port", "9000", "--browser-width", "500", "--title", "Mine"]) - assert args.address == "0.0.0.0" + 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" diff --git a/ccflow/tests/ui/spaday/test_graph.py b/ccflow/tests/ui/spaday/test_graph.py index 2828f7d4..3fc95026 100644 --- a/ccflow/tests/ui/spaday/test_graph.py +++ b/ccflow/tests/ui/spaday/test_graph.py @@ -3,10 +3,10 @@ from spaday.validate import validate from ccflow import BaseModel, LazyRegistry, ModelRegistry -from ccflow.ui.spaday.graph import DEPENDENCY_RANKDIR_FIELD, dependency_graph, dependency_graph_view +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 all_text, event_action, nodes_with_tag, prop_value +from .utils import event_action, nodes_with_tag, prop_value class Leaf(BaseModel): @@ -21,6 +21,12 @@ class Holder(BaseModel): 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() @@ -32,27 +38,20 @@ def _registry_with_dependency(): return root -class TestDependencyGraph: +class TestDependencyEdges: def test_empty_registry(self): - assert dependency_graph([]) == {"nodes": [], "edges": []} - - def test_nodes_use_leaf_name_as_label(self): - graph = dependency_graph(registry_leaves(_registry_with_dependency())) - labels = {node["id"]: node["label"] for node in graph["nodes"]} - assert labels == {"sub/alpha": "alpha", "holder": "holder"} + assert dependency_edges([]) == {} def test_edge_from_dependent_to_dependency(self): - graph = dependency_graph(registry_leaves(_registry_with_dependency())) - assert graph["edges"] == [{"source": "holder", "target": "sub/alpha"}] + 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")) - graph = dependency_graph(registry_leaves(root)) - assert len(graph["nodes"]) == 2 - assert graph["edges"] == [] + assert dependency_edges(registry_leaves(root)) == {"one": [], "two": []} def test_dependencies_outside_registry_are_dropped(self): root = ModelRegistry.root() @@ -62,51 +61,79 @@ def test_dependencies_outside_registry_are_dropped(self): 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. - graph = dependency_graph(registry_leaves(holder_registry)) - assert [node["id"] for node in graph["nodes"]] == ["holder"] - assert graph["edges"] == [] + assert dependency_edges(registry_leaves(holder_registry)) == {"holder": []} - def test_pending_models_are_nodes_without_edges(self): + def test_pending_models_report_no_dependencies(self): lazy = LazyRegistry( name="lazy", group={"model": {"_target_": "ccflow.tests.ui.spaday.test_graph.Leaf", "name": "pending"}}, ) - graph = dependency_graph(registry_leaves(lazy)) - assert [node["id"] for node in graph["nodes"]] == ["group/model"] - assert graph["nodes"][0]["class"] == "pending" - assert graph["edges"] == [] + assert dependency_edges(registry_leaves(lazy)) == {"group/model": []} assert not lazy["group"].is_loaded("model") -class TestDependencyGraphView: +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"] + assert graph["edges"] == [{"source": "holder", "target": "sub/alpha"}] + + def test_focus_node_is_marked(self): + graph = model_dependency_graph("holder", {"holder": ["sub/alpha"], "sub/alpha": []}) + assert graph["nodes"][0]["class"] == "focus" + assert "class" not in graph["nodes"][1] + + 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"] + assert graph["edges"] == [ + {"source": "outer", "target": "holder"}, + {"source": "holder", "target": "leaf"}, + ] + + 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 + + +class TestModelDependencyView: + def test_none_without_dependencies(self): + adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) + assert model_dependency_view("sub/alpha", adjacency, selected_field="selected") is None + def test_renders_dagre_component(self): - view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") - node = view.to_node() + adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) + node = model_dependency_view("holder", adjacency, selected_field="selected").to_node() dagre = nodes_with_tag(node, "spaday-dagre") assert len(dagre) == 1 assert prop_value(dagre[0], "graph")["edges"] == [{"source": "holder", "target": "sub/alpha"}] def test_node_click_sets_selection(self): - view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") - dagre = nodes_with_tag(view.to_node(), "spaday-dagre")[0] + adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) + dagre = model_dependency_view("holder", adjacency, selected_field="selected").to_node() action = event_action(dagre, "dagre-node-click") assert action["kind"] == "set-field" assert action["field"] == "selected" # The node-click detail is the node id, i.e. the registry path. assert action["value"] == {"expr": "event"} - def test_layout_bound_to_rankdir_field(self): - view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") - dagre = nodes_with_tag(view.to_node(), "spaday-dagre")[0] - layout = dagre["bindings"]["layout"]["compute"] - assert layout["fields"]["rankdir"] == {"expr": "field", "name": DEPENDENCY_RANKDIR_FIELD} - - def test_empty_registry_message(self): - view = dependency_graph_view([], selected_field="selected") - node = view.to_node() - assert not nodes_with_tag(node, "spaday-dagre") - assert "No models" in all_text(node) + def test_layout_is_left_to_right(self): + adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) + dagre = model_dependency_view("holder", adjacency, selected_field="selected").to_node() + assert prop_value(dagre, "layout") == {"rankdir": "LR"} def test_validates(self): - view = dependency_graph_view(registry_leaves(_registry_with_dependency()), selected_field="selected") - validate(view.to_node()) + adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) + validate(model_dependency_view("holder", adjacency, selected_field="selected").to_node()) diff --git a/ccflow/tests/ui/spaday/test_registry.py b/ccflow/tests/ui/spaday/test_registry.py index da658671..ba65a273 100644 --- a/ccflow/tests/ui/spaday/test_registry.py +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -6,7 +6,7 @@ from ccflow.ui.spaday.registry import ( DARK_FIELD, SELECTED_FIELD, - VIEW_FIELD, + SELECTED_PATHS_FIELD, registry_leaves, registry_store, registry_tree, @@ -29,6 +29,12 @@ class AnotherModel(BaseModel): 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") @@ -42,9 +48,15 @@ class TestRegistryStore: def test_default_store(self): store = registry_store() assert store[SELECTED_FIELD] == "" - assert store[VIEW_FIELD] == "details" + assert store[SELECTED_PATHS_FIELD] == [] assert store[DARK_FIELD] is False + def test_store_seeded_with_selection(self): + # The materialize redirect reloads with ?sel=, and the tree expands to reveal that path. + store = registry_store("sub/alpha") + assert store[SELECTED_FIELD] == "sub/alpha" + assert store[SELECTED_PATHS_FIELD] == ["sub/alpha"] + class TestRegistryLeaves: def test_empty_registry(self): @@ -95,6 +107,10 @@ 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_bound_so_the_tree_reveals_the_selection(self): + node = registry_tree(_registry()).to_node() + assert node["bindings"]["selected_paths"]["field"] == SELECTED_PATHS_FIELD + class TestRegistryViewer: def test_returns_app(self): @@ -116,9 +132,16 @@ def test_show_panel_per_leaf(self): assert "zeta" in show_targets assert len(nodes_with_tag(node, "spa-show")) == 3 - def test_dependency_graph_tab_present(self): - node = registry_viewer(_registry()).to_node() - assert nodes_with_tag(node, "spaday-dagre") + def test_dependency_graph_is_model_local(self): + # Models without dependencies get no graph; the one that has them gets exactly one. + assert not nodes_with_tag(registry_viewer(_registry()).to_node(), "spaday-dagre") + + root = ModelRegistry.root() + root.clear() + leaf = SimpleModel(name="leaf") + root.add("leaf", leaf) + root.add("holder", HolderModel(child=leaf)) + assert len(nodes_with_tag(registry_viewer(root).to_node(), "spaday-dagre")) == 1 def test_browser_width_sets_gutter(self): node = registry_viewer(_registry(), browser_width=500).to_node() diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py index 65995273..02e381d5 100644 --- a/ccflow/ui/spaday/cli.py +++ b/ccflow/ui/spaday/cli.py @@ -21,7 +21,7 @@ from ccflow.utils.hydra import add_hydra_config_args, load_config, resolve_config_paths from .model import MATERIALIZE_ENDPOINT -from .registry import SELECTED_FIELD, registry_store, registry_viewer +from .registry import registry_store, registry_viewer __all__ = ("main", "registry_viewer_cli", "serve_registry") @@ -62,7 +62,7 @@ def serve_registry( title: str = "ccflow Model Registry", browser_width: int = 400, sort_children: bool = True, - address: str = "127.0.0.1", + host: str = "127.0.0.1", port: int = 8080, run: bool = True, ): @@ -74,7 +74,7 @@ def serve_registry( 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). - address, port: Interface and port uvicorn binds to (only used when ``run`` is True). + 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: @@ -115,8 +115,7 @@ async def materialize(request): def homepage(request): """Serve the page with the ``?sel=`` model preselected (used by the materialize redirect).""" selected = request.query_params.get("sel", "") - store = {**registry_store(), SELECTED_FIELD: selected} - return HTMLResponse(bootstrap(packages=_PACKAGES, styles=_STYLES, store=store, title=title, layout=layout)) + return HTMLResponse(bootstrap(packages=_PACKAGES, styles=_STYLES, store=registry_store(selected), title=title, layout=layout)) app = serve( page, @@ -132,7 +131,7 @@ def homepage(request): app.routes.insert(0, Route("/", homepage, methods=["GET"])) if run: - uvicorn.run(app, host=address, port=port) + uvicorn.run(app, host=host, port=port) return app @@ -145,7 +144,7 @@ def _get_ui_args_parser() -> argparse.ArgumentParser: add_hydra_config_args(parser) - parser.add_argument("--address", type=str, default="127.0.0.1", help="Address to bind the server to (default: 127.0.0.1).") + 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", @@ -207,7 +206,7 @@ def registry_viewer_cli( title=args.title, browser_width=args.browser_width, sort_children=args.sort_children, - address=args.address, + host=args.host, port=args.port, ) diff --git a/ccflow/ui/spaday/graph.py b/ccflow/ui/spaday/graph.py index 43b04202..7f47597a 100644 --- a/ccflow/ui/spaday/graph.py +++ b/ccflow/ui/spaday/graph.py @@ -1,23 +1,21 @@ -"""Registry dependency graph, rendered with ``spaday-dagre``. +"""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, which -this module turns into the serializable ``{nodes, edges}`` config the ``spaday-dagre`` component lays -out. Clicking a node selects that model, so the graph doubles as a navigator. +: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. Clicking a node selects it, so the graph doubles as a navigator. """ from collections.abc import Mapping -from spaday import Component, Strong, Text -from spaday.actions import SetField, event_value, field, obj -from spaday.components import Column, Row +from spaday import Component +from spaday.actions import SetField, event_value from spaday_dagre import Dagre -from spaday_webawesome import WaButton -__all__ = ("DEPENDENCY_RANKDIR_FIELD", "dependency_graph", "dependency_graph_view") +__all__ = ("dependency_edges", "model_dependency_graph", "model_dependency_view") -#: The signal-store field holding the dagre ``rankdir`` layout direction. -DEPENDENCY_RANKDIR_FIELD = "rankdir" +#: Dependencies read left to right. +_LAYOUT = {"rankdir": "LR"} def _is_pending(model) -> bool: @@ -30,76 +28,62 @@ def _normalize(name: str) -> str: return name.removeprefix("/") -def dependency_graph(leaves: list[tuple[str, object]]) -> dict: - """Build the dagre ``{nodes, edges}`` config for the registry's dependency relation. +def dependency_edges(leaves: list[tuple[str, object]]) -> dict[str, list[str]]: + """Map each leaf path to the paths it depends on. - Only edges between models present in ``leaves`` are emitted, so a dependency on something outside - the browsed registry does not introduce a dangling node. Pending (lazy) models are shown, but - contribute no edges because resolving them would instantiate the model. + 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} - nodes = [] - edges = [] - + adjacency: dict[str, list[str]] = {} for path, model in leaves: - pending = _is_pending(model) - node = {"id": path, "label": path.rsplit("/", 1)[-1]} - if pending: - node["class"] = "pending" - nodes.append(node) - if pending: - continue - 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: - edges.append({"source": path, "target": target}) - - # Deduplicate edges while preserving order (a model may reference the same dependency twice). - seen = set() - unique_edges = [] - for edge in edges: - key = (edge["source"], edge["target"]) - if key not in seen: - seen.add(key) - unique_edges.append(edge) - - return {"nodes": nodes, "edges": unique_edges} - - -def _rankdir_button(label: str, rankdir: str) -> WaButton: - return WaButton(appearance="outlined", size="s").text(label).on("click", SetField(DEPENDENCY_RANKDIR_FIELD, rankdir)) - - -def dependency_graph_view(leaves: list[tuple[str, object]], *, selected_field: str) -> Component: - """The dependency graph panel: layout controls plus the graph itself. + 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``.""" + 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.rsplit("/", 1)[-1], **({"class": "focus"} if node == path else {})} for node in order] + edges = [{"source": node, "target": target} for node in order for target in adjacency.get(node, ())] + return {"nodes": nodes, "edges": edges} + + +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. ``selected_field`` is the signal-store field a node click writes to, so the graph drives the same selection as the sidebar tree. """ - graph = dependency_graph(leaves) - - if not graph["nodes"]: - return Column(Strong("No models"), Text("This registry has no models to graph."), gap="0.5rem") - - if not graph["edges"]: - header = Text("No registry dependencies between these models.") - else: - header = Text(f"{len(graph['nodes'])} models, {len(graph['edges'])} dependencies. Click a node to inspect it.") + graph = model_dependency_graph(path, adjacency) + if len(graph["nodes"]) < 2: + return None - controls = Row( - _rankdir_button("Left to right", "LR"), - _rankdir_button("Top down", "TB"), - gap="0.5rem", - align="center", - ) - - dagre = ( - Dagre(id="dependency-graph", zoomable=True) + return ( + Dagre(zoomable=True) .prop("graph", graph) - .compute("layout", obj({"rankdir": field(DEPENDENCY_RANKDIR_FIELD)})) + .prop("layout", _LAYOUT) .on("dagre-node-click", SetField(selected_field, event_value())) - .style(display="block", min_height="60vh") + .style(display="block", height="22rem") ) - - return Column(header, controls, dagre, gap="0.75rem") diff --git a/ccflow/ui/spaday/model.py b/ccflow/ui/spaday/model.py index a9e7f616..35fea4dd 100644 --- a/ccflow/ui/spaday/model.py +++ b/ccflow/ui/spaday/model.py @@ -108,8 +108,11 @@ def model_config_view(model, path: str = "") -> Component: return Column(*children, gap="0.75rem") -def model_view(model, path: str = "") -> Component: - """A card with tabs inspecting a single ccflow model instance.""" +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") @@ -118,6 +121,8 @@ def model_view(model, path: str = "") -> Component: 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") diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py index 7677ba3f..4671155b 100644 --- a/ccflow/ui/spaday/registry.py +++ b/ccflow/ui/spaday/registry.py @@ -12,17 +12,17 @@ from spaday.actions import SetField, any_, eq, event_value, field, lit, not_ from spaday.components import App, Body, Column, Gutter, Main, Nav, Row, Show from spaday_trees import Tree -from spaday_webawesome import Tabs, WaSwitch +from spaday_webawesome import WaSwitch import ccflow -from .graph import DEPENDENCY_RANKDIR_FIELD, dependency_graph_view +from .graph import dependency_edges, model_dependency_view from .model import model_view, pending_model_view __all__ = ( "DARK_FIELD", "SELECTED_FIELD", - "VIEW_FIELD", + "SELECTED_PATHS_FIELD", "registry_leaves", "registry_store", "registry_tree", @@ -32,16 +32,21 @@ #: The signal-store field holding the selected model's registry path ("" when nothing is selected). SELECTED_FIELD = "selected" -#: The signal-store field holding the active main-area tab. -VIEW_FIELD = "view" +#: The tree's own selection, as the list of paths it takes. Seeding it makes the tree expand to reveal +#: that model, which is what keeps the tree open across the reload a materialize triggers. +SELECTED_PATHS_FIELD = "selected_paths" #: The signal-store field driving the ``wa-dark`` page theme. DARK_FIELD = "dark" -def registry_store() -> dict: +def registry_store(selected: str = "") -> dict: """The initial signal-store state the viewer is mounted with.""" - return {SELECTED_FIELD: "", VIEW_FIELD: "details", DARK_FIELD: False, DEPENDENCY_RANKDIR_FIELD: "LR"} + return { + SELECTED_FIELD: selected, + SELECTED_PATHS_FIELD: [selected] if selected else [], + DARK_FIELD: False, + } def _sorted_items(registry, sort_children: bool): @@ -80,6 +85,7 @@ def registry_tree(registry, *, sort_children: bool = True) -> Tree: # The tree virtualizes its rows, so it renders nothing unless it is given a height to fill. return ( Tree(paths=paths, id="registry-tree") + .bind("selected_paths", SELECTED_PATHS_FIELD) .on("selection-change", SetField(SELECTED_FIELD, event_value("paths.0"))) .style(display="block", flex="1", min_height="70vh") ) @@ -96,13 +102,15 @@ def _placeholder() -> Component: def _details_view(leaves: list[tuple[str, object]]) -> Component: """The per-model detail cards, one mounted at a time based on ``selected``.""" + adjacency = dependency_edges(leaves) panels: list[Component] = [Show(_placeholder(), when=not_(field(SELECTED_FIELD)))] pending_paths = [] for path, model in leaves: if isinstance(model, Mapping) and "_target_" in model: pending_paths.append(path) else: - panels.append(Show(model_view(model, path), when=eq(field(SELECTED_FIELD), lit(path)))) + dependency_view = model_dependency_view(path, adjacency, selected_field=SELECTED_FIELD) + panels.append(Show(model_view(model, path, dependency_view), when=eq(field(SELECTED_FIELD), lit(path)))) if pending_paths: pending_selected = any_(*(eq(field(SELECTED_FIELD), lit(path)) for path in pending_paths)) panels.append(Show(pending_model_view(field(SELECTED_FIELD)), when=pending_selected)) @@ -110,7 +118,7 @@ def _details_view(leaves: list[tuple[str, object]]) -> Component: 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 details or the graph.""" + """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( @@ -119,13 +127,9 @@ def registry_viewer(registry, *, title: str = "ccflow Model Registry", browser_w gap="0.75rem", ) - tabs = Tabs(active="details").bind("active", VIEW_FIELD, mode="two-way") - tabs.tab("Details", _details_view(leaves), name="details") - tabs.tab("Dependencies", dependency_graph_view(leaves, selected_field=SELECTED_FIELD), name="dependencies") - theme = Row(WaSwitch().text("Dark").bind("checked", DARK_FIELD, mode="two-way"), gap="0.5rem", align="center") return App( Nav(Row(Strong(title), theme, gap="1rem", align="center", justify="space-between")), - Body(sidebar, Main(tabs)), + Body(sidebar, Main(_details_view(leaves))), ).bind_root_class("wa-dark", DARK_FIELD) From 315fc53c1313a1b105ad799003834f9ce1b1f1c1 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:44:41 -0400 Subject: [PATCH 8/8] Show full paths and a node context menu in the dependency graph Label nodes with the full registry path so the hierarchy an entry comes from is visible. Point edges from a dependency to the model that uses it, so the chain reads in dataflow order and the inspected model is the last node. Replace navigate-on-click with a context menu: a pointer event on a node opens a popup naming it, and its Open model action both selects the model and reveals it in the sidebar tree. Each node carries its own menu body with literal actions, because the action DSL cannot build the single-element list the tree's selected_paths needs from a store value. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- ccflow/tests/ui/spaday/test_graph.py | 63 ++++++++++++++++------ ccflow/ui/spaday/graph.py | 78 +++++++++++++++++++++++----- ccflow/ui/spaday/registry.py | 5 +- 3 files changed, 117 insertions(+), 29 deletions(-) diff --git a/ccflow/tests/ui/spaday/test_graph.py b/ccflow/tests/ui/spaday/test_graph.py index 3fc95026..84a8a620 100644 --- a/ccflow/tests/ui/spaday/test_graph.py +++ b/ccflow/tests/ui/spaday/test_graph.py @@ -9,6 +9,10 @@ from .utils import event_action, nodes_with_tag, prop_value +def _view(path, adjacency): + return model_dependency_view(path, adjacency, selected_field="selected", selected_paths_field="selected_paths") + + class Leaf(BaseModel): """A model with no registry dependencies.""" @@ -86,7 +90,8 @@ def test_graph_is_local_to_the_model(self): 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"] - assert graph["edges"] == [{"source": "holder", "target": "sub/alpha"}] + # The edge points from the dependency into the model that uses it. + assert graph["edges"] == [{"source": "sub/alpha", "target": "holder"}] def test_focus_node_is_marked(self): graph = model_dependency_graph("holder", {"holder": ["sub/alpha"], "sub/alpha": []}) @@ -97,9 +102,10 @@ 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": "outer", "target": "holder"}, - {"source": "holder", "target": "leaf"}, + {"source": "holder", "target": "outer"}, + {"source": "leaf", "target": "holder"}, ] def test_cycles_terminate(self): @@ -107,33 +113,60 @@ def test_cycles_terminate(self): 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 model_dependency_view("sub/alpha", adjacency, selected_field="selected") is None + assert _view("sub/alpha", adjacency) is None def test_renders_dagre_component(self): adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) - node = model_dependency_view("holder", adjacency, selected_field="selected").to_node() + 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": "holder", "target": "sub/alpha"}] + assert prop_value(dagre[0], "graph")["edges"] == [{"source": "sub/alpha", "target": "holder"}] + + def test_node_click_opens_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 ("click", "contextmenu"): + action = event_action(dagre, event) + # Guarded on the node id, so a click on blank canvas opens nothing. + assert action["kind"] == "if" + assert action["cond"] == {"expr": "event-prop", "path": "target.parentElement.dataset.nodeId"} + assert action["then"]["kind"] == "seq" + + def test_menu_has_a_body_per_node(self): + adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) + node = _view("holder", adjacency).to_node() + assert nodes_with_tag(node, "spa-popup") + shows = nodes_with_tag(node, "spa-show") + assert len(shows) == 2 # one per node in the graph - def test_node_click_sets_selection(self): + def test_open_model_sets_selection_and_reveals_in_tree(self): adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) - dagre = model_dependency_view("holder", adjacency, selected_field="selected").to_node() - action = event_action(dagre, "dagre-node-click") - assert action["kind"] == "set-field" - assert action["field"] == "selected" - # The node-click detail is the node id, i.e. the registry path. - assert action["value"] == {"expr": "event"} + node = _view("holder", adjacency).to_node() + buttons = [n for n in nodes_with_tag(node, "wa-button") if "click" in n.get("events", {})] + writes = {} + for button in buttons: + for action in button["events"]["click"]["actions"]: + if action["kind"] == "set-field": + writes.setdefault(action["field"], []).append(action["value"]["value"]) + # Literal per node, because the DSL cannot build the tree's single-element path list. + assert set(writes["selected"]) == {"holder", "sub/alpha"} + assert ["holder"] in writes["selected_paths"] + assert ["sub/alpha"] in writes["selected_paths"] def test_layout_is_left_to_right(self): adjacency = dependency_edges(registry_leaves(_registry_with_dependency())) - dagre = model_dependency_view("holder", adjacency, selected_field="selected").to_node() + 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(model_dependency_view("holder", adjacency, selected_field="selected").to_node()) + validate(_view("holder", adjacency).to_node()) diff --git a/ccflow/ui/spaday/graph.py b/ccflow/ui/spaday/graph.py index 7f47597a..c8e80b11 100644 --- a/ccflow/ui/spaday/graph.py +++ b/ccflow/ui/spaday/graph.py @@ -8,15 +8,26 @@ from collections.abc import Mapping -from spaday import Component -from spaday.actions import SetField, event_value +from spaday import Component, Strong, element +from spaday.actions import If, Sequence, SetField, by_id, close_popup, eq, event_prop, field, lit, open_popup +from spaday.components import Column, Popup, Show from spaday_dagre import Dagre +from spaday_webawesome import WaButton, WaCard, WaDivider -__all__ = ("dependency_edges", "model_dependency_graph", "model_dependency_view") +__all__ = ("MENU_PATH_FIELD", "dependency_edges", "model_dependency_graph", "model_dependency_view") -#: Dependencies read left to right. +#: Dependencies flow left to right into the model. _LAYOUT = {"rankdir": "LR"} +#: The signal-store field holding the node a context menu was opened on. +MENU_PATH_FIELD = "menu_path" + +_MENU_ID = "dependency-node-menu" + +#: dagre draws each node as ```` wrapping a ```` and a ````, so a pointer +#: event lands on a child and the node is one level up. +_EVENT_NODE_ID = "target.parentElement.dataset.nodeId" + def _is_pending(model) -> bool: """Whether the entry is an un-instantiated (lazy) registry config rather than a model.""" @@ -50,7 +61,11 @@ def dependency_edges(leaves: list[tuple[str, object]]) -> dict[str, list[str]]: def model_dependency_graph(path: str, adjacency: dict[str, list[str]]) -> dict: - """The dagre ``{nodes, edges}`` config for everything reachable from ``path``.""" + """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": []} @@ -65,25 +80,64 @@ def model_dependency_graph(path: str, adjacency: dict[str, list[str]]) -> dict: seen.add(target) queue.append(target) - nodes = [{"id": node, "label": node.rsplit("/", 1)[-1], **({"class": "focus"} if node == path else {})} for node in order] - edges = [{"source": node, "target": target} for node in order for target in adjacency.get(node, ())] + nodes = [{"id": node, "label": node, **({"class": "focus"} if node == path else {})} 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 model_dependency_view(path: str, adjacency: dict[str, list[str]], *, selected_field: str) -> Component | None: +def _node_menu(paths: list[str], *, selected_field: str, selected_paths_field: str) -> Popup: + """The context menu shown for a graph node. + + One body per node, gated on which node was clicked, so each carries literal actions: the action DSL + has no way to build the single-element list the tree's ``selected_paths`` needs from a store value. + """ + entries = [] + for path in paths: + open_model = Sequence( + SetField(selected_field, lit(path)), + SetField(selected_paths_field, lit([path])), + close_popup(by_id(_MENU_ID)), + ) + entries.append( + Show( + Column( + Strong(path.rsplit("/", 1)[-1]), + element("code").text(path).style(font_size="0.8em", overflow_wrap="anywhere"), + WaDivider(), + WaButton(appearance="filled", size="s").text("Open model").on("click", open_model), + gap="0.4rem", + ), + when=eq(field(MENU_PATH_FIELD), lit(path)), + ) + ) + + card = WaCard(appearance="outlined").child(Column(*entries, gap="0.4rem")).style(min_width="14rem") + return Popup(card, id=_MENU_ID) + + +def model_dependency_view(path: str, adjacency: dict[str, list[str]], *, selected_field: str, selected_paths_field: str) -> Component | None: """The model's dependency graph, or ``None`` when it depends on nothing worth drawing. - ``selected_field`` is the signal-store field a node click writes to, so the graph drives the same - selection as the sidebar tree. + A node opens a context menu rather than navigating on the spot; choosing "Open model" from it sets + ``selected_field`` and reveals the model in the sidebar tree via ``selected_paths_field``. """ graph = model_dependency_graph(path, adjacency) if len(graph["nodes"]) < 2: return None - return ( + # The node id lives on the group above the clicked shape, and is absent when the pointer misses a + # node, which is what keeps the menu from opening over blank canvas. + node_id = event_prop(_EVENT_NODE_ID) + show_menu = If(node_id, open_popup(by_id(_MENU_ID), context_field=MENU_PATH_FIELD, context=node_id)) + + dagre = ( Dagre(zoomable=True) .prop("graph", graph) .prop("layout", _LAYOUT) - .on("dagre-node-click", SetField(selected_field, event_value())) + .on("click", show_menu) + .on("contextmenu", show_menu) .style(display="block", height="22rem") ) + + paths = [node["id"] for node in graph["nodes"]] + return Column(dagre, _node_menu(paths, selected_field=selected_field, selected_paths_field=selected_paths_field), gap="0") diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py index 4671155b..1fe8edc5 100644 --- a/ccflow/ui/spaday/registry.py +++ b/ccflow/ui/spaday/registry.py @@ -16,7 +16,7 @@ import ccflow -from .graph import dependency_edges, model_dependency_view +from .graph import MENU_PATH_FIELD, dependency_edges, model_dependency_view from .model import model_view, pending_model_view __all__ = ( @@ -45,6 +45,7 @@ def registry_store(selected: str = "") -> dict: return { SELECTED_FIELD: selected, SELECTED_PATHS_FIELD: [selected] if selected else [], + MENU_PATH_FIELD: "", DARK_FIELD: False, } @@ -109,7 +110,7 @@ def _details_view(leaves: list[tuple[str, object]]) -> Component: if isinstance(model, Mapping) and "_target_" in model: pending_paths.append(path) else: - dependency_view = model_dependency_view(path, adjacency, selected_field=SELECTED_FIELD) + dependency_view = model_dependency_view(path, adjacency, selected_field=SELECTED_FIELD, selected_paths_field=SELECTED_PATHS_FIELD) panels.append(Show(model_view(model, path, dependency_view), when=eq(field(SELECTED_FIELD), lit(path)))) if pending_paths: pending_selected = any_(*(eq(field(SELECTED_FIELD), lit(path)) for path in pending_paths))