DriverTrac/venv/lib/python3.12/site-packages/streamlit/components/v2/component_manager.py

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