432 lines
15 KiB
Python
432 lines
15 KiB
Python
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Custom Components v2 manager and supporting orchestration.
|
|
|
|
This module composes the registry, manifest handling, and file watching
|
|
capabilities for Streamlit's Custom Components v2. It provides a unified
|
|
interface to register components from manifests or individual definitions, query
|
|
component metadata and asset paths, and react to on-disk changes by re-resolving
|
|
component definitions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from streamlit.components.v2.component_definition_resolver import (
|
|
build_definition_with_validation,
|
|
)
|
|
from streamlit.components.v2.component_file_watcher import ComponentFileWatcher
|
|
from streamlit.components.v2.component_manifest_handler import ComponentManifestHandler
|
|
from streamlit.components.v2.component_registry import (
|
|
BidiComponentDefinition,
|
|
BidiComponentRegistry,
|
|
)
|
|
from streamlit.logger import get_logger
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from streamlit.components.v2.manifest_scanner import ComponentManifest
|
|
|
|
_LOGGER: Final = get_logger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class _ApiInputs:
|
|
"""Inputs provided via the Python API to resolve a component definition.
|
|
|
|
Attributes
|
|
----------
|
|
css : str | None
|
|
Inline CSS content or a path/glob to a CSS asset within ``asset_dir``.
|
|
js : str | None
|
|
Inline JS content or a path/glob to a JS asset within ``asset_dir``.
|
|
"""
|
|
|
|
css: str | None
|
|
js: str | None
|
|
|
|
|
|
class BidiComponentManager:
|
|
"""Manager class that composes component registry, manifest handler, and
|
|
file watcher.
|
|
|
|
This class provides a unified interface for working with bidirectional
|
|
components while maintaining clean separation of concerns through
|
|
composition. It handles the coordination and lifecycle management of all
|
|
component-related functionality.
|
|
|
|
Component Lifecycle
|
|
-------------------
|
|
The lifecycle of a component managed by this class involves four key stages:
|
|
|
|
1. **Discovery**: On startup, ``discover_and_register_components`` scans
|
|
for installed packages with component manifests (``pyproject.toml``).
|
|
For each component found, a placeholder definition containing only its
|
|
name and ``asset_dir`` is registered. This makes the system aware of all
|
|
available installed components from the outset.
|
|
|
|
2. **Definition & Validation**: When a user's script calls the public API
|
|
(e.g., ``st.components.v2.component(...)``), the manager invokes
|
|
``build_definition_with_validation``. This function is the single,
|
|
centralized point for all validation. It resolves file paths, performs
|
|
security checks against the component's ``asset_dir``, and produces a
|
|
complete, validated ``BidiComponentDefinition``.
|
|
|
|
3. **Registration**: The validated definition is then passed to the
|
|
registry's ``register`` method. This adds the complete definition,
|
|
overwriting the placeholder if one existed from the discovery phase.
|
|
|
|
4. **Updating**: The ``ComponentFileWatcher`` monitors the ``asset_dir``
|
|
for changes. On a change, it triggers a re-computation of the definition
|
|
using the original API inputs, runs it through the same validation
|
|
logic, and updates the registry with the new definition via the stricter
|
|
``update_component`` method.
|
|
|
|
Notes
|
|
-----
|
|
This manager intentionally favors composition over inheritance and delegates
|
|
specialized responsibilities to ``BidiComponentRegistry``,
|
|
``ComponentManifestHandler``, and ``ComponentFileWatcher``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
registry: BidiComponentRegistry | None = None,
|
|
manifest_handler: ComponentManifestHandler | None = None,
|
|
file_watcher: ComponentFileWatcher | None = None,
|
|
) -> None:
|
|
"""Initialize the component manager.
|
|
|
|
Parameters
|
|
----------
|
|
registry : BidiComponentRegistry, optional
|
|
Component registry instance. If not provided, a new one will be created.
|
|
manifest_handler : ComponentManifestHandler, optional
|
|
Manifest handler instance. If not provided, a new one will be created.
|
|
file_watcher : ComponentFileWatcher, optional
|
|
File watcher instance. If not provided, a new one will be created.
|
|
"""
|
|
# Create dependencies
|
|
self._registry = registry or BidiComponentRegistry()
|
|
self._manifest_handler = manifest_handler or ComponentManifestHandler()
|
|
# Store API inputs for re-resolution on change events
|
|
self._api_inputs: dict[str, _ApiInputs] = {}
|
|
self._api_inputs_lock = threading.Lock()
|
|
self._file_watcher = file_watcher or ComponentFileWatcher(
|
|
self._on_components_changed
|
|
)
|
|
|
|
def record_api_inputs(
|
|
self, component_key: str, css: str | None, js: str | None
|
|
) -> None:
|
|
"""Record original API inputs for later re-resolution on file changes.
|
|
|
|
Parameters
|
|
----------
|
|
component_key : str
|
|
Fully-qualified component name.
|
|
css : str | None
|
|
Inline CSS or a path/glob to a CSS file within the component's
|
|
``asset_dir``.
|
|
js : str | None
|
|
Inline JavaScript or a path/glob to a JS file within the component's
|
|
``asset_dir``.
|
|
"""
|
|
with self._api_inputs_lock:
|
|
self._api_inputs[component_key] = _ApiInputs(css=css, js=js)
|
|
|
|
def register_from_manifest(
|
|
self, manifest: ComponentManifest, package_root: Path
|
|
) -> None:
|
|
"""Register components from a manifest file.
|
|
|
|
This is a high-level method that processes the manifest and registers
|
|
all components found within it.
|
|
|
|
Parameters
|
|
----------
|
|
manifest : ComponentManifest
|
|
The component manifest to process.
|
|
package_root : Path
|
|
Root path of the package containing the components.
|
|
"""
|
|
# First process the manifest
|
|
component_definitions = self._manifest_handler.process_manifest(
|
|
manifest, package_root
|
|
)
|
|
|
|
# Register all component definitions
|
|
self._registry.register_components_from_definitions(component_definitions)
|
|
|
|
_LOGGER.debug(
|
|
"Registered %d components from manifest", len(component_definitions)
|
|
)
|
|
|
|
def register(self, definition: BidiComponentDefinition) -> None:
|
|
"""Register a single component definition.
|
|
|
|
Parameters
|
|
----------
|
|
definition : BidiComponentDefinition
|
|
The component definition to register.
|
|
"""
|
|
self._registry.register(definition)
|
|
|
|
def get(self, name: str) -> BidiComponentDefinition | None:
|
|
"""Get a component definition by name.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The name of the component to retrieve.
|
|
|
|
Returns
|
|
-------
|
|
BidiComponentDefinition or None
|
|
The component definition if found; otherwise ``None``.
|
|
"""
|
|
return self._registry.get(name)
|
|
|
|
def build_definition_with_validation(
|
|
self,
|
|
*,
|
|
component_key: str,
|
|
html: str | None,
|
|
css: str | None,
|
|
js: str | None,
|
|
) -> BidiComponentDefinition:
|
|
"""Build a validated component definition for the given inputs.
|
|
|
|
Parameters
|
|
----------
|
|
component_key : str
|
|
Fully-qualified component name the definition is for.
|
|
html : str | None
|
|
Inline HTML content to include in the definition.
|
|
css : str | None
|
|
Inline CSS content or a path/glob under the component's asset_dir.
|
|
js : str | None
|
|
Inline JS content or a path/glob under the component's asset_dir.
|
|
|
|
Returns
|
|
-------
|
|
BidiComponentDefinition
|
|
The fully validated component definition.
|
|
"""
|
|
return build_definition_with_validation(
|
|
manager=self,
|
|
component_key=component_key,
|
|
html=html,
|
|
css=css,
|
|
js=js,
|
|
)
|
|
|
|
def get_component_asset_root(self, name: str) -> Path | None:
|
|
"""Get the asset root for a manifest-backed component.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The name of the component to get the asset root for.
|
|
|
|
Returns
|
|
-------
|
|
Path or None
|
|
The component's ``asset_root`` directory if found; otherwise
|
|
``None``.
|
|
"""
|
|
return self._manifest_handler.get_asset_root(name)
|
|
|
|
def unregister(self, name: str) -> None:
|
|
"""Unregister a component by name.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The name of the component to unregister.
|
|
"""
|
|
self._registry.unregister(name)
|
|
|
|
def clear(self) -> None:
|
|
"""Clear all registered components."""
|
|
self._registry.clear()
|
|
|
|
def get_component_path(self, name: str) -> str | None:
|
|
"""Get the filesystem path for a manifest-backed component.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The name of the component.
|
|
|
|
Returns
|
|
-------
|
|
str or None
|
|
The component's ``asset_dir`` directory if found; otherwise
|
|
``None``.
|
|
"""
|
|
asset_root = self._manifest_handler.get_asset_root(name)
|
|
if asset_root is not None:
|
|
return str(asset_root)
|
|
|
|
return None
|
|
|
|
def start_file_watching(self) -> None:
|
|
"""Start file watching for component changes."""
|
|
if self._file_watcher.is_watching_active:
|
|
_LOGGER.warning("File watching is already started")
|
|
return
|
|
|
|
# Get asset watch roots from manifest handler
|
|
asset_roots = self._manifest_handler.get_asset_watch_roots()
|
|
|
|
# Start file watching
|
|
self._file_watcher.start_file_watching(asset_roots)
|
|
|
|
if self._file_watcher.is_watching_active:
|
|
_LOGGER.debug("Started file watching for component changes") # type: ignore[unreachable]
|
|
else:
|
|
_LOGGER.debug("File watching not started")
|
|
|
|
def discover_and_register_components(self) -> None:
|
|
"""Discover installed v2 components and register them.
|
|
|
|
This scans installed distributions for manifests, registers all discovered
|
|
components, and starts file watching for development workflows.
|
|
"""
|
|
try:
|
|
from streamlit.components.v2.manifest_scanner import (
|
|
scan_component_manifests,
|
|
)
|
|
|
|
manifests = scan_component_manifests()
|
|
for manifest, package_root in manifests:
|
|
self.register_from_manifest(manifest, package_root)
|
|
_LOGGER.info(
|
|
"Registered components from pyproject.toml: %s v%s",
|
|
manifest.name,
|
|
manifest.version,
|
|
)
|
|
|
|
# Start file watching for development mode after all components are registered
|
|
self.start_file_watching()
|
|
|
|
except Exception as e:
|
|
_LOGGER.warning("Failed to scan component manifests: %s", e)
|
|
|
|
def stop_file_watching(self) -> None:
|
|
"""Stop file watching."""
|
|
if not self._file_watcher.is_watching_active:
|
|
_LOGGER.warning("File watching is not started")
|
|
return
|
|
|
|
self._file_watcher.stop_file_watching()
|
|
|
|
_LOGGER.debug("Stopped file watching")
|
|
|
|
def get_metadata(self, component_name: str) -> ComponentManifest | None:
|
|
"""Get metadata for a component.
|
|
|
|
Parameters
|
|
----------
|
|
component_name : str
|
|
The name of the component to get metadata for.
|
|
|
|
Returns
|
|
-------
|
|
ComponentManifest or None
|
|
The component metadata if found; otherwise ``None``.
|
|
"""
|
|
return self._manifest_handler.get_metadata(component_name)
|
|
|
|
def _on_components_changed(self, component_names: list[str]) -> None:
|
|
"""Handle change events for components' asset roots.
|
|
|
|
For each component, re-resolve from stored API inputs and update the
|
|
registry with the new definition if resolution succeeds.
|
|
|
|
Parameters
|
|
----------
|
|
component_names : list[str]
|
|
Fully-qualified component names whose watched files changed.
|
|
"""
|
|
for name in component_names:
|
|
try:
|
|
updated_def = self._recompute_definition_from_api(name)
|
|
if updated_def is not None:
|
|
self._registry.update_component(updated_def)
|
|
except Exception: # noqa: PERF203
|
|
_LOGGER.exception("Failed to update component after change: %s", name)
|
|
|
|
def _recompute_definition_from_api(
|
|
self, component_name: str
|
|
) -> BidiComponentDefinition | None:
|
|
"""Recompute a component's definition using previously recorded API inputs.
|
|
|
|
Parameters
|
|
----------
|
|
component_name : str
|
|
Fully-qualified component name to recompute.
|
|
|
|
Returns
|
|
-------
|
|
BidiComponentDefinition | None
|
|
A fully validated component definition suitable for replacing the
|
|
stored entry in the registry, or ``None`` if recomputation failed
|
|
or no API inputs were previously recorded.
|
|
"""
|
|
with self._api_inputs_lock:
|
|
inputs = self._api_inputs.get(component_name)
|
|
if inputs is None:
|
|
return None
|
|
|
|
# Get existing def to preserve html unless new content is provided later
|
|
existing_def = self._registry.get(component_name)
|
|
html_value = existing_def.html if existing_def else None
|
|
|
|
try:
|
|
# Resolve a fully validated definition from stored API inputs and
|
|
# the preserved html value from the existing definition.
|
|
new_def = self.build_definition_with_validation(
|
|
component_key=component_name,
|
|
html=html_value,
|
|
css=inputs.css,
|
|
js=inputs.js,
|
|
)
|
|
except Exception as e:
|
|
_LOGGER.debug(
|
|
"Skipping update for %s due to re-resolution error: %s",
|
|
component_name,
|
|
e,
|
|
)
|
|
return None
|
|
|
|
return new_def
|
|
|
|
@property
|
|
def is_file_watching_started(self) -> bool:
|
|
"""Check if file watching is currently active.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
True if file watching is started, False otherwise
|
|
"""
|
|
return self._file_watcher.is_watching_active
|