From e44c6374da679c1d47a489ff635ff785e607149d 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/5] 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 7a831eb6..b0588c30 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 7125c533..961a3a24 100644 --- a/ccflow/utils/tokenize.py +++ b/ccflow/utils/tokenize.py @@ -409,11 +409,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 a4c441b8..41ec2545 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 77333e2315f7f993126c7460d3e6f82342400152 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/5] 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 5561e6fb85917aa805841af937006a90c65c3b07 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/5] 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 7f4dc5b7b411a437342e73937f8b83c8c48720ed 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/5] 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 41ec2545..8ecf4d04 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 bf691e8fc89bf9bfd6d4bbd7ab92eeda5a3d548c 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/5] 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)),