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..da2951ce 100644 --- a/ccflow/flow_model.py +++ b/ccflow/flow_model.py @@ -118,11 +118,6 @@ _AnyCallable = Callable[..., Any] -# --------------------------------------------------------------------------- -# Internal data structures -# --------------------------------------------------------------------------- - - class _UnsetFlowInput: def __repr__(self) -> str: return "" @@ -393,11 +388,6 @@ class _LocalFlowModelPicklePayload(NamedTuple): factory_kwargs: dict[str, Any] -# --------------------------------------------------------------------------- -# Small value helpers -# --------------------------------------------------------------------------- - - def _context_values(context: ContextBase) -> dict[str, Any]: return dict(context) @@ -469,11 +459,6 @@ def _concrete_context_type(context_type: Any) -> type[ContextBase] | None: return None -# --------------------------------------------------------------------------- -# Type coercion, lazy thunks, and registry references -# --------------------------------------------------------------------------- - - def _remember_type_adapter(cache: "OrderedDict[Any, Any]", key: Any, value: Any) -> Any: cache[key] = value cache.move_to_end(key) @@ -670,11 +655,6 @@ def _ensure_named_python_function(fn: _AnyCallable, *, decorator_name: str) -> N raise TypeError(f"{decorator_name} only supports named Python functions.") -# --------------------------------------------------------------------------- -# Context-transform serialization and generated-model persistence -# --------------------------------------------------------------------------- - - def _serialize_context_transform_config(config: _FlowModelConfig) -> str: payload = cloudpickle.dumps(_serialize_flow_model_config(config), protocol=5) return b64encode(payload).decode("ascii") @@ -867,11 +847,6 @@ def _register_generated_model_class(config: _FlowModelConfig, generated_cls: typ ) -# --------------------------------------------------------------------------- -# Runtime context contracts and dependency projection -# --------------------------------------------------------------------------- - - def _runtime_context_for_model(model: CallableModel, values: dict[str, Any]) -> ContextBase: """Build the runtime context object expected by ``model`` from raw values.""" @@ -1026,11 +1001,6 @@ def _missing_regular_param_names(model: "_GeneratedFlowModelBase", config: _Flow return missing -# --------------------------------------------------------------------------- -# Generated model input resolution -# --------------------------------------------------------------------------- - - def _resolve_regular_param_value(model: "_GeneratedFlowModelBase", param: _FlowModelParam, context: ContextBase) -> Any: value = getattr(model, param.name, _UNSET_FLOW_INPUT) if _is_unset_flow_input(value): @@ -1470,10 +1440,6 @@ def _coerce_model_context_value(model: CallableModel, field_name: str, value: An return _coerce_value(field_name, value, contract.input_types[field_name], source) -# --------------------------------------------------------------------------- -# Effective identity helpers -# --------------------------------------------------------------------------- - # Identity terms used below: # - config identity: stable hash of the analyzed Flow.model contract, fixed at # generated-class construction time and carried through local restore. @@ -1843,11 +1809,6 @@ def _generated_model_identity_payload( ) -# --------------------------------------------------------------------------- -# Static binding resolution and with_context normalization -# --------------------------------------------------------------------------- - - def _resolved_static_contextual_values( model: "_GeneratedFlowModelBase", config: _FlowModelConfig, @@ -2104,11 +2065,6 @@ def _normalize_with_context(model: CallableModel, patches: tuple[Any, ...], fiel return _validate_static_context_spec_declared_context(model, context_spec) -# --------------------------------------------------------------------------- -# Bound context application and compute context construction -# --------------------------------------------------------------------------- - - def _context_from_values_preserving_private_state(context: ContextBase, values: dict[str, Any]) -> ContextBase: """Validate updated public values while preserving private context state.""" @@ -2537,11 +2493,6 @@ def _recursive_dependency_specs_for_flow( active.remove(model_id) -# --------------------------------------------------------------------------- -# model.flow API and BoundModel wrapper -# --------------------------------------------------------------------------- - - class FlowAPI: """API namespace exposed as ``model.flow``. @@ -3158,11 +3109,6 @@ def _evaluation_identity_payload( return _generated_model_identity_payload(self, context) -# --------------------------------------------------------------------------- -# Generated model method builders and decorators -# --------------------------------------------------------------------------- - - def _make_call_impl(config: _FlowModelConfig) -> _AnyCallable: """Create the ``__call__`` implementation for one generated model class.""" diff --git a/ccflow/tests/test_base.py b/ccflow/tests/test_base.py index 2f3c475c..132234f6 100644 --- a/ccflow/tests/test_base.py +++ b/ccflow/tests/test_base.py @@ -175,8 +175,8 @@ def test_widget(self): def test_panel(self): from ccflow import ModelRegistry - from ccflow.ui.model import ModelViewer - from ccflow.ui.registry import ModelRegistryViewer + from ccflow.ui.panel.model import ModelViewer + from ccflow.ui.panel.registry import ModelRegistryViewer m = ModelA(x="foo") panel_obj = m.__panel__() diff --git a/ccflow/tests/ui/panel/__init__.py b/ccflow/tests/ui/panel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ccflow/tests/ui/test_cli.py b/ccflow/tests/ui/panel/test_cli.py similarity index 94% rename from ccflow/tests/ui/test_cli.py rename to ccflow/tests/ui/panel/test_cli.py index c399fb73..45353553 100644 --- a/ccflow/tests/ui/test_cli.py +++ b/ccflow/tests/ui/panel/test_cli.py @@ -1,6 +1,6 @@ -"""Unit tests for ccflow.ui.cli module.""" +"""Unit tests for ccflow.ui.panel.cli module.""" -from ccflow.ui.cli import _get_ui_args_parser +from ccflow.ui.panel.cli import _get_ui_args_parser class TestGetUIArgsParser: diff --git a/ccflow/tests/ui/test_model.py b/ccflow/tests/ui/panel/test_model.py similarity index 99% rename from ccflow/tests/ui/test_model.py rename to ccflow/tests/ui/panel/test_model.py index 043dbd63..7cc15687 100644 --- a/ccflow/tests/ui/test_model.py +++ b/ccflow/tests/ui/panel/test_model.py @@ -1,10 +1,10 @@ -"""Unit tests for ccflow.ui.model module.""" +"""Unit tests for ccflow.ui.panel.model module.""" import panel as pn from pydantic import Field from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, MetaData, ModelRegistry -from ccflow.ui.model import ModelConfigViewer, ModelTypeViewer, ModelViewer +from ccflow.ui.panel.model import ModelConfigViewer, ModelTypeViewer, ModelViewer from .utils import find_components_by_type diff --git a/ccflow/tests/ui/test_registry.py b/ccflow/tests/ui/panel/test_registry.py similarity index 99% rename from ccflow/tests/ui/test_registry.py rename to ccflow/tests/ui/panel/test_registry.py index d9b1dd8a..a5f0f744 100644 --- a/ccflow/tests/ui/test_registry.py +++ b/ccflow/tests/ui/panel/test_registry.py @@ -1,11 +1,11 @@ -"""Unit tests for ccflow.ui.registry module.""" +"""Unit tests for ccflow.ui.panel.registry module.""" from unittest import mock import panel as pn from ccflow import BaseModel, ModelRegistry -from ccflow.ui.registry import ModelRegistryViewer, RegistryBrowser +from ccflow.ui.panel.registry import ModelRegistryViewer, RegistryBrowser from .utils import find_components_by_type diff --git a/ccflow/tests/ui/utils.py b/ccflow/tests/ui/panel/utils.py similarity index 100% rename from ccflow/tests/ui/utils.py rename to ccflow/tests/ui/panel/utils.py diff --git a/ccflow/tests/ui/spaday/__init__.py b/ccflow/tests/ui/spaday/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ccflow/tests/ui/spaday/test_cli.py b/ccflow/tests/ui/spaday/test_cli.py new file mode 100644 index 00000000..a02f3543 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_cli.py @@ -0,0 +1,146 @@ +"""Unit tests for ccflow.ui.spaday.cli module.""" + +import importlib +from pathlib import Path + +import pytest +from spaday.bootstrap import _ASSETS, bundles_dir + +from ccflow import BaseModel, LazyRegistry, 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 + + def test_materialize_route_present(self): + registry = ModelRegistry(name="test") + registry.add("m", SimpleModel(name="m")) + app = serve_registry(registry, run=False) + paths = {getattr(route, "path", None) for route in app.routes} + assert "/materialize" in paths + + @pytest.mark.parametrize("module", ["ccflow.ui.cli", "ccflow.ui.model", "ccflow.ui.registry"]) + def test_panel_module_compatibility_imports(self, module): + assert importlib.import_module(module) + + +class TestMaterializeEndpoint: + def _lazy_registry(self): + return LazyRegistry( + name="root", + group={"model": {"_target_": "ccflow.tests.ui.spaday.test_cli.SimpleModel", "name": "pending"}}, + ) + + def test_materialize_instantiates_pending_model(self, mocker): + starlette_testclient = pytest.importorskip("starlette.testclient") + from ccflow.ui.spaday import cli + + to_thread = mocker.spy(cli.asyncio, "to_thread") + registry = self._lazy_registry() + app = serve_registry(registry, run=False) + assert not registry["group"].is_loaded("model") + + client = starlette_testclient.TestClient(app) + response = client.post("/materialize", 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") + registry = self._lazy_registry() + app = serve_registry(registry, run=False) + + client = starlette_testclient.TestClient(app) + 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() + 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): + # 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..57a5aee6 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_model.py @@ -0,0 +1,135 @@ +"""Unit tests for ccflow.ui.spaday.model module.""" + +from pydantic import Field +from spaday.actions import field +from spaday.validate import validate + +from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, ModelRegistry +from ccflow.ui.spaday.model import MATERIALIZE_ENDPOINT, model_config_view, model_type_view, model_view, pending_model_view + +from .utils import all_text, nodes_with_tag, prop_str, text_of + + +class SimpleModel(BaseModel): + """A documented test model.""" + + name: str = Field(description="the display name") + value: int = 0 + + +class Ctx(ContextBase): + """A test context.""" + + a: int = 1 + + +class MyCallable(CallableModel): + """A callable test model.""" + + x: str = "hi" + + @property + def context_type(self) -> type[Ctx]: + return Ctx + + @Flow.call + def __call__(self, context: Ctx) -> GenericResult: + return GenericResult(value=self.x) + + +class TestModelTypeView: + def test_none_is_empty(self): + node = model_type_view(None).to_node() + assert node["tag"] == "spa-stack" + assert node.get("slots", {}) == {} + + def test_type_name_in_badge(self): + node = model_type_view(SimpleModel).to_node() + badges = nodes_with_tag(node, "wa-badge") + assert any(text_of(b) == "SimpleModel" for b in badges) + + def test_lists_fields(self): + text = " ".join(all_text(model_type_view(SimpleModel).to_node())) + assert "name" in text + assert "value" in text + + def test_includes_field_description(self): + text = " ".join(all_text(model_type_view(SimpleModel).to_node())) + assert "the display name" in text + + def test_includes_docstring(self): + text = " ".join(all_text(model_type_view(SimpleModel).to_node())) + assert "A documented test model." in text + + +class TestModelConfigView: + def test_includes_path(self): + model = SimpleModel(name="m") + text = " ".join(all_text(model_config_view(model, "reg/m").to_node())) + assert "reg/m" in text + + def test_no_metadata_message_when_empty(self): + model = SimpleModel(name="m") + text = " ".join(all_text(model_config_view(model).to_node())) + assert "No additional metadata." in text + + def test_dependencies_rendered(self): + registry = ModelRegistry(name="test") + dep = SimpleModel(name="dep") + registry.add("dep", dep) + holder = MyCallable() + registry.add("holder", holder) + # A model that depends on another shows its registry dependencies (if any). + node = model_config_view(holder, "holder").to_node() + assert node["tag"] == "spa-stack" + + +class TestModelView: + def test_is_card(self): + node = model_view(SimpleModel(name="m"), "m").to_node() + assert node["tag"] == "wa-card" + + def test_has_core_tabs(self): + text = all_text(model_view(SimpleModel(name="m"), "m").to_node()) + assert "Summary" in text + assert "Model Type" in text + assert "Parameters" in text + + def test_plain_model_has_no_callable_tabs(self): + text = all_text(model_view(SimpleModel(name="m"), "m").to_node()) + assert "Context Type" not in text + assert "Result Type" not in text + + def test_callable_model_has_callable_tabs(self): + text = all_text(model_view(MyCallable(), "m").to_node()) + assert "Context Type" in text + assert "Result Type" in text + + def test_parameters_include_field_values(self): + text = " ".join(all_text(model_view(SimpleModel(name="widget", value=7), "m").to_node())) + assert "widget" in text + + def test_validates(self): + validate(model_view(MyCallable(), "m").to_node()) + + +class TestPendingModelView: + def test_is_card(self): + node = pending_model_view("group/model").to_node() + assert node["tag"] == "wa-card" + + def test_shows_pending_badge_and_path(self): + text = " ".join(all_text(pending_model_view("group/model").to_node())) + assert "Pending" in text + assert "group/model" in text + + def test_materialize_button_links_to_endpoint_with_path(self): + 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(field("selected")).to_node()) diff --git a/ccflow/tests/ui/spaday/test_registry.py b/ccflow/tests/ui/spaday/test_registry.py new file mode 100644 index 00000000..b685d661 --- /dev/null +++ b/ccflow/tests/ui/spaday/test_registry.py @@ -0,0 +1,159 @@ +"""Unit tests for ccflow.ui.spaday.registry module.""" + +from spaday.validate import validate + +from ccflow import BaseModel, LazyRegistry, ModelRegistry +from ccflow.ui.spaday.registry import ( + 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")] == [""] + + def test_lazy_registry_renders_without_materializing_models(self): + lazy = LazyRegistry( + name="lazy", + group={ + "model": { + "_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel", + "name": "pending", + }, + "other": { + "_target_": "ccflow.tests.ui.spaday.test_registry.SimpleModel", + "name": "other", + }, + }, + ) + + node = registry_viewer(lazy).to_node() + + assert not lazy["group"].is_loaded("model") + assert not lazy["group"].is_loaded("other") + # 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/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..29d62b42 100644 --- a/ccflow/ui/__init__.py +++ b/ccflow/ui/__init__.py @@ -1,3 +1 @@ -from .cli import * -from .model import * -from .registry import * +from .panel import * diff --git a/ccflow/ui/cli.py b/ccflow/ui/cli.py index 7d39bb7e..b7657617 100644 --- a/ccflow/ui/cli.py +++ b/ccflow/ui/cli.py @@ -1,99 +1,3 @@ -"""CLI for serving ModelRegistryViewer as a Panel application.""" +"""Compatibility imports for the Panel UI CLI.""" -import argparse -from collections.abc import Callable - -import panel as pn - -from ccflow import ModelRegistry -from ccflow.utils.hydra import add_hydra_config_args, add_panel_server_args, load_config, resolve_config_paths - -from .registry import ModelRegistryViewer - -__all__ = ("registry_viewer_cli",) - - -def _get_ui_args_parser() -> argparse.ArgumentParser: - """Create argument parser with UI server configuration options.""" - parser = argparse.ArgumentParser( - add_help=True, - description="Serve ModelRegistryViewer as a Panel application", - ) - - # Standard hydra config loading arguments - add_hydra_config_args(parser) - - # Standard Panel server arguments - add_panel_server_args(parser) - - # Viewer-specific arguments - parser.add_argument( - "--browser-width", - type=int, - default=400, - help="Initial width of the registry browser sidebar (default: 400). User can drag to resize.", - ) - parser.add_argument( - "--title", - type=str, - default="ccflow Model Registry", - help="Title shown in the page header (default: 'ccflow Model Registry')", - ) - - return parser - - -def registry_viewer_cli( - config_path: str = "", - config_name: str = "", - hydra_main: Callable | None = None, -): - """CLI entry point for serving ModelRegistryViewer. - - Parameters - ---------- - config_path - The config_path specified in hydra.main() - config_name - The config_name specified in hydra.main() - hydra_main - The function decorated with hydra.main(). Used to resolve config_path - relative to the decorated function's file location. - """ - parser = _get_ui_args_parser() - args = parser.parse_args() - - # Resolve config paths using shared helper - root_config_dir, root_config_name = resolve_config_paths(args, config_path, config_name, hydra_main) - - # Load config using hydra utilities - result = load_config( - root_config_dir=root_config_dir, - root_config_name=root_config_name, - config_dir=args.config_dir, - config_name=args.config_dir_config_name, - overrides=args.overrides, - basepath=args.basepath, - ) - - # Load registry from config - registry = ModelRegistry.root() - registry.load_config(cfg=result.cfg, overwrite=True) - - # Create app factory for per-session instances - def create_app(): - viewer = ModelRegistryViewer( - registry, - browser_width=args.browser_width, - title=args.title, - ) - return viewer.__panel__() - - # Serve the panel app (callable = fresh instance per session) - pn.serve( - create_app, - address=args.address, - port=args.port, - allow_websocket_origin=args.allow_websocket_origin, - show=args.show, - ) +from .panel.cli import * diff --git a/ccflow/ui/model.py b/ccflow/ui/model.py index 95b4d126..59a6365b 100644 --- a/ccflow/ui/model.py +++ b/ccflow/ui/model.py @@ -1,280 +1,3 @@ -import html +"""Compatibility imports for Panel model views.""" -import panel as pn -import panel_material_ui # noqa: F401 Must be imported like this to register the extension -import panel_material_ui as pmui -import param -from pydantic._internal._repr import display_as_type - -import ccflow - -pn.extension() -pn.extension("jsoneditor") - - -__all__ = ("ModelConfigViewer", "ModelTypeViewer", "ModelViewer") - - -_FIELD_STYLES = { - "name": "color:#0550ae;", # blue - "type": "color:#8250df;", # purple - "description": "color:#57606a;font-style:italic;", # muted gray -} - - -class ModelTypeViewer(param.Parameterized): - """ - Displays type name, class docstring, and fields for a Pydantic model type. - """ - - model_type = param.Parameter(default=None) - - def __init__(self, **params): - super().__init__(**params) - - self._pane = pn.pane.HTML("", sizing_mode="stretch_width") - self._layout = pn.Column( - self._pane, - sizing_mode="stretch_width", - ) - - self.param.watch(self._on_type_change, "model_type") - - def __panel__(self): - return self._layout - - def _on_type_change(self, event): - model_cls = event.new - if model_cls is None: - self._pane.object = "" - return - - type_name = display_as_type(model_cls) - - # Class documentation - docs = (model_cls.__doc__ or "").strip() - docs_html = "" - if docs: - escaped = html.escape(docs).replace("\n", "
") - docs_html = f""" -
-
Class Documentation:
-
{escaped}
-
- """ - - # Fields - fields = getattr(model_cls, "model_fields", {}) - field_items = [] - - for name, field in fields.items(): - field_type = display_as_type(field.annotation) - desc = field.description or "" - name_html = f'{html.escape(name)}' - type_html = f'{html.escape(field_type)}' - desc_html = f' — {html.escape(desc)}' if desc else "" - field_items.append(f'
  • {name_html} ({type_html}){desc_html}
  • ') - - fields_html = "" - if field_items: - fields_html = f""" -
    -
    Fields:
    - -
    - """ - - self._pane.object = f""" -
    -
    - Type: - {html.escape(type_name)} -
    - {docs_html} - {fields_html} -
    - """ - - -class ModelConfigViewer(param.Parameterized): - """ - Displays instance-level metadata (description + dependencies). - """ - - model = param.Parameter(default=None) - - def __init__(self, **params): - super().__init__(**params) - - self.model_path = "" - self._metadata = pn.pane.HTML("", sizing_mode="stretch_width") - - self._layout = pn.Column( - self._metadata, - sizing_mode="stretch_width", - ) - - self.param.watch(self._on_model_change, "model") - - def __panel__(self): - return self._layout - - def _render_dependencies(self, model): - deps = model.get_registry_dependencies() - if not deps: - return "" - - # Collect all values, deduplicate, and sort - all_paths = [] - for group in deps: - if len(group) == 1: - all_paths.append(group[0]) - else: - all_paths.append(" | ".join(group)) - - # Unique elements, sorted - rows = sorted(set(all_paths)) - - items = "".join(f'
  • {html.escape(row)}
  • ' for row in rows) - - return f""" -
    -
    - Registry Dependencies -
    - -
    - """ - - def _on_model_change(self, event): - model = event.new - if model is None: - self._metadata.object = "" - return - - path_html = "" - if self.model_path: - path_html = f""" -
    -
    Registry Path
    - {html.escape(self.model_path)} -
    - """ - - description = model.meta.description.strip() if hasattr(model, "meta") and model.meta.description else "" - - desc_html = "" - if description: - try: - import bleach - - description = bleach.linkify(html.escape(description)) - except ImportError: - description = html.escape(description) - desc_html = f""" -
    -
    Instance Description
    -
    {description}
    -
    - """ - - self._metadata.object = path_html + desc_html + self._render_dependencies(model) - - -class ModelViewer(param.Parameterized): - """ - Displays a tabbed view of a ccflow Model instance, including description, registry dependencies, docstrings and json representation. - """ - - model = param.Parameter(default=None) - - def __init__(self, **params): - super().__init__(**params) - - self.model_path = "" - - # Sub-viewers (no JSONEditor inside) - self._config_viewer = ModelConfigViewer() - self._type_viewer = ModelTypeViewer() - self._context_type_viewer = ModelTypeViewer() - self._result_type_viewer = ModelTypeViewer() - - # Material UI Tabs (metadata only) - self._tabs = pmui.Tabs( - active=0, - sizing_mode="stretch_width", - ) - - # JSON editor (stable, but hidden until a model is selected) - self._json_editor = pn.widgets.JSONEditor( - value={}, - mode="view", - menu=False, - sizing_mode="stretch_width", - min_width=400, - ) - - self._json_container = pn.Column( - "## Parameters", - self._json_editor, - visible=False, # hidden initially - sizing_mode="stretch_width", - ) - - self._layout = pn.Column( - "## Model Viewer", - self._tabs, - pn.Spacer(height=12), - self._json_container, - sizing_mode="stretch_width", - ) - - self.param.watch(self._on_model_change, "model") - - def __panel__(self): - return self._layout - - def _on_model_change(self, event): - model = event.new - self._tabs.clear() - - if model is None: - # hide JSON editor if no model - self._json_editor.value = {} - self._json_container.visible = False - return - - # Config tab - self._config_viewer.model_path = self.model_path - self._config_viewer.model = model - self._tabs.append(("Summary", self._config_viewer)) - - # Model Type tab - self._type_viewer.model_type = type(model) - self._tabs.append(("Model Type", self._type_viewer)) - - # CallableModel extras - if isinstance(model, ccflow.CallableModel): - self._context_type_viewer.model_type = model.context_type - self._tabs.append(("Context Type", self._context_type_viewer)) - - self._result_type_viewer.model_type = model.result_type - self._tabs.append(("Result Type", self._result_type_viewer)) - - # Default to Config tab - self._tabs.active = 0 - - # Update & show JSONEditor - self._json_editor.value = model.__pydantic_serializer__.to_python(model, fallback=str, mode="json") - self._json_container.visible = True +from .panel.model import * diff --git a/ccflow/ui/panel/__init__.py b/ccflow/ui/panel/__init__.py new file mode 100644 index 00000000..417aeab3 --- /dev/null +++ b/ccflow/ui/panel/__init__.py @@ -0,0 +1,3 @@ +from .cli import * +from .model import * +from .registry import * diff --git a/ccflow/ui/panel/cli.py b/ccflow/ui/panel/cli.py new file mode 100644 index 00000000..4991a8ad --- /dev/null +++ b/ccflow/ui/panel/cli.py @@ -0,0 +1,95 @@ +"""CLI for serving ModelRegistryViewer as a Panel application.""" + +import argparse +from collections.abc import Callable + +import panel as pn + +from ccflow import ModelRegistry +from ccflow.utils.hydra import add_hydra_config_args, add_panel_server_args, load_config, resolve_config_paths + +from .registry import ModelRegistryViewer + +__all__ = ("registry_viewer_cli",) + + +def _get_ui_args_parser() -> argparse.ArgumentParser: + """Create argument parser with UI server configuration options.""" + parser = argparse.ArgumentParser( + add_help=True, + description="Serve ModelRegistryViewer as a Panel application", + ) + + # Standard hydra config loading arguments + add_hydra_config_args(parser) + + # Standard Panel server arguments + add_panel_server_args(parser) + + # Viewer-specific arguments + parser.add_argument( + "--browser-width", + type=int, + default=400, + help="Initial width of the registry browser sidebar (default: 400). User can drag to resize.", + ) + parser.add_argument( + "--title", + type=str, + default="ccflow Model Registry", + help="Title shown in the page header (default: 'ccflow Model Registry')", + ) + + return parser + + +def registry_viewer_cli( + config_path: str = "", + config_name: str = "", + hydra_main: Callable | None = None, +): + """CLI entry point for serving ModelRegistryViewer. + + Args: + config_path: The config_path specified in hydra.main() + config_name: The config_name specified in hydra.main() + hydra_main: The function decorated with hydra.main(). Used to resolve config_path + relative to the decorated function's file location. + """ + parser = _get_ui_args_parser() + args = parser.parse_args() + + # Resolve config paths using shared helper + root_config_dir, root_config_name = resolve_config_paths(args, config_path, config_name, hydra_main) + + # Load config using hydra utilities + result = load_config( + root_config_dir=root_config_dir, + root_config_name=root_config_name, + config_dir=args.config_dir, + config_name=args.config_dir_config_name, + overrides=args.overrides, + basepath=args.basepath, + ) + + # Load registry from config + registry = ModelRegistry.root() + registry.load_config(cfg=result.cfg, overwrite=True) + + # Create app factory for per-session instances + def create_app(): + viewer = ModelRegistryViewer( + registry, + browser_width=args.browser_width, + title=args.title, + ) + return viewer.__panel__() + + # Serve the panel app (callable = fresh instance per session) + pn.serve( + create_app, + address=args.address, + port=args.port, + allow_websocket_origin=args.allow_websocket_origin, + show=args.show, + ) diff --git a/ccflow/ui/panel/model.py b/ccflow/ui/panel/model.py new file mode 100644 index 00000000..95b4d126 --- /dev/null +++ b/ccflow/ui/panel/model.py @@ -0,0 +1,280 @@ +import html + +import panel as pn +import panel_material_ui # noqa: F401 Must be imported like this to register the extension +import panel_material_ui as pmui +import param +from pydantic._internal._repr import display_as_type + +import ccflow + +pn.extension() +pn.extension("jsoneditor") + + +__all__ = ("ModelConfigViewer", "ModelTypeViewer", "ModelViewer") + + +_FIELD_STYLES = { + "name": "color:#0550ae;", # blue + "type": "color:#8250df;", # purple + "description": "color:#57606a;font-style:italic;", # muted gray +} + + +class ModelTypeViewer(param.Parameterized): + """ + Displays type name, class docstring, and fields for a Pydantic model type. + """ + + model_type = param.Parameter(default=None) + + def __init__(self, **params): + super().__init__(**params) + + self._pane = pn.pane.HTML("", sizing_mode="stretch_width") + self._layout = pn.Column( + self._pane, + sizing_mode="stretch_width", + ) + + self.param.watch(self._on_type_change, "model_type") + + def __panel__(self): + return self._layout + + def _on_type_change(self, event): + model_cls = event.new + if model_cls is None: + self._pane.object = "" + return + + type_name = display_as_type(model_cls) + + # Class documentation + docs = (model_cls.__doc__ or "").strip() + docs_html = "" + if docs: + escaped = html.escape(docs).replace("\n", "
    ") + docs_html = f""" +
    +
    Class Documentation:
    +
    {escaped}
    +
    + """ + + # Fields + fields = getattr(model_cls, "model_fields", {}) + field_items = [] + + for name, field in fields.items(): + field_type = display_as_type(field.annotation) + desc = field.description or "" + name_html = f'{html.escape(name)}' + type_html = f'{html.escape(field_type)}' + desc_html = f' — {html.escape(desc)}' if desc else "" + field_items.append(f'
  • {name_html} ({type_html}){desc_html}
  • ') + + fields_html = "" + if field_items: + fields_html = f""" +
    +
    Fields:
    + +
    + """ + + self._pane.object = f""" +
    +
    + Type: + {html.escape(type_name)} +
    + {docs_html} + {fields_html} +
    + """ + + +class ModelConfigViewer(param.Parameterized): + """ + Displays instance-level metadata (description + dependencies). + """ + + model = param.Parameter(default=None) + + def __init__(self, **params): + super().__init__(**params) + + self.model_path = "" + self._metadata = pn.pane.HTML("", sizing_mode="stretch_width") + + self._layout = pn.Column( + self._metadata, + sizing_mode="stretch_width", + ) + + self.param.watch(self._on_model_change, "model") + + def __panel__(self): + return self._layout + + def _render_dependencies(self, model): + deps = model.get_registry_dependencies() + if not deps: + return "" + + # Collect all values, deduplicate, and sort + all_paths = [] + for group in deps: + if len(group) == 1: + all_paths.append(group[0]) + else: + all_paths.append(" | ".join(group)) + + # Unique elements, sorted + rows = sorted(set(all_paths)) + + items = "".join(f'
  • {html.escape(row)}
  • ' for row in rows) + + return f""" +
    +
    + Registry Dependencies +
    + +
    + """ + + def _on_model_change(self, event): + model = event.new + if model is None: + self._metadata.object = "" + return + + path_html = "" + if self.model_path: + path_html = f""" +
    +
    Registry Path
    + {html.escape(self.model_path)} +
    + """ + + description = model.meta.description.strip() if hasattr(model, "meta") and model.meta.description else "" + + desc_html = "" + if description: + try: + import bleach + + description = bleach.linkify(html.escape(description)) + except ImportError: + description = html.escape(description) + desc_html = f""" +
    +
    Instance Description
    +
    {description}
    +
    + """ + + self._metadata.object = path_html + desc_html + self._render_dependencies(model) + + +class ModelViewer(param.Parameterized): + """ + Displays a tabbed view of a ccflow Model instance, including description, registry dependencies, docstrings and json representation. + """ + + model = param.Parameter(default=None) + + def __init__(self, **params): + super().__init__(**params) + + self.model_path = "" + + # Sub-viewers (no JSONEditor inside) + self._config_viewer = ModelConfigViewer() + self._type_viewer = ModelTypeViewer() + self._context_type_viewer = ModelTypeViewer() + self._result_type_viewer = ModelTypeViewer() + + # Material UI Tabs (metadata only) + self._tabs = pmui.Tabs( + active=0, + sizing_mode="stretch_width", + ) + + # JSON editor (stable, but hidden until a model is selected) + self._json_editor = pn.widgets.JSONEditor( + value={}, + mode="view", + menu=False, + sizing_mode="stretch_width", + min_width=400, + ) + + self._json_container = pn.Column( + "## Parameters", + self._json_editor, + visible=False, # hidden initially + sizing_mode="stretch_width", + ) + + self._layout = pn.Column( + "## Model Viewer", + self._tabs, + pn.Spacer(height=12), + self._json_container, + sizing_mode="stretch_width", + ) + + self.param.watch(self._on_model_change, "model") + + def __panel__(self): + return self._layout + + def _on_model_change(self, event): + model = event.new + self._tabs.clear() + + if model is None: + # hide JSON editor if no model + self._json_editor.value = {} + self._json_container.visible = False + return + + # Config tab + self._config_viewer.model_path = self.model_path + self._config_viewer.model = model + self._tabs.append(("Summary", self._config_viewer)) + + # Model Type tab + self._type_viewer.model_type = type(model) + self._tabs.append(("Model Type", self._type_viewer)) + + # CallableModel extras + if isinstance(model, ccflow.CallableModel): + self._context_type_viewer.model_type = model.context_type + self._tabs.append(("Context Type", self._context_type_viewer)) + + self._result_type_viewer.model_type = model.result_type + self._tabs.append(("Result Type", self._result_type_viewer)) + + # Default to Config tab + self._tabs.active = 0 + + # Update & show JSONEditor + self._json_editor.value = model.__pydantic_serializer__.to_python(model, fallback=str, mode="json") + self._json_container.visible = True diff --git a/ccflow/ui/panel/registry.py b/ccflow/ui/panel/registry.py new file mode 100644 index 00000000..225a1fa9 --- /dev/null +++ b/ccflow/ui/panel/registry.py @@ -0,0 +1,183 @@ +import panel as pn +import panel_material_ui # noqa: F401 Must be imported like this to register the extension +import panel_material_ui as pmui +import param + +from .model import ModelViewer + +pn.extension() + +__all__ = ("ModelRegistryViewer", "RegistryBrowser") + + +class RegistryBrowser(param.Parameterized): + selected_model = param.Parameter(default=None) + + sort_children = param.Boolean( + default=True, + doc="If True, sort child entries alphabetically by name at every registry level. Defaults to insertion order when False.", + ) + + def __init__(self, registry, **params): + super().__init__(**params) + self._registry = registry + self.selected_path = "" + + self._tree_items = self._build_tree(registry) + self._node_index = self._build_node_index(self._tree_items) + + self._tree = pmui.Tree( + items=self._tree_items, + multi_select=False, + ) + + self._search = pn.widgets.AutocompleteInput( + name="Search", + options=sorted(self._node_index.keys()), + placeholder="Search full path…", + case_sensitive=False, + search_strategy="includes", + min_characters=1, + sizing_mode="stretch_width", + ) + + self._search.param.watch(self._on_search_select, "value") + self._tree.param.watch(self._on_tree_select, "value") + + self._layout = pn.Column( + "## Registry", + self._search, + self._tree, + ) + + def __panel__(self): + return self._layout + + # Tree construction + + def _build_tree(self, registry, index_prefix=()): + import ccflow + + model_items = registry.models.items() + if self.sort_children: + # Subregistries first, then leaf models; each group sorted alphabetically. + model_items = sorted(model_items, key=lambda kv: (not isinstance(kv[1], ccflow.ModelRegistry), kv[0])) + + items = [] + for i, (name, model) in enumerate(model_items): + index_path = index_prefix + (i,) + entry = { + "label": name, + "_index_path": index_path, + } + + if isinstance(model, ccflow.ModelRegistry): + entry["items"] = self._build_tree(model, index_prefix=index_path) + else: + entry["model"] = model + + items.append(entry) + + return items + + def _build_node_index(self, tree_items): + index = {} + + def walk(items, prefix=""): + for node in items: + path = f"{prefix}/{node['label']}" if prefix else node["label"] + node["_path"] = path + if "model" in node: + index[path] = node + walk(node.get("items", []), path) + + walk(tree_items) + return index + + @staticmethod + def _expanded_from_index_path(index_path): + return [index_path[:i] for i in range(1, len(index_path))] + + # Callbacks + + def _on_search_select(self, event): + path = event.new + if not path: + return + node = self._node_index.get(path) + if not node: + return + self._tree.expanded = self._expanded_from_index_path(node["_index_path"]) + self._tree.value = [node] + self._search.value = "" + + def _on_tree_select(self, event): + if event.new: + node = event.new[0] + model = node.get("model") + self.selected_path = node.get("_path", "") if model is not None else "" + self.selected_model = model + else: + self.selected_path = "" + self.selected_model = None + + +class ModelRegistryViewer(param.Parameterized): + """ + Top-level viewer that composes the RegistryBrowser and ModelViewer + into a viewport-filling page with a resizable sidebar. + """ + + # Layout parameters + browser_width = param.Integer( + default=400, + bounds=(200, None), + doc="Initial width of the registry browser sidebar (px). User can drag to resize at runtime.", + ) + + title = param.String( + default="ccflow Model Registry", + doc="Title shown in the page header.", + ) + + model = param.Parameter( + default=None, + doc="The currently selected model from the registry browser", + ) + + sort_children = param.Boolean( + default=True, + doc="If True, sort registry child entries alphabetically by name at every level. Defaults to insertion order when False.", + ) + + def __init__(self, registry, **params): + super().__init__(**params) + + # Core components + self._browser = RegistryBrowser(registry, sort_children=self.sort_children) + self._viewer = ModelViewer() + + # Wire browser → viewer and model param + def _on_selection(e): + self.model = e.new + self._viewer.model_path = self._browser.selected_path + self._viewer.model = e.new + + self._browser.param.watch(_on_selection, "selected_model") + + # Wrap browser in a scrolling Column so large registries remain navigable. + sidebar_panel = pn.Column( + self._browser, + sizing_mode="stretch_both", + scroll=True, + ) + + self._layout = pmui.Page( + sidebar=[sidebar_panel], + main=[self._viewer], + sidebar_width=self.browser_width, + title=self.title, + ) + + def __panel__(self): + return self._layout diff --git a/ccflow/ui/registry.py b/ccflow/ui/registry.py index 225a1fa9..e8162ca3 100644 --- a/ccflow/ui/registry.py +++ b/ccflow/ui/registry.py @@ -1,183 +1,3 @@ -import panel as pn -import panel_material_ui # noqa: F401 Must be imported like this to register the extension -import panel_material_ui as pmui -import param +"""Compatibility imports for Panel registry views.""" -from .model import ModelViewer - -pn.extension() - -__all__ = ("ModelRegistryViewer", "RegistryBrowser") - - -class RegistryBrowser(param.Parameterized): - selected_model = param.Parameter(default=None) - - sort_children = param.Boolean( - default=True, - doc="If True, sort child entries alphabetically by name at every registry level. Defaults to insertion order when False.", - ) - - def __init__(self, registry, **params): - super().__init__(**params) - self._registry = registry - self.selected_path = "" - - self._tree_items = self._build_tree(registry) - self._node_index = self._build_node_index(self._tree_items) - - self._tree = pmui.Tree( - items=self._tree_items, - multi_select=False, - ) - - self._search = pn.widgets.AutocompleteInput( - name="Search", - options=sorted(self._node_index.keys()), - placeholder="Search full path…", - case_sensitive=False, - search_strategy="includes", - min_characters=1, - sizing_mode="stretch_width", - ) - - self._search.param.watch(self._on_search_select, "value") - self._tree.param.watch(self._on_tree_select, "value") - - self._layout = pn.Column( - "## Registry", - self._search, - self._tree, - ) - - def __panel__(self): - return self._layout - - # Tree construction - - def _build_tree(self, registry, index_prefix=()): - import ccflow - - model_items = registry.models.items() - if self.sort_children: - # Subregistries first, then leaf models; each group sorted alphabetically. - model_items = sorted(model_items, key=lambda kv: (not isinstance(kv[1], ccflow.ModelRegistry), kv[0])) - - items = [] - for i, (name, model) in enumerate(model_items): - index_path = index_prefix + (i,) - entry = { - "label": name, - "_index_path": index_path, - } - - if isinstance(model, ccflow.ModelRegistry): - entry["items"] = self._build_tree(model, index_prefix=index_path) - else: - entry["model"] = model - - items.append(entry) - - return items - - def _build_node_index(self, tree_items): - index = {} - - def walk(items, prefix=""): - for node in items: - path = f"{prefix}/{node['label']}" if prefix else node["label"] - node["_path"] = path - if "model" in node: - index[path] = node - walk(node.get("items", []), path) - - walk(tree_items) - return index - - @staticmethod - def _expanded_from_index_path(index_path): - return [index_path[:i] for i in range(1, len(index_path))] - - # Callbacks - - def _on_search_select(self, event): - path = event.new - if not path: - return - node = self._node_index.get(path) - if not node: - return - self._tree.expanded = self._expanded_from_index_path(node["_index_path"]) - self._tree.value = [node] - self._search.value = "" - - def _on_tree_select(self, event): - if event.new: - node = event.new[0] - model = node.get("model") - self.selected_path = node.get("_path", "") if model is not None else "" - self.selected_model = model - else: - self.selected_path = "" - self.selected_model = None - - -class ModelRegistryViewer(param.Parameterized): - """ - Top-level viewer that composes the RegistryBrowser and ModelViewer - into a viewport-filling page with a resizable sidebar. - """ - - # Layout parameters - browser_width = param.Integer( - default=400, - bounds=(200, None), - doc="Initial width of the registry browser sidebar (px). User can drag to resize at runtime.", - ) - - title = param.String( - default="ccflow Model Registry", - doc="Title shown in the page header.", - ) - - model = param.Parameter( - default=None, - doc="The currently selected model from the registry browser", - ) - - sort_children = param.Boolean( - default=True, - doc="If True, sort registry child entries alphabetically by name at every level. Defaults to insertion order when False.", - ) - - def __init__(self, registry, **params): - super().__init__(**params) - - # Core components - self._browser = RegistryBrowser(registry, sort_children=self.sort_children) - self._viewer = ModelViewer() - - # Wire browser → viewer and model param - def _on_selection(e): - self.model = e.new - self._viewer.model_path = self._browser.selected_path - self._viewer.model = e.new - - self._browser.param.watch(_on_selection, "selected_model") - - # Wrap browser in a scrolling Column so large registries remain navigable. - sidebar_panel = pn.Column( - self._browser, - sizing_mode="stretch_both", - scroll=True, - ) - - self._layout = pmui.Page( - sidebar=[sidebar_panel], - main=[self._viewer], - sidebar_width=self.browser_width, - title=self.title, - ) - - def __panel__(self): - return self._layout +from .panel.registry import * diff --git a/ccflow/ui/spaday/__init__.py b/ccflow/ui/spaday/__init__.py new file mode 100644 index 00000000..417aeab3 --- /dev/null +++ b/ccflow/ui/spaday/__init__.py @@ -0,0 +1,3 @@ +from .cli import * +from .model import * +from .registry import * diff --git a/ccflow/ui/spaday/cli.py b/ccflow/ui/spaday/cli.py new file mode 100644 index 00000000..ae9469b6 --- /dev/null +++ b/ccflow/ui/spaday/cli.py @@ -0,0 +1,199 @@ +"""CLI for serving the ccflow ModelRegistry as a spaday application. + +Mirrors :mod:`ccflow.ui.panel.cli` but renders the spaday viewer and serves it with Starlette + uvicorn +instead of Panel. ``serve_registry`` is the importable entry point; ``registry_viewer_cli`` is the +hydra-config-driven command wrapped by the ``ccflow-ui-spaday`` console script. +""" + +import argparse +import asyncio +import logging +import os +from collections.abc import Callable +from pathlib import Path +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 .model import MATERIALIZE_ENDPOINT +from .registry import SELECTED_FIELD, registry_store, registry_viewer + +__all__ = ("main", "registry_viewer_cli", "serve_registry") + +log = logging.getLogger(__name__) + + +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 + 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. + """ + body = parse_qs((await request.body()).decode()) + path = request.query_params.get("path", "") or body.get("path", [""])[0] + if path: + try: + 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) + + 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)) + + app = serve( + page, + packages=webawesome_package, + store=registry_store(), + title=title, + layout=layout, + 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). + app.routes.insert(0, Route("/", homepage, methods=["GET"])) + + 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: Callable | None = None, +): + """CLI entry point for serving the spaday ModelRegistry viewer. + + Args: + config_path: The config_path specified in hydra.main(). + config_name: The config_name specified in hydra.main(). + hydra_main: The function decorated with hydra.main(). Used to resolve config_path relative to + the decorated function's file location. + """ + parser = _get_ui_args_parser() + args = parser.parse_args() + + root_config_dir, root_config_name = resolve_config_paths(args, config_path, config_name, hydra_main) + # hydra's initialize_config_dir requires an absolute directory; resolve a relative --config-path + # against the current working directory. + root_config_dir = os.path.abspath(root_config_dir) + + result = load_config( + root_config_dir=root_config_dir, + root_config_name=root_config_name, + config_dir=args.config_dir, + config_name=args.config_dir_config_name, + overrides=args.overrides, + basepath=args.basepath, + ) + + registry = ModelRegistry.root() + registry.load_config(cfg=result.cfg, overwrite=True) + + serve_registry( + registry, + title=args.title, + browser_width=args.browser_width, + sort_children=args.sort_children, + 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..a9e7f616 --- /dev/null +++ b/ccflow/ui/spaday/model.py @@ -0,0 +1,153 @@ +"""Model-detail components for the spaday registry viewer. + +Each function builds a piece of the model inspector as a :class:`spaday.Component` tree (rendered to the +browser by the spaday runtime), mirroring the tabs of the Panel viewer in :mod:`ccflow.ui.panel.model`: +an instance summary, the model / context / result types with their fields, and the serialized parameters. +""" + +import json + +from pydantic._internal._repr import display_as_type +from spaday import Component, Strong, Text, element +from spaday.actions import Expr +from spaday.components import Column, Row +from spaday_webawesome import Tabs, WaBadge, WaButton, WaCard, WaDivider + +import ccflow + +#: Path of the endpoint (served by :func:`ccflow.ui.spaday.cli.serve_registry`) that materializes a +#: pending model server-side and redirects back with it selected. +MATERIALIZE_ENDPOINT = "/materialize" + +__all__ = ("MATERIALIZE_ENDPOINT", "model_config_view", "model_type_view", "model_view", "pending_model_view") + +_PRE_STYLE = { + "white_space": "pre-wrap", + "font_family": "monospace", + "background": "#f6f8fa", + "padding": "8px", + "margin": "0", + "border_radius": "4px", + "overflow_wrap": "anywhere", +} + + +def _labeled(label: str, *body: Component) -> Component: + """A bold label above its content.""" + return Column(Strong(label), *body, gap="0.25rem") + + +def _code(text: str | Expr, *, color: str = "") -> Component: + """An inline ```` element that wraps long identifiers.""" + node = element("code").text(text).style(overflow_wrap="anywhere") + return node.style(color=color) if color else node + + +def _pre(text: str) -> Component: + """A preformatted code block.""" + return element("pre").text(text).style(**_PRE_STYLE) + + +def model_type_view(model_cls) -> Component: + """Show a Pydantic model type's name, class docstring, and fields.""" + if model_cls is None: + return Column() + + children = [Row(Strong("Type:"), WaBadge(variant="brand").text(display_as_type(model_cls)), gap="0.5rem", align="center")] + + docs = (model_cls.__doc__ or "").strip() + if docs: + children.append(_labeled("Class Documentation", _pre(docs))) + + fields = getattr(model_cls, "model_fields", {}) + if fields: + items = element("ul").style(margin="0", padding_left="18px") + for name, field in fields.items(): + entry = element("li").style(overflow_wrap="anywhere") + entry.child(_code(name, color="#0550ae")) + entry.child(Text(f" ({display_as_type(field.annotation)})")) + if field.description: + entry.child(Text(f" — {field.description}")) + items.child(entry) + children.append(_labeled("Fields", items)) + + return Column(*children, gap="0.75rem") + + +def _dependencies_view(model) -> Component: + """A bulleted list of the model's registry dependencies, or ``None`` if it has none.""" + deps = model.get_registry_dependencies() + if not deps: + return None + + rows = sorted({group[0] if len(group) == 1 else " | ".join(group) for group in deps}) + items = element("ul").style(margin="0", padding_left="18px") + for row in rows: + items.child(element("li").child(_code(row))) + return _labeled("Registry Dependencies", items) + + +def model_config_view(model, path: str = "") -> Component: + """Show instance-level metadata: registry path, description, and dependencies.""" + children = [] + + if path: + children.append(_labeled("Registry Path", _code(path))) + + description = model.meta.description.strip() if hasattr(model, "meta") and model.meta.description else "" + if description: + children.append(_labeled("Instance Description", element("div").text(description))) + + dependencies = _dependencies_view(model) + if dependencies is not None: + children.append(dependencies) + + if not children: + children.append(Text("No additional metadata.")) + + return Column(*children, gap="0.75rem") + + +def model_view(model, path: str = "") -> 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")) + + +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(path: str | Expr) -> Component: + """A shared card for the currently selected model that has not been instantiated. + + 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. + """ + 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(), + gap="0.75rem", + ), + name="summary", + ) + header = Row(WaBadge(variant="neutral").text("Pending"), Strong("Pending model"), gap="0.5rem", align="center") + return WaCard(appearance="outlined").child(Column(header, WaDivider(), tabs, gap="0.75rem")) diff --git a/ccflow/ui/spaday/registry.py b/ccflow/ui/spaday/registry.py new file mode 100644 index 00000000..e5c9e4b9 --- /dev/null +++ b/ccflow/ui/spaday/registry.py @@ -0,0 +1,111 @@ +"""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 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 + +import ccflow + +from .model import model_view, pending_model_view + +__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" + + +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.""" + if isinstance(registry, ccflow.LazyRegistry): + items = [] + for name in registry.models: + loaded = registry.get_loaded(name) + items.append((name, loaded if loaded is not None else registry.get_pending_config(name))) + else: + items = list(registry.models.items()) + if sort_children: + items = sorted(items, key=lambda kv: (not isinstance(kv[1], ccflow.ModelRegistry), kv[0])) + return list(items) + + +def registry_leaves(registry, *, sort_children: bool = True, _prefix: str = "") -> list[tuple[str, object]]: + """Return ``(path, model)`` for every leaf model in the registry, depth-first.""" + leaves: list[tuple[str, object]] = [] + for name, model in _sorted_items(registry, sort_children): + path = f"{_prefix}/{name}" if _prefix else name + if isinstance(model, ccflow.ModelRegistry): + leaves.extend(registry_leaves(model, sort_children=sort_children, _prefix=path)) + else: + leaves.append((path, model)) + return leaves + + +def 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("")))] + 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 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..8ecf4d04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,10 @@ full = [ "ray", "scipy", "smart_open", + "spaday", + "spaday-webawesome", + "starlette", + "uvicorn", "xarray", ] otel = [ @@ -96,6 +100,10 @@ develop = [ "ray", "scipy", "smart_open", + "spaday", + "spaday-webawesome", + "starlette", + "uvicorn", "xarray", # Reporting deps "opentelemetry-api", @@ -119,6 +127,7 @@ test = [ ] [project.scripts] +ccflow-ui-spaday = "ccflow.ui.spaday.cli:main" [project.urls] Repository = "https://github.com/Point72/ccflow"