Source code for agi_gui.widget_registry

"""Typed registry for reusable AGILAB Streamlit widgets."""

from __future__ import annotations

from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass
from typing import Any


[docs] @dataclass(frozen=True, slots=True) class WidgetSpec: """Metadata for a reusable ``agi-gui`` widget.""" key: str label: str widget: Callable[..., Any] module: str category: str description: str = "" aliases: tuple[str, ...] = () tags: tuple[str, ...] = () def __post_init__(self) -> None: if not _normalize_lookup_key(self.key): raise ValueError("Widget key must be a non-empty string") if not self.label.strip(): raise ValueError("Widget label must be a non-empty string") if not callable(self.widget): raise TypeError(f"Widget {self.key!r} must be callable") if not self.module.strip(): raise ValueError("Widget module must be a non-empty string") if not _normalize_lookup_key(self.category): raise ValueError("Widget category must be a non-empty string") @property def qualified_name(self) -> str: """Return the import-style name of the registered widget callable.""" return f"{self.module}.{getattr(self.widget, '__name__', self.key)}"
[docs] def as_row(self) -> dict[str, str]: """Return a compact row suitable for docs and diagnostics.""" return { "key": self.key, "label": self.label, "category": self.category, "qualified_name": self.qualified_name, "description": self.description, "aliases": ", ".join(self.aliases), "tags": ", ".join(self.tags), }
def __call__(self, *args: Any, **kwargs: Any) -> Any: """Delegate calls to the registered widget callable.""" return self.widget(*args, **kwargs)
[docs] class WidgetRegistry: """Immutable registry for resolving AGILAB UI widgets by key or alias."""
[docs] def __init__(self, widgets: Iterable[WidgetSpec] = ()) -> None: self._widgets = tuple(widgets) self._lookup = self._build_lookup(self._widgets)
@staticmethod def _build_lookup(widgets: tuple[WidgetSpec, ...]) -> dict[str, WidgetSpec]: lookup: dict[str, WidgetSpec] = {} for spec in widgets: names = (spec.key, *spec.aliases) for name in names: lookup_key = _normalize_lookup_key(name) if not lookup_key: raise ValueError(f"Widget {spec.key!r} has an empty alias") existing = lookup.get(lookup_key) if existing is not None and existing is not spec: raise ValueError( f"Widget lookup name {name!r} is already registered " f"for {existing.key!r}" ) lookup[lookup_key] = spec return lookup def __contains__(self, key_or_alias: object) -> bool: if not isinstance(key_or_alias, str): return False return _normalize_lookup_key(key_or_alias) in self._lookup def __iter__(self) -> Iterator[WidgetSpec]: return iter(self._widgets) def __len__(self) -> int: return len(self._widgets) @property def widgets(self) -> tuple[WidgetSpec, ...]: """Return registered widgets in deterministic display order.""" return self._widgets
[docs] def keys(self) -> tuple[str, ...]: """Return primary widget keys in deterministic display order.""" return tuple(spec.key for spec in self._widgets)
[docs] def categories(self) -> tuple[str, ...]: """Return known widget categories in deterministic display order.""" seen: set[str] = set() categories: list[str] = [] for spec in self._widgets: if spec.category not in seen: seen.add(spec.category) categories.append(spec.category) return tuple(categories)
[docs] def get(self, key_or_alias: str, default: Any = None) -> WidgetSpec | Any: """Return a widget spec by key or alias, or ``default`` when absent.""" return self._lookup.get(_normalize_lookup_key(key_or_alias), default)
[docs] def require(self, key_or_alias: str) -> WidgetSpec: """Return a widget spec by key or alias, raising a useful error when absent.""" lookup_key = _normalize_lookup_key(key_or_alias) spec = self._lookup.get(lookup_key) if spec is not None: return spec available = ", ".join(self.keys()) or "<empty>" raise KeyError(f"Unknown agi-gui widget {key_or_alias!r}. Available widgets: {available}")
[docs] def by_category(self, category: str) -> tuple[WidgetSpec, ...]: """Return widgets matching ``category`` in deterministic display order.""" lookup_category = _normalize_lookup_key(category) return tuple( spec for spec in self._widgets if _normalize_lookup_key(spec.category) == lookup_category )
[docs] def register(self, spec: WidgetSpec) -> "WidgetRegistry": """Return a new registry with ``spec`` added.""" return type(self)((*self._widgets, spec))
[docs] def as_rows(self) -> list[dict[str, str]]: """Return registry rows suitable for rendering as a table.""" return [spec.as_row() for spec in self._widgets]
_DEFAULT_WIDGET_REGISTRY: WidgetRegistry | None = None
[docs] def default_widget_registry() -> WidgetRegistry: """Return the default ``agi-gui`` widget registry.""" global _DEFAULT_WIDGET_REGISTRY if _DEFAULT_WIDGET_REGISTRY is None: _DEFAULT_WIDGET_REGISTRY = WidgetRegistry(_default_widget_specs()) return _DEFAULT_WIDGET_REGISTRY
[docs] def get_widget(key_or_alias: str) -> Callable[..., Any]: """Return a registered widget callable by key or alias.""" return default_widget_registry().require(key_or_alias).widget
[docs] def widget_registry_rows() -> list[dict[str, str]]: """Return default registry rows for diagnostics or documentation.""" return default_widget_registry().as_rows()
def _default_widget_specs() -> tuple[WidgetSpec, ...]: from .file_picker import agi_file_picker from .ux_widgets import ( action_button, action_row, compact_choice, confirm_button, empty_state, notice, status_container, toast, ) return ( WidgetSpec( key="file_picker", label="File picker", widget=agi_file_picker, module="agi_gui.file_picker", category="file", description="Server-side Streamlit path picker with root validation.", aliases=("agi_file_picker", "picker", "path_picker"), tags=("filesystem", "upload", "selection"), ), WidgetSpec( key="compact_choice", label="Compact choice", widget=compact_choice, module="agi_gui.ux_widgets", category="choice", description="Single-choice control using modern Streamlit primitives with fallbacks.", aliases=("choice", "segmented_choice", "pills_choice"), tags=("selection", "navigation"), ), WidgetSpec( key="action_button", label="Action button", widget=action_button, module="agi_gui.ux_widgets", category="action", description="Button with normalized AGILAB action styling.", aliases=("button", "command_button"), tags=("action", "command"), ), WidgetSpec( key="action_row", label="Action row", widget=action_row, module="agi_gui.ux_widgets", category="action", description="Deterministic row of normalized action buttons.", aliases=("button_row", "command_row"), tags=("action", "layout"), ), WidgetSpec( key="confirm_button", label="Confirm button", widget=confirm_button, module="agi_gui.ux_widgets", category="action", description="Two-step confirmation button for destructive or costly actions.", aliases=("confirm", "destructive_confirm"), tags=("action", "safety"), ), WidgetSpec( key="empty_state", label="Empty state", widget=empty_state, module="agi_gui.ux_widgets", category="feedback", description="Normalized empty-state notice with optional action.", aliases=("empty", "placeholder_state"), tags=("feedback", "state"), ), WidgetSpec( key="notice", label="Notice", widget=notice, module="agi_gui.ux_widgets", category="feedback", description="Inline message wrapper with compatibility fallbacks.", aliases=("message", "inline_notice"), tags=("feedback", "message"), ), WidgetSpec( key="status_container", label="Status container", widget=status_container, module="agi_gui.ux_widgets", category="feedback", description="Status context using Streamlit status or compatible fallbacks.", aliases=("status", "progress_status"), tags=("feedback", "progress"), ), WidgetSpec( key="toast", label="Toast", widget=toast, module="agi_gui.ux_widgets", category="feedback", description="Toast notification with regular-message fallback.", aliases=("notification", "notify"), tags=("feedback", "notification"), ), ) def _normalize_lookup_key(value: str) -> str: return str(value).strip().casefold().replace("-", "_").replace(" ", "_")