Source code for dash_prism.registry

"""
Layout Registry for Prism
=========================

This module handles registration of layouts that can be opened in Prism tabs.
Layouts can be static (pre-built) or dynamic (generated on-demand via callbacks).

Usage Examples
--------------

Static layout registration::

    register_layout(
        id='home',
        name='Home',
        layout=html.Div('Welcome!')
    )

Decorator for callback-based layouts::

    @register_layout(
        id='chart',
        name='Chart View',
        param_options={
            'bar': ('Bar Chart', {'chart_type': 'bar'}),
            'line': ('Line Chart', {'chart_type': 'line'}),
        }
    )
    def chart_layout(chart_type: str = 'bar'):
        return dcc.Graph(...)
"""

from __future__ import annotations

import inspect
from dataclasses import dataclass, field
from typing import (
    Any,
    Callable,
    Dict,
    List,
    Optional,
    Tuple,
    Union,
    overload,
)

# =============================================================================
# DATA CLASSES
# =============================================================================


[docs] @dataclass class LayoutParameter: """Describes a parameter for a parameterized layout callback. :ivar name: The parameter name. :type name: str :ivar has_default: Whether the parameter has a default value. :type has_default: bool :ivar default: The default value, if any. :type default: Any :ivar annotation: The type annotation as a string. :type annotation: str | None """ name: str has_default: bool = False default: Any = None annotation: Optional[str] = None
[docs] def to_dict(self) -> Dict[str, Any]: """Convert to a dictionary for JSON serialization. :returns: Dictionary with camelCase keys for frontend consumption. :rtype: dict[str, Any] """ return { "name": self.name, "hasDefault": self.has_default, "default": self.default, "annotation": self.annotation, }
@dataclass class LayoutRegistration: """Represents a registered layout in the Prism system. :ivar id: Unique identifier for this layout. :type id: str :ivar name: Human-readable display name. :type name: str :ivar description: Description shown in the layout picker. :type description: str :ivar keywords: Searchable keywords for finding this layout. :type keywords: list[str] :ivar allow_multiple: Whether multiple instances can be open simultaneously. :type allow_multiple: bool :ivar layout: Static layout component (mutually exclusive with callback). :type layout: Any :ivar callback: Function to generate layout (mutually exclusive with layout). :type callback: Callable[..., Any] | None :ivar is_async: Whether the callback is an async function. :type is_async: bool :ivar parameters: Parameters the callback accepts. :type parameters: list[LayoutParameter] :ivar param_options: Pre-defined parameter configurations for quick access. :type param_options: dict[str, tuple[str, dict[str, Any]]] | None """ id: str name: str description: str = "" keywords: List[str] = field(default_factory=list) allow_multiple: bool = False layout: Any = None callback: Optional[Callable[..., Any]] = None is_async: bool = False parameters: List[LayoutParameter] = field(default_factory=list) param_options: Optional[Dict[str, Tuple[str, Dict[str, Any]]]] = None @property def is_callable(self) -> bool: """Check if this layout is generated via callback. :returns: True if layout uses a callback, False if static. :rtype: bool """ return self.callback is not None def to_metadata(self) -> Dict[str, Any]: """Convert to metadata dict for frontend consumption. Does **not** include the actual layout component or callback function. :returns: Metadata dictionary with camelCase keys. :rtype: dict[str, Any] """ params = [] if self.param_options else [p.to_dict() for p in self.parameters] result: Dict[str, Any] = { "name": self.name, "description": self.description, "keywords": self.keywords, "allowMultiple": self.allow_multiple, "params": params, # Changed from 'parameters' "paramOptions": None, # Changed from 'parameterOptions' } if self.param_options: result["paramOptions"] = { key: {"description": label, "params": params} # Changed 'label' to 'description' for key, (label, params) in self.param_options.items() } return result # ============================================================================= # REGISTRY CLASS # ============================================================================= class LayoutRegistry: """Global registry of all layouts available in Prism. Provides methods to register, retrieve, and manage layouts. Supports iteration and membership testing. **Example**:: >>> registry = LayoutRegistry() >>> len(registry) 0 >>> 'home' in registry False """ def __init__(self) -> None: self._layouts: Dict[str, LayoutRegistration] = {} @property def layouts(self) -> Dict[str, LayoutRegistration]: """Get a copy of all registered layouts. :returns: Dictionary mapping layout IDs to registrations. :rtype: dict[str, LayoutRegistration] """ return self._layouts.copy() def __contains__(self, layout_id: str) -> bool: """Check if a layout ID is registered.""" return layout_id in self._layouts def __len__(self) -> int: """Return the number of registered layouts.""" return len(self._layouts) def __iter__(self): """Iterate over layout IDs.""" return iter(self._layouts) def get(self, layout_id: str) -> Optional[LayoutRegistration]: """Get a registered layout by ID. :param layout_id: The unique identifier of the layout. :type layout_id: str :returns: The registration if found, None otherwise. :rtype: LayoutRegistration | None """ return self._layouts.get(layout_id) def register(self, registration: LayoutRegistration) -> None: """Register a layout. :param registration: The layout registration to add. :type registration: LayoutRegistration :raises ValueError: If the layout ID is already registered. """ if registration.id in self._layouts: raise ValueError(f"Layout '{registration.id}' is already registered") self._layouts[registration.id] = registration def unregister(self, layout_id: str) -> bool: """Remove a layout from the registry. :param layout_id: The ID of the layout to remove. :type layout_id: str :returns: True if removed, False if not found. :rtype: bool """ if layout_id in self._layouts: del self._layouts[layout_id] return True return False def clear(self) -> None: """Clear all registered layouts. Useful for testing.""" self._layouts.clear() def get_metadata(self) -> Dict[str, Dict[str, Any]]: """Get metadata for all registered layouts. :returns: Dictionary mapping layout IDs to metadata dicts. This is what gets sent to the frontend. :rtype: dict[str, dict[str, Any]] """ return {layout_id: reg.to_metadata() for layout_id, reg in self._layouts.items()} # Global registry instance registry = LayoutRegistry() # ============================================================================= # INTERNAL HELPERS # ============================================================================= def _extract_parameters(func: Callable[..., Any]) -> List[LayoutParameter]: """Extract parameter information from a function signature. :param func: The function to inspect. :type func: Callable[..., Any] :returns: Parameter metadata for each function parameter. :rtype: list[LayoutParameter] """ sig = inspect.signature(func) parameters: List[LayoutParameter] = [] for param_name, param in sig.parameters.items(): # Skip *args and **kwargs if param.kind in ( inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD, ): continue has_default = param.default is not inspect.Parameter.empty default = param.default if has_default else None # Get type annotation as string annotation: Optional[str] = None if param.annotation is not inspect.Parameter.empty: if isinstance(param.annotation, type): annotation = param.annotation.__name__ else: annotation = str(param.annotation) parameters.append( LayoutParameter( name=param_name, has_default=has_default, default=default, annotation=annotation, ) ) return parameters def _is_async_function(func: Callable[..., Any]) -> bool: """Check if a function is an async coroutine function. :param func: The function to check. :type func: Callable[..., Any] :returns: ``True`` if async, ``False`` otherwise. :rtype: bool """ return inspect.iscoroutinefunction(func) def _validate_registration( *, layout_id: str, name: Optional[str], description: str, keywords: Optional[List[str]], allow_multiple: bool, param_options: Optional[Dict[str, Tuple[str, Dict[str, Any]]]], layout: Any, callback: Optional[Callable[..., Any]], ) -> None: """Validate registration parameters. :param layout_id: Unique identifier for the layout. :type layout_id: str :param name: Display name. :type name: str | None :param description: Layout description. :type description: str :param keywords: Search keywords. :type keywords: list[str] | None :param allow_multiple: Whether multiple instances are allowed. :type allow_multiple: bool :param param_options: Parameter presets. :type param_options: dict[str, tuple[str, dict[str, Any]]] | None :param layout: Static layout component. :type layout: Any :param callback: Layout generator function. :type callback: Callable[..., Any] | None :raises ValueError: If validation fails. """ if not layout_id: raise ValueError("Layout 'id' is required") if not isinstance(layout_id, str): raise ValueError(f"Layout 'id' must be a string, got {type(layout_id).__name__}") if layout_id in registry: raise ValueError(f"Layout '{layout_id}' is already registered") if layout is None and callback is None: raise ValueError( "Either 'layout' (static) or a decorated function (callback) " "must be provided" ) if layout is not None and callback is not None: raise ValueError( "Cannot specify both 'layout' (static) and a callback function. " "Use 'layout' for static layouts or decorate a function for " "dynamic layouts." ) if param_options is not None and layout is not None: raise ValueError("'param_options' is only supported for callback layouts") if param_options is not None: if not isinstance(param_options, dict): raise ValueError("'param_options' must be a dict") for key, value in param_options.items(): if not isinstance(key, str): raise ValueError(f"param_options keys must be strings, got {type(key).__name__}") if not isinstance(value, tuple) or len(value) != 2: raise ValueError( f"param_options['{key}'] must be a tuple of " "(label: str, params: dict)" ) label, params = value if not isinstance(label, str): raise ValueError(f"param_options['{key}'] label must be a string") if not isinstance(params, dict): raise ValueError(f"param_options['{key}'] params must be a dict") def _validate_param_options( param_options: Dict[str, Tuple[str, Dict[str, Any]]], parameters: List[LayoutParameter], ) -> None: """Validate that param_options reference valid function parameters. :param param_options: The parameter options to validate. :type param_options: dict[str, tuple[str, dict[str, Any]]] :param parameters: The function's actual parameters. :type parameters: list[LayoutParameter] :raises ValueError: If param_options reference unknown parameters. """ param_names = {p.name for p in parameters} for option_key, (_, params) in param_options.items(): for param_name in params.keys(): if param_name not in param_names: raise ValueError( f"param_options['{option_key}'] references unknown parameter " f"'{param_name}'. Valid parameters: {param_names}" ) # ============================================================================= # PUBLIC API - register_layout # ============================================================================= @overload def register_layout( id: str, # noqa: A002 *, name: Optional[str] = None, description: str = "", keywords: Optional[List[str]] = None, allow_multiple: bool = False, param_options: Optional[Dict[str, Tuple[str, Dict[str, Any]]]] = None, layout: Any, ) -> None: ... @overload def register_layout( id: str, # noqa: A002 *, name: Optional[str] = None, description: str = "", keywords: Optional[List[str]] = None, allow_multiple: bool = False, param_options: Optional[Dict[str, Tuple[str, Dict[str, Any]]]] = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
[docs] def register_layout( id: str, # noqa: A002 *, name: Optional[str] = None, description: str = "", keywords: Optional[List[str]] = None, allow_multiple: bool = False, param_options: Optional[Dict[str, Tuple[str, Dict[str, Any]]]] = None, layout: Any = None, ) -> Union[None, Callable[[Callable[..., Any]], Callable[..., Any]]]: """Register a layout with Prism. Can be used in two ways: **Method A: Static Layout**:: register_layout( id='home', name='Home', description='Welcome page', layout=html.Div([html.H1('Welcome!')]) ) **Method B: Decorator**:: @register_layout( id='chart', name='Chart View', param_options={ 'bar': ('Bar Chart', {'chart_type': 'bar'}), 'line': ('Line Chart', {'chart_type': 'line'}), } ) def chart_layout(chart_type: str = 'bar'): return dcc.Graph(...) :param id: Unique identifier for this layout (required). :type id: str :param name: Human-readable display name. Defaults to ``id``. :type name: str | None :param description: Description shown in the layout picker. :type description: str :param keywords: Searchable keywords for finding this layout. :type keywords: list[str] | None :param allow_multiple: Whether multiple instances can be open simultaneously. Defaults to False. :type allow_multiple: bool :param param_options: Pre-defined parameter configurations. Format: ``{'key': ('Display Label', {'param': 'value'})}``. :type param_options: dict[str, tuple[str, dict[str, Any]]] | None :param layout: Static Dash component tree (Method A only). :type layout: Any | None :returns: None if registering a static layout, decorator function otherwise. :rtype: None | Callable[[Callable[..., Any]], Callable[..., Any]] :raises ValueError: If validation fails or layout is already registered. **Examples** Static registration:: >>> register_layout( ... id='about', ... name='About', ... layout=html.Div('About page'), ... ) Decorator registration:: >>> @register_layout(id='home', name='Home') ... def home_layout(): ... return html.Div('Welcome!') """ # Method A: Static layout provided if layout is not None: _validate_registration( layout_id=id, name=name, description=description, keywords=keywords, allow_multiple=allow_multiple, param_options=param_options, layout=layout, callback=None, ) registry.register( LayoutRegistration( id=id, name=name or id, description=description, keywords=keywords or [], allow_multiple=allow_multiple, layout=layout, callback=None, is_async=False, parameters=[], param_options=param_options, ) ) return None # Method B: Decorator mode def decorator(func: Callable[..., Any]) -> Callable[..., Any]: _validate_registration( layout_id=id, name=name, description=description, keywords=keywords, allow_multiple=allow_multiple, param_options=param_options, layout=None, callback=func, ) parameters = _extract_parameters(func) is_async = _is_async_function(func) if param_options: _validate_param_options(param_options, parameters) registry.register( LayoutRegistration( id=id, name=name or id, description=description, keywords=keywords or [], allow_multiple=allow_multiple, layout=None, callback=func, is_async=is_async, parameters=parameters, param_options=param_options, ) ) return func return decorator
# ============================================================================= # CONVENIENCE FUNCTIONS # =============================================================================
[docs] def get_layout(layout_id: str) -> Optional[LayoutRegistration]: """Get a registered layout by ID. :param layout_id: The ID of the layout to retrieve. :type layout_id: str :returns: The registration if found, None otherwise. :rtype: LayoutRegistration | None """ return registry.get(layout_id)
def get_registered_layouts_metadata() -> Dict[str, Dict[str, Any]]: """Get metadata for all registered layouts. :returns: Dictionary mapping layout IDs to metadata dicts. Excludes actual components and callbacks. :rtype: dict[str, dict[str, Any]] """ return registry.get_metadata() def resolve_layout_params( registration: LayoutRegistration, layout_id: str, layout_params: Optional[Dict[str, Any]], layout_option: Optional[str], ) -> Dict[str, Any]: """Resolve effective layout parameters. Single source of truth for parameter resolution across the codebase. Validates param_options and layout_option, returns resolved parameters. If ``layout_option`` is provided, it will be mapped to parameters via ``registration.param_options``. :param registration: Layout registration metadata. :type registration: LayoutRegistration :param layout_id: The registered layout ID. :type layout_id: str :param layout_params: Parameters supplied from the client. :type layout_params: dict[str, Any] | None :param layout_option: Selected option key from ``param_options``. :type layout_option: str | None :returns: Resolved parameters to pass to the layout callback. :rtype: dict[str, Any] :raises ValueError: If the layout option is invalid or unsupported. """ # Validate layout_params input if layout_params is None: resolved_params: Dict[str, Any] = {} elif not isinstance(layout_params, dict): raise ValueError( f"layoutParams must be an object/dict for layout '{layout_id}', " f"got {type(layout_params).__name__}" ) else: resolved_params = layout_params # Handle param_options (mutually exclusive with layout_params) if registration.param_options: if layout_option is None: raise ValueError( f"Layout '{layout_id}' requires layoutOption when param_options is defined." ) if resolved_params: raise ValueError( f"Layout '{layout_id}' only accepts parameters via param_options; " "layoutParams are not supported." ) if not isinstance(layout_option, str): raise ValueError( f"layoutOption must be a string for layout '{layout_id}', " f"got {type(layout_option).__name__}" ) if not registration.is_callable or registration.callback is None: raise ValueError( f"Layout options are only supported for callback layouts. " f"Layout '{layout_id}' is static." ) option_entry = registration.param_options.get(layout_option) if not option_entry: raise ValueError(f"Layout option '{layout_option}' not found for layout '{layout_id}'.") _, option_params = option_entry if not isinstance(option_params, dict): raise ValueError( f"param_options for layout '{layout_id}' must map to a dict of params." ) return dict(option_params) # If layout_option provided but no param_options defined, that's an error if layout_option is not None: raise ValueError( f"Layout '{layout_id}' does not define param_options but layoutOption was provided." ) return resolved_params
[docs] def clear_registry() -> None: """Clear all registered layouts. Useful for testing to reset state between test cases. """ registry.clear()