From a613b165d08c3fe3b72e3e84790dc27f8acb0444 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 13:11:24 -0400 Subject: [PATCH 1/5] Move the Qt-free tier to formbridge Every module that needs no Qt moves to formbridge with its path; consumers import it from there (no re-exports). UiThreadDispatcher keeps only Qt delivery over formbridge's MainThreadDispatcher. The unused animation.flash_trace re-export shim is deleted. Co-Authored-By: Claude Opus 5.5 --- src/pyqt_reactive/animation/flash_mixin.py | 4 +- src/pyqt_reactive/animation/flash_trace.py | 11 - src/pyqt_reactive/core/log_utils.py | 348 ----- src/pyqt_reactive/core/path_cache.py | 141 -- src/pyqt_reactive/core/performance_monitor.py | 226 ---- src/pyqt_reactive/core/sort_utils.py | 16 - src/pyqt_reactive/flash_trace.py | 74 - src/pyqt_reactive/forms/form_init_service.py | 14 +- .../forms/inline_dataclass_chrome.py | 2 +- .../forms/inline_dataclass_context.py | 4 +- src/pyqt_reactive/forms/layout_constants.py | 106 -- .../forms/parameter_form_base.py | 59 - .../forms/parameter_form_constants.py | 149 --- .../forms/parameter_form_manager.py | 16 +- .../forms/parameter_form_service.py | 924 ------------- .../forms/parameter_info_types.py | 350 ----- .../forms/parameter_type_utils.py | 224 ---- .../forms/parameter_value_contracts.py | 49 - src/pyqt_reactive/forms/ui_utils.py | 55 - .../forms/widget_creation_config.py | 8 +- .../forms/widget_creation_types.py | 2 +- src/pyqt_reactive/forms/widget_strategies.py | 18 +- src/pyqt_reactive/pattern_metadata.py | 28 - src/pyqt_reactive/protocols/__init__.py | 43 - .../protocols/codegen_provider.py | 49 - .../protocols/component_selection.py | 114 -- src/pyqt_reactive/protocols/form_config.py | 60 - src/pyqt_reactive/protocols/log_providers.py | 64 - .../protocols/preview_formatter.py | 91 -- .../services/async_operation_executor.py | 87 -- .../services/coalesced_scan_requests.py | 40 - .../services/code_editor_form_updater.py | 236 ---- .../services/enabled_field_styling_service.py | 2 +- .../services/enum_dispatch_service.py | 171 --- .../services/executable_action.py | 32 - .../services/field_change_dispatcher.py | 2 +- .../services/flag_context_manager.py | 228 ---- .../services/function_list_editor_actions.py | 4 +- .../services/function_navigation.py | 118 -- .../function_pattern_code_document.py | 1190 ----------------- src/pyqt_reactive/services/help_document.py | 203 --- .../services/interval_snapshot_poller.py | 137 -- .../services/manager_host_abc.py | 20 - .../services/parameter_help_service.py | 462 ------- .../services/parameter_ops_service.py | 6 +- .../services/parameter_service_abc.py | 207 --- .../services/pattern_data_manager.py | 248 ---- .../services/persistent_system_monitor.py | 10 +- src/pyqt_reactive/services/process_tracker.py | 286 ---- .../services/scope_color_service.py | 2 +- .../services/scope_token_service.py | 455 ------- src/pyqt_reactive/services/search_service.py | 94 -- .../services/service_registry.py | 137 -- .../services/system_metrics_sampler.py | 512 ------- .../services/system_monitor_actions.py | 2 +- .../services/system_monitor_config.py | 107 -- .../services/system_monitor_core.py | 181 --- src/pyqt_reactive/services/tab_identity.py | 17 - .../services/time_travel_navigation.py | 2 +- src/pyqt_reactive/services/tree_item_key.py | 26 - .../services/ui_thread_dispatch.py | 186 +-- .../services/value_collection_service.py | 2 +- .../services/widget_tree_projection.py | 2 +- .../services/widget_tree_projection_config.py | 193 --- .../services/window_code_document.py | 89 -- src/pyqt_reactive/services/window_manager.py | 2 +- src/pyqt_reactive/services/window_snapshot.py | 5 +- src/pyqt_reactive/services/zmq_server_info.py | 105 -- .../services/zmq_server_scan_service.py | 412 ------ src/pyqt_reactive/strategies/__init__.py | 33 - .../strategies/preview_formatting.py | 263 ---- .../strategies/status_presentation.py | 37 - .../strategies/tree_aggregation.py | 48 - src/pyqt_reactive/theming/style_generator.py | 2 +- src/pyqt_reactive/utils/__init__.py | 26 +- .../utils/log_highlight_client.py | 102 -- src/pyqt_reactive/utils/log_highlighter.py | 99 -- src/pyqt_reactive/utils/log_loader.py | 23 - src/pyqt_reactive/utils/log_streamer.py | 59 - src/pyqt_reactive/utils/preview_formatters.py | 149 --- src/pyqt_reactive/utils/styling_utils.py | 30 - src/pyqt_reactive/weak_events.py | 106 -- .../widgets/editors/simple_code_editor.py | 10 +- .../widgets/enhanced_path_widget.py | 2 +- .../widgets/function_list_editor.py | 16 +- src/pyqt_reactive/widgets/function_pane.py | 12 +- .../widgets/help_document_browser.py | 2 +- src/pyqt_reactive/widgets/log_viewer.py | 12 +- src/pyqt_reactive/widgets/shared/__init__.py | 12 - .../widgets/shared/abstract_manager_widget.py | 25 +- .../widgets/shared/abstract_table_browser.py | 2 +- .../shared/action_tabbed_window_body.py | 5 +- .../widgets/shared/base_form_dialog.py | 2 +- .../widgets/shared/base_managed_window.py | 2 +- .../shared/clickable_help_components.py | 10 +- .../widgets/shared/column_filter_widget.py | 2 +- .../shared/manager_action_controller.py | 2 +- .../widgets/shared/manager_item_access.py | 4 +- .../shared/manager_item_display_builder.py | 4 +- .../widgets/shared/manager_item_hooks.py | 78 -- .../shared/manager_preview_formatting.py | 46 - .../shared/manager_reorder_controller.py | 49 - .../widgets/shared/manager_state_binding.py | 25 - .../shared/manager_status_controller.py | 2 +- .../shared/manager_time_travel_binding.py | 84 -- .../widgets/shared/manager_workflows.py | 82 -- .../shared/responsive_layout_widgets.py | 5 +- .../widgets/shared/scope_color_receiver.py | 13 - .../widgets/shared/scope_color_strategy.py | 184 --- .../widgets/shared/scoped_table_widget.py | 2 +- .../widgets/shared/tree_state_adapter.py | 2 +- .../widgets/shared/tree_sync_adapter.py | 2 +- .../shared/zmq_server_browser_widget.py | 8 +- src/pyqt_reactive/widgets/system_monitor.py | 9 +- src/pyqt_reactive/windows/config_window.py | 6 +- .../windows/help_window_manager.py | 4 +- tests/test_action_tabbed_window_body_lazy.py | 4 +- tests/test_annotation_choice_fields.py | 2 +- tests/test_async_operation_executor.py | 83 -- tests/test_background_process_consumers.py | 90 -- tests/test_code_editor_form_updater.py | 149 --- tests/test_compact_dataclass_projection.py | 77 -- ...est_explicit_function_pattern_overrides.py | 140 -- tests/test_function_list_editor_tokens.py | 6 +- tests/test_help_document.py | 199 --- tests/test_help_document_browser.py | 2 +- tests/test_host_provider_contracts.py | 54 - tests/test_inline_dataclass_chrome.py | 2 +- tests/test_log_viewer_lifecycle.py | 10 +- tests/test_parameter_form_service.py | 234 ---- tests/test_parameter_help_service.py | 54 - tests/test_path_name_roles.py | 2 +- tests/test_pattern_data_manager.py | 19 - tests/test_preview_configuration_imports.py | 34 - tests/test_preview_formatter_registry.py | 57 - tests/test_preview_formatters.py | 8 +- tests/test_reset_flash_changes.py | 2 +- tests/test_scoped_table_widget.py | 2 +- tests/test_system_metrics_sampler.py | 264 ---- tests/test_system_monitor_config.py | 4 +- tests/test_ui_thread_dispatch.py | 20 +- tests/test_weak_events.py | 101 -- tests/test_widget_projection_safety.py | 18 +- tests/test_widgets.py | 13 +- tests/test_window_code_document.py | 108 -- tests/test_window_snapshot.py | 12 +- tests/test_zmq_server_browser_lifecycle.py | 2 +- 147 files changed, 174 insertions(+), 12769 deletions(-) delete mode 100644 src/pyqt_reactive/animation/flash_trace.py delete mode 100644 src/pyqt_reactive/core/log_utils.py delete mode 100644 src/pyqt_reactive/core/path_cache.py delete mode 100644 src/pyqt_reactive/core/performance_monitor.py delete mode 100644 src/pyqt_reactive/core/sort_utils.py delete mode 100644 src/pyqt_reactive/flash_trace.py delete mode 100644 src/pyqt_reactive/forms/layout_constants.py delete mode 100644 src/pyqt_reactive/forms/parameter_form_base.py delete mode 100644 src/pyqt_reactive/forms/parameter_form_constants.py delete mode 100644 src/pyqt_reactive/forms/parameter_form_service.py delete mode 100644 src/pyqt_reactive/forms/parameter_info_types.py delete mode 100644 src/pyqt_reactive/forms/parameter_type_utils.py delete mode 100644 src/pyqt_reactive/forms/parameter_value_contracts.py delete mode 100644 src/pyqt_reactive/forms/ui_utils.py delete mode 100644 src/pyqt_reactive/pattern_metadata.py delete mode 100644 src/pyqt_reactive/protocols/codegen_provider.py delete mode 100644 src/pyqt_reactive/protocols/component_selection.py delete mode 100644 src/pyqt_reactive/protocols/form_config.py delete mode 100644 src/pyqt_reactive/protocols/log_providers.py delete mode 100644 src/pyqt_reactive/protocols/preview_formatter.py delete mode 100644 src/pyqt_reactive/services/async_operation_executor.py delete mode 100644 src/pyqt_reactive/services/coalesced_scan_requests.py delete mode 100644 src/pyqt_reactive/services/code_editor_form_updater.py delete mode 100644 src/pyqt_reactive/services/enum_dispatch_service.py delete mode 100644 src/pyqt_reactive/services/executable_action.py delete mode 100644 src/pyqt_reactive/services/flag_context_manager.py delete mode 100644 src/pyqt_reactive/services/function_navigation.py delete mode 100644 src/pyqt_reactive/services/function_pattern_code_document.py delete mode 100644 src/pyqt_reactive/services/help_document.py delete mode 100644 src/pyqt_reactive/services/interval_snapshot_poller.py delete mode 100644 src/pyqt_reactive/services/manager_host_abc.py delete mode 100644 src/pyqt_reactive/services/parameter_help_service.py delete mode 100644 src/pyqt_reactive/services/parameter_service_abc.py delete mode 100644 src/pyqt_reactive/services/pattern_data_manager.py delete mode 100644 src/pyqt_reactive/services/process_tracker.py delete mode 100644 src/pyqt_reactive/services/scope_token_service.py delete mode 100644 src/pyqt_reactive/services/search_service.py delete mode 100644 src/pyqt_reactive/services/service_registry.py delete mode 100644 src/pyqt_reactive/services/system_metrics_sampler.py delete mode 100644 src/pyqt_reactive/services/system_monitor_config.py delete mode 100644 src/pyqt_reactive/services/system_monitor_core.py delete mode 100644 src/pyqt_reactive/services/tab_identity.py delete mode 100644 src/pyqt_reactive/services/tree_item_key.py delete mode 100644 src/pyqt_reactive/services/widget_tree_projection_config.py delete mode 100644 src/pyqt_reactive/services/window_code_document.py delete mode 100644 src/pyqt_reactive/services/zmq_server_info.py delete mode 100644 src/pyqt_reactive/services/zmq_server_scan_service.py delete mode 100644 src/pyqt_reactive/strategies/__init__.py delete mode 100644 src/pyqt_reactive/strategies/preview_formatting.py delete mode 100644 src/pyqt_reactive/strategies/status_presentation.py delete mode 100644 src/pyqt_reactive/strategies/tree_aggregation.py delete mode 100644 src/pyqt_reactive/utils/log_highlight_client.py delete mode 100644 src/pyqt_reactive/utils/log_highlighter.py delete mode 100644 src/pyqt_reactive/utils/log_loader.py delete mode 100644 src/pyqt_reactive/utils/log_streamer.py delete mode 100644 src/pyqt_reactive/utils/preview_formatters.py delete mode 100644 src/pyqt_reactive/utils/styling_utils.py delete mode 100644 src/pyqt_reactive/weak_events.py delete mode 100644 src/pyqt_reactive/widgets/shared/manager_item_hooks.py delete mode 100644 src/pyqt_reactive/widgets/shared/manager_preview_formatting.py delete mode 100644 src/pyqt_reactive/widgets/shared/manager_reorder_controller.py delete mode 100644 src/pyqt_reactive/widgets/shared/manager_state_binding.py delete mode 100644 src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py delete mode 100644 src/pyqt_reactive/widgets/shared/manager_workflows.py delete mode 100644 src/pyqt_reactive/widgets/shared/scope_color_receiver.py delete mode 100644 src/pyqt_reactive/widgets/shared/scope_color_strategy.py delete mode 100644 tests/test_async_operation_executor.py delete mode 100644 tests/test_background_process_consumers.py delete mode 100644 tests/test_code_editor_form_updater.py delete mode 100644 tests/test_compact_dataclass_projection.py delete mode 100644 tests/test_explicit_function_pattern_overrides.py delete mode 100644 tests/test_help_document.py delete mode 100644 tests/test_host_provider_contracts.py delete mode 100644 tests/test_parameter_form_service.py delete mode 100644 tests/test_parameter_help_service.py delete mode 100644 tests/test_pattern_data_manager.py delete mode 100644 tests/test_preview_configuration_imports.py delete mode 100644 tests/test_preview_formatter_registry.py delete mode 100644 tests/test_system_metrics_sampler.py delete mode 100644 tests/test_weak_events.py delete mode 100644 tests/test_window_code_document.py diff --git a/src/pyqt_reactive/animation/flash_mixin.py b/src/pyqt_reactive/animation/flash_mixin.py index bcd081f0..6fc6f59b 100644 --- a/src/pyqt_reactive/animation/flash_mixin.py +++ b/src/pyqt_reactive/animation/flash_mixin.py @@ -80,10 +80,10 @@ from objectstate.time_travel_profile import TimeTravelProfiler from pyqt_reactive.animation.flash_config import FlashConfig, FlashPlayback, FlashPhase, get_flash_config -from pyqt_reactive.forms.layout_constants import default_container_corner_radius_px +from formbridge.forms.layout_constants import default_container_corner_radius_px from pyqt_reactive.services.window_snapshot import FlashPaintElement, FlashPaintFrame -from ..flash_trace import flash_trace +from formbridge.flash_trace import flash_trace from objectstate import ScopeKey diff --git a/src/pyqt_reactive/animation/flash_trace.py b/src/pyqt_reactive/animation/flash_trace.py deleted file mode 100644 index 616de393..00000000 --- a/src/pyqt_reactive/animation/flash_trace.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Structured diagnostics for the PyQt flash pipeline.""" - -from __future__ import annotations - - -from ..flash_trace import ( - FlashTrace as FlashTrace, - FlashTraceRecord as FlashTraceRecord, - flash_trace as flash_trace, - logger as logger, -) diff --git a/src/pyqt_reactive/core/log_utils.py b/src/pyqt_reactive/core/log_utils.py deleted file mode 100644 index e199de4c..00000000 --- a/src/pyqt_reactive/core/log_utils.py +++ /dev/null @@ -1,348 +0,0 @@ -""" -Core Log Utilities for pyqt-reactor. - -Unified log discovery, classification, and monitoring utilities -shared between UI implementations. -""" - -import logging -import time -from collections.abc import Callable -from dataclasses import dataclass -from enum import Enum, nonmember -from pathlib import Path -from typing import List, Optional - -from zmqruntime.execution.logs import ExecutionWorkerLogIdentity -from zmqruntime.messages import ProcessIdentity - -logger = logging.getLogger(__name__) - - -LogDisplayNameResolver = Callable[[Path, str | None], str] - - -class LogType(str, Enum): - """Closed log roles with member-owned presentation and ordering semantics.""" - - _path_name = nonmember(lambda path, worker_id: path.name) - _main_process = nonmember(lambda path, worker_id: "Main Process") - _main_subprocess = nonmember(lambda path, worker_id: "Main Subprocess") - _worker = nonmember( - lambda path, worker_id: ( - f"Worker {worker_id}" if worker_id is not None else path.name - ) - ) - - def __new__( - cls, - value: str, - display_name_resolver: LogDisplayNameResolver, - sort_priority: int, - retained_on_clear: bool = False, - ) -> "LogType": - member = str.__new__(cls, value) - member._value_ = value - member._display_name_resolver = display_name_resolver - member.sort_priority = sort_priority - member.retained_on_clear = retained_on_clear - return member - - TUI = ("tui", _main_process, 0, True) - MAIN = ("main", _main_subprocess, 1) - WORKER = ("worker", _worker, 2) - ZMQ_SERVER = ("zmq_server", _path_name, 3) - ZMQ_WORKER = ("zmq_worker", _path_name, 3) - NAPARI = ("napari", _path_name, 3) - UNKNOWN = ("unknown", _path_name, 3) - - def display_name_for(self, path: Path, worker_id: str | None) -> str: - """Resolve this member's default display name.""" - - return self._display_name_resolver(path, worker_id) - - -def _get_log_dir() -> Path: - """Return configured log directory or default.""" - from pyqt_reactive.protocols.form_config import get_form_config - - config = get_form_config() - if config.log_dir: - return Path(config.log_dir) - return Path.home() / ".local" / "share" / "pyqt_reactive" / "logs" - - -def _get_log_prefixes() -> List[str]: - """Return configured log prefixes or default.""" - from pyqt_reactive.protocols.form_config import get_form_config - - config = get_form_config() - return config.log_prefixes or ["pyqt_reactive_"] - - -def _match_prefixed(file_name: str, suffix: str) -> Optional[str]: - """Return matching prefix for a file name if it starts with prefix+suffix.""" - for prefix in _get_log_prefixes(): - if file_name.startswith(f"{prefix}{suffix}"): - return prefix - return None - - -def get_current_log_file_path() -> str: - """Get the current log file path from the logging system.""" - from pyqt_reactive.protocols.form_config import get_form_config - - try: - # Get the root logger and find the FileHandler - root_logger = logging.getLogger() - for handler in root_logger.handlers: - if isinstance(handler, logging.FileHandler): - return handler.baseFilename - - # Fallback: try to get from configured logger name - config = get_form_config() - if config.log_root_logger_name: - root_named_logger = logging.getLogger(config.log_root_logger_name) - for handler in root_named_logger.handlers: - if isinstance(handler, logging.FileHandler): - return handler.baseFilename - - # Last resort: create a default path - log_dir = _get_log_dir() - log_dir.mkdir(parents=True, exist_ok=True) - prefix = (_get_log_prefixes() or ["pyqt_reactive_"])[0] - return str(log_dir / f"{prefix}subprocess_{int(time.time())}.log") - - except Exception as e: - logger.error(f"Failed to get current log file path: {e}") - raise RuntimeError(f"Could not determine log file path: {e}") - - -@dataclass(slots=True) -class LogFileInfo: - """Information about a discovered log file.""" - - path: Path - log_type: LogType - worker_id: Optional[str] = None - display_name: Optional[str] = None - process_identity: Optional[ProcessIdentity] = None - - def __post_init__(self): - """Generate display name if not provided.""" - if self.display_name is None: - self.display_name = self.log_type.display_name_for( - self.path, - self.worker_id, - ) - - -def discover_logs(base_log_path: Optional[str] = None, include_main_log: bool = True, - log_directory: Optional[Path] = None) -> List[LogFileInfo]: - """ - Discover application log files and return as classified LogFileInfo objects. - - Args: - base_log_path: Base path for specific subprocess logs (optional) - include_main_log: Whether to include the current main process log - log_directory: Directory to search (defaults to configured log directory) - - Returns: - List of LogFileInfo objects for discovered log files - """ - discovered_logs = [] - - # Include current main process log if requested - if include_main_log: - try: - main_log_path = get_current_log_file_path() - main_log = Path(main_log_path) - if main_log.exists(): - log_info = classify_log_file(main_log, base_log_path, include_main_log) - discovered_logs.append(log_info) - except Exception: - pass # Main log not available, continue - - # Discover subprocess logs if base_log_path is provided - if base_log_path: - base_path = Path(base_log_path) - log_dir = base_path.parent - if log_dir.exists(): - for log_file in log_dir.glob("*.log"): - if is_relevant_log_file(log_file, base_log_path): - log_info = classify_log_file(log_file, base_log_path, include_main_log) - discovered_logs.append(log_info) - - # Discover all logs if no specific base_log_path - elif log_directory or not base_log_path: - if log_directory is None: - log_directory = _get_log_dir() - - if log_directory.exists(): - for log_file in log_directory.glob("*.log"): - if is_app_log_file(log_file) and log_file not in [log.path for log in discovered_logs]: - # Infer base_log_path for proper classification - inferred_base = infer_base_log_path(log_file) if 'subprocess_' in log_file.name else None - log_info = classify_log_file(log_file, inferred_base, include_main_log) - discovered_logs.append(log_info) - - return discovered_logs - - -def classify_log_file(log_path: Path, base_log_path: Optional[str] = None, include_tui_log: bool = True) -> LogFileInfo: - """ - Pure function: Classify a log file and extract metadata. - - Args: - log_path: Path to log file - base_log_path: Base path for subprocess log files - include_tui_log: Whether to check for TUI log classification - - Returns: - LogFileInfo with classification and metadata - """ - file_name = log_path.name - - # Check if it's the current TUI log - if include_tui_log: - try: - tui_log_path = get_current_log_file_path() - if log_path == Path(tui_log_path): - return LogFileInfo( - log_path, - LogType.TUI, - process_identity=ProcessIdentity.current(), - ) - except RuntimeError: - pass # TUI log not found, continue with other classification - - # Check for ZMQ server logs (zmq_server_port_{port}_{timestamp}.log) - prefix = _match_prefixed(file_name, "zmq_server_port_") - if prefix: - # Extract port from filename - parts = file_name.replace(f'{prefix}zmq_server_port_', '').replace('.log', '').split('_') - port = parts[0] if parts else 'unknown' - return LogFileInfo( - log_path, - LogType.ZMQ_SERVER, - display_name=f"ZMQ Server (port {port})", - ) - - # Check for ZMQ worker logs - worker_log = ExecutionWorkerLogIdentity.from_path(log_path) - if worker_log is not None: - worker_id = str(worker_log.worker_pid) - return LogFileInfo( - log_path, - LogType.ZMQ_WORKER, - worker_id, - display_name=f"ZMQ Worker {worker_id}", - ) - - # Check for Napari viewer logs - if file_name.startswith('napari_detached_port_'): - port = file_name.replace('napari_detached_port_', '').replace('.log', '') - return LogFileInfo( - log_path, - LogType.NAPARI, - display_name=f"Napari Viewer (port {port})", - ) - - # Check subprocess logs if base_log_path is provided - if base_log_path: - base_name = Path(base_log_path).name - - # Check if it's the main subprocess log: exact match - if file_name == f"{base_name}.log": - return LogFileInfo(log_path, LogType.MAIN) - - # Check if it's a worker log: {base_name}_worker_*.log - if file_name.startswith(f"{base_name}_worker_") and file_name.endswith('.log'): - # Extract worker ID (everything between _worker_ and .log) - worker_part = file_name[len(f"{base_name}_worker_"):-4] # Remove .log suffix - worker_id = worker_part.split('_')[0] # Take first part before any additional underscores - return LogFileInfo(log_path, LogType.WORKER, worker_id) - - # Unknown or malformed log file - logger.debug(f"Unrecognized log file pattern: {file_name}") - return LogFileInfo(log_path, LogType.UNKNOWN) - - -def is_relevant_log_file(file_path: Path, base_log_path: Optional[str]) -> bool: - """ - Check if file is a relevant log file for monitoring. - - Args: - file_path: Path to file to check - base_log_path: Base path for subprocess log files - - Returns: - bool: True if file is relevant for monitoring - """ - if not base_log_path: - return False - - base_name = Path(base_log_path).name - file_name = file_path.name - - # Check if it matches our patterns - if file_name == f"{base_name}.log": - return True - - if file_name.startswith(f"{base_name}_worker_") and file_name.endswith('.log'): - return True - - return False - - -def is_app_log_file(file_path: Path) -> bool: - """ - Check if a file is a recognized application log file. - - Args: - file_path: Path to file to check - - Returns: - bool: True if file matches configured log prefixes - """ - if not file_path.name.endswith('.log'): - return False - - file_name = file_path.name - - # App log patterns based on configured prefixes, plus common auxiliary logs - prefixes = _get_log_prefixes() - extra_patterns = [ - "pyqt_gui_subprocess_", - "zmq_worker_", - "napari_detached_", - ] - patterns = [*prefixes, *extra_patterns] - - return any(file_name.startswith(pattern) for pattern in patterns) - - -def infer_base_log_path(file_path: Path) -> str: - """ - Infer the base log path from a subprocess log file name. - - Args: - file_path: Path to subprocess log file - - Returns: - str: Inferred base log path - """ - file_name = file_path.name - - # Handle worker logs: remove _worker_* suffix - if '_worker_' in file_name: - base_name = file_name.split('_worker_')[0] - else: - # Handle main subprocess logs: remove .log extension - base_name = file_path.stem - - return str(file_path.parent / base_name) - - -# Backward compatibility alias -is_openhcs_log_file = is_app_log_file diff --git a/src/pyqt_reactive/core/path_cache.py b/src/pyqt_reactive/core/path_cache.py deleted file mode 100644 index 33395c3e..00000000 --- a/src/pyqt_reactive/core/path_cache.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Last-used directories remembered per dialog context across sessions. - -A context is a :class:`PathCacheKey` subclass. The family is open: applications -declare their own contexts by subclassing, and each context's key in the cache -file is derived from its class name. -""" - -import json -import logging -import re -from pathlib import Path - -from metaclass_registry import AutoRegisterMeta - -logger = logging.getLogger(__name__) - - -def _stored_key(name: str, cls: type) -> str | None: - if "__registry__" in vars(cls): - return None - return re.sub(r"(? None: - try: - if self.cache_file.exists(): - with open(self.cache_file) as f: - self._cache = json.load(f) - except (json.JSONDecodeError, OSError) as e: - logger.warning(f"Failed to load path cache: {e}") - self._cache = {} - - def _save_cache(self) -> None: - try: - self.cache_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.cache_file, "w") as f: - json.dump(self._cache, f, indent=2) - except OSError as e: - logger.warning(f"Failed to save path cache: {e}") - - def get_cached_path(self, key: type[PathCacheKey]) -> Path | None: - """Return the remembered directory for ``key`` if it still exists.""" - - cached_str = self._cache.get(key.stored_key) - if not cached_str: - return None - cached_path = Path(cached_str) - if cached_path.exists(): - return cached_path - del self._cache[key.stored_key] - self._save_cache() - return None - - def set_cached_path(self, key: type[PathCacheKey], path: Path) -> None: - """Remember ``path`` for ``key``; a path that does not exist is ignored.""" - - if path and path.exists(): - self._cache[key.stored_key] = str(path) - self._save_cache() - else: - logger.warning(f"Attempted to cache non-existent path for {key.stored_key}: {path}") - - def get_initial_path(self, key: type[PathCacheKey], fallback: Path | None = None) -> Path: - """Return the remembered directory, else ``fallback``, else the home directory.""" - - cached = self.get_cached_path(key) - if cached: - return cached - if fallback and fallback.exists(): - return fallback - return Path.home() - - -_global_path_cache: UnifiedPathCache | None = None - - -def get_path_cache() -> UnifiedPathCache: - """Return the process-wide path cache.""" - - global _global_path_cache - if _global_path_cache is None: - _global_path_cache = UnifiedPathCache() - return _global_path_cache - - -def cache_path(key: type[PathCacheKey], path: Path) -> None: - """Remember ``path`` for ``key`` in the process-wide cache.""" - - get_path_cache().set_cached_path(key, path) - - -def get_initial_path(key: type[PathCacheKey], fallback: Path | None = None) -> Path: - """Return the starting directory for a dialog opened in context ``key``.""" - - return get_path_cache().get_initial_path(key, fallback) diff --git a/src/pyqt_reactive/core/performance_monitor.py b/src/pyqt_reactive/core/performance_monitor.py deleted file mode 100644 index 2dccb97f..00000000 --- a/src/pyqt_reactive/core/performance_monitor.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Performance monitoring utilities for pyqt-reactor. - -Provides decorators and context managers for timing operations and -logging performance metrics. -""" - -import time -import functools -import logging -import os -from contextlib import contextmanager -from typing import Optional, Callable -from pathlib import Path - -from pyqt_reactive.protocols import get_form_config - -# Create performance logger -_config = get_form_config() -perf_logger = logging.getLogger(_config.performance_logger_name) -perf_logger.setLevel(logging.WARNING) - -# Add file handler for performance logs -_data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) -_log_dir = Path(_config.log_dir) if _config.log_dir else _data_home / "pyqt_reactive" / "logs" -perf_log_file = _log_dir / _config.performance_log_filename -perf_log_file.parent.mkdir(parents=True, exist_ok=True) - -file_handler = logging.FileHandler(perf_log_file) -file_handler.setLevel(logging.DEBUG) -file_handler.setFormatter(logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -)) -perf_logger.addHandler(file_handler) - -# Also log to console -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.WARNING) -console_handler.setFormatter(logging.Formatter( - '⏱️ %(message)s' -)) -perf_logger.addHandler(console_handler) - - -@contextmanager -def timer(operation_name: str, threshold_ms: float = 0.0, log_args: bool = False, **kwargs): - """Context manager for timing operations. - - Args: - operation_name: Name of the operation being timed - threshold_ms: Only log if operation takes longer than this (in milliseconds) - log_args: Whether to log kwargs in the message - **kwargs: Additional context to include in log message - - Example: - with timer("Loading config", threshold_ms=10.0, config_type="GlobalPipelineConfig"): - config = load_config() - """ - start = time.perf_counter() - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000 - - if elapsed_ms >= threshold_ms: - msg = f"{operation_name}: {elapsed_ms:.2f}ms" - if log_args and kwargs: - args_str = ", ".join(f"{k}={v}" for k, v in kwargs.items()) - msg += f" ({args_str})" - - perf_logger.debug(msg) - - -def timed(operation_name: Optional[str] = None, threshold_ms: float = 0.0): - """Decorator for timing function calls. - - Args: - operation_name: Name for the operation (defaults to function name) - threshold_ms: Only log if operation takes longer than this (in milliseconds) - - Example: - @timed("Config loading", threshold_ms=10.0) - def load_config(): - ... - """ - def decorator(func: Callable) -> Callable: - nonlocal operation_name - if operation_name is None: - operation_name = f"{func.__module__}.{func.__qualname__}" - - @functools.wraps(func) - def wrapper(*args, **kwargs): - start = time.perf_counter() - try: - return func(*args, **kwargs) - finally: - elapsed_ms = (time.perf_counter() - start) * 1000 - - if elapsed_ms >= threshold_ms: - perf_logger.debug(f"{operation_name}: {elapsed_ms:.2f}ms") - - return wrapper - return decorator - - -class PerformanceMonitor: - """Accumulates timing statistics for repeated operations. - - Example: - monitor = PerformanceMonitor("Placeholder resolution") - - for field in fields: - with monitor.measure(): - resolve_placeholder(field) - - monitor.report() # Logs summary statistics - """ - - def __init__(self, operation_name: str): - self.operation_name = operation_name - self.timings = [] - self.current_start = None - - @contextmanager - def measure(self): - """Measure a single operation.""" - start = time.perf_counter() - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000 - self.timings.append(elapsed_ms) - - def report(self, log_individual: bool = False): - """Log summary statistics. - - Args: - log_individual: Whether to log each individual timing - """ - if not self.timings: - perf_logger.debug(f"{self.operation_name}: No measurements") - return - - count = len(self.timings) - total_ms = sum(self.timings) - avg_ms = total_ms / count - min_ms = min(self.timings) - max_ms = max(self.timings) - - perf_logger.debug( - f"{self.operation_name} - " - f"Count: {count}, " - f"Total: {total_ms:.2f}ms, " - f"Avg: {avg_ms:.2f}ms, " - f"Min: {min_ms:.2f}ms, " - f"Max: {max_ms:.2f}ms" - ) - - if log_individual: - for i, timing in enumerate(self.timings, 1): - perf_logger.debug(f" #{i}: {timing:.2f}ms") - - def reset(self): - """Clear all timings.""" - self.timings.clear() - - -# Global monitors for common operations -_monitors = {} - - -def get_monitor(operation_name: str) -> PerformanceMonitor: - """Get or create a global monitor for an operation. - - Example: - monitor = get_monitor("Placeholder resolution") - with monitor.measure(): - resolve_placeholder(field) - """ - if operation_name not in _monitors: - _monitors[operation_name] = PerformanceMonitor(operation_name) - return _monitors[operation_name] - - -def report_all_monitors(): - """Report statistics for all global monitors.""" - if not _monitors: - perf_logger.debug("No performance monitors active") - return - - perf_logger.debug("=" * 60) - perf_logger.debug("PERFORMANCE SUMMARY") - perf_logger.debug("=" * 60) - - for monitor in _monitors.values(): - monitor.report() - - perf_logger.debug("=" * 60) - - -def reset_all_monitors(): - """Reset all global monitors.""" - for monitor in _monitors.values(): - monitor.reset() - - -# Convenience function to enable/disable performance logging -_enabled = True - - -def enable_performance_logging(): - """Enable performance logging.""" - global _enabled - _enabled = True - perf_logger.setLevel(logging.DEBUG) - - -def disable_performance_logging(): - """Disable performance logging.""" - global _enabled - _enabled = False - perf_logger.setLevel(logging.WARNING) - - -def is_performance_logging_enabled() -> bool: - """Check if performance logging is enabled.""" - return _enabled diff --git a/src/pyqt_reactive/core/sort_utils.py b/src/pyqt_reactive/core/sort_utils.py deleted file mode 100644 index 4846f93b..00000000 --- a/src/pyqt_reactive/core/sort_utils.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Sorting utilities.""" - -import re -from typing import Iterable, List, TypeVar - -T = TypeVar("T") - - -def natural_sort(items: Iterable[T]) -> List[T]: - """Return a naturally sorted list for human-friendly ordering.""" - def sort_key(value: T): - parts = re.split(r"(\\d+)", str(value)) - return [int(p) if p.isdigit() else p.lower() for p in parts] - - return sorted(list(items), key=sort_key) - diff --git a/src/pyqt_reactive/flash_trace.py b/src/pyqt_reactive/flash_trace.py deleted file mode 100644 index d2a3d201..00000000 --- a/src/pyqt_reactive/flash_trace.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -import logging -import time -from collections import deque -from dataclasses import dataclass -from typing import ( - Any, - Iterable, -) - -logger = logging.getLogger("pyqt_reactive.flash_trace") - - -@dataclass(frozen=True, slots=True) -class FlashTraceRecord: - """One bounded flash-pipeline diagnostic event.""" - - timestamp: float - event: str - fields: tuple[tuple[str, str], ...] - - -class FlashTrace: - """Process-local ring buffer with opt-in logger output for diagnostics.""" - - _records: deque[FlashTraceRecord] = deque(maxlen=300) - - @classmethod - def record(cls, event: str, **fields: Any) -> None: - normalized_fields = tuple( - (name, cls._format_value(value)) - for name, value in fields.items() - if value is not None - ) - cls._records.append( - FlashTraceRecord( - timestamp=time.time(), - event=event, - fields=normalized_fields, - ) - ) - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - "event=%s %s", - event, - " ".join(f"{name}={value}" for name, value in normalized_fields), - ) - - @classmethod - def recent(cls) -> tuple[FlashTraceRecord, ...]: - """Return the recent flash trace ring buffer.""" - return tuple(cls._records) - - @classmethod - def _format_value(cls, value: Any) -> str: - if isinstance(value, (list, tuple, set, frozenset)): - return cls._format_iterable(value) - text = str(value).replace("\n", "\\n") - if len(text) > 220: - return f"{text[:217]}..." - return text - - @classmethod - def _format_iterable(cls, values: Iterable[Any]) -> str: - formatted = [str(value) for value in values] - if len(formatted) > 10: - formatted = [*formatted[:10], f"...+{len(formatted) - 10}"] - return "[" + ",".join(formatted) + "]" - - -def flash_trace(event: str, **fields: Any) -> None: - """Record a flash diagnostic event.""" - FlashTrace.record(event, **fields) diff --git a/src/pyqt_reactive/forms/form_init_service.py b/src/pyqt_reactive/forms/form_init_service.py index ba8a26d8..4b1b6346 100644 --- a/src/pyqt_reactive/forms/form_init_service.py +++ b/src/pyqt_reactive/forms/form_init_service.py @@ -29,9 +29,9 @@ from contextlib import contextmanager from python_introspect import UnifiedParameterAnalyzer -from pyqt_reactive.forms.parameter_form_base import ParameterFormConfig -from pyqt_reactive.forms.parameter_form_constants import CONSTANTS -from pyqt_reactive.forms.parameter_value_contracts import ( +from formbridge.forms.parameter_form_base import ParameterFormConfig +from formbridge.forms.parameter_form_constants import CONSTANTS +from formbridge.forms.parameter_value_contracts import ( FormContext, FormObject, GeneratedServiceNamespace, @@ -44,10 +44,10 @@ from objectstate import get_base_config_type, has_lazy_resolution if TYPE_CHECKING: - from pyqt_reactive.forms.parameter_form_service import FormStructure + from formbridge.forms.parameter_form_service import FormStructure try: - from pyqt_reactive.core.performance_monitor import timer + from formbridge.core.performance_monitor import timer except Exception: # pragma: no cover - optional performance monitoring @contextmanager def timer(*args, **kwargs): @@ -466,7 +466,7 @@ def _record_lineage(self, spec: BuilderSpec, service_type: Type) -> None: import pyqt_reactive.services.signal_service as signal_service import pyqt_reactive.services.parameter_ops_service as parameter_ops_service import pyqt_reactive.services.enabled_field_styling_service as enabled_field_styling_service -import pyqt_reactive.services.enum_dispatch_service as enum_dispatch_service +import formbridge.services.enum_dispatch_service as enum_dispatch_service class ServiceRegistryMeta(type): @@ -556,7 +556,7 @@ def _build_config(field_id, extracted, context_obj, color_scheme, parent_manager ctx = DerivationContext(context_obj, extracted, color_scheme) vars(config).update(vars(ctx)) - from pyqt_reactive.forms.parameter_form_service import ParameterAnalysisInput + from formbridge.forms.parameter_form_service import ParameterAnalysisInput description = extracted.description analysis_input = ParameterAnalysisInput( field_id=field_id, diff --git a/src/pyqt_reactive/forms/inline_dataclass_chrome.py b/src/pyqt_reactive/forms/inline_dataclass_chrome.py index 14318f0b..9c11df3a 100644 --- a/src/pyqt_reactive/forms/inline_dataclass_chrome.py +++ b/src/pyqt_reactive/forms/inline_dataclass_chrome.py @@ -18,7 +18,7 @@ InlineDataclassChildFieldIdentity, InlineDataclassFormContext, ) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.forms.widget_creation_config import ResetButtonStyler from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.shared.clickable_help_components import ( diff --git a/src/pyqt_reactive/forms/inline_dataclass_context.py b/src/pyqt_reactive/forms/inline_dataclass_context.py index f6fe17b6..dd5bb761 100644 --- a/src/pyqt_reactive/forms/inline_dataclass_context.py +++ b/src/pyqt_reactive/forms/inline_dataclass_context.py @@ -12,7 +12,7 @@ from objectstate import ObjectState from PyQt6.QtGui import QColor from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager - from pyqt_reactive.forms.parameter_info_types import InlineDataclassWidgetInfo + from formbridge.forms.parameter_info_types import InlineDataclassWidgetInfo from pyqt_reactive.theming import ColorScheme @@ -171,7 +171,7 @@ def _sync_owner_widget(self, raw_value) -> None: ) def update_reset_button_styling(self, button, field_name: str) -> None: - from pyqt_reactive.utils.styling_utils import update_reset_button_styling + from formbridge.utils.styling_utils import update_reset_button_styling update_reset_button_styling( button, diff --git a/src/pyqt_reactive/forms/layout_constants.py b/src/pyqt_reactive/forms/layout_constants.py deleted file mode 100644 index 6a1a50bd..00000000 --- a/src/pyqt_reactive/forms/layout_constants.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Layout constants for PyQt parameter forms. - -This module centralizes all spacing, margin, and layout configuration -to ensure uniform appearance across all parameter forms. -""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class ParameterFormLayoutConfig: - """Configuration for parameter form layout spacing and margins.""" - - # Main form layout settings - main_layout_spacing: int = 4 - main_layout_margins: tuple = (main_layout_spacing, main_layout_spacing, main_layout_spacing, main_layout_spacing) - - # Content layout settings (between parameter fields) - # THIS IS THE KEY ONE - controls vertical spacing between each parameter row - content_layout_spacing: int = 1 - content_layout_margins: tuple = (content_layout_spacing, content_layout_spacing, content_layout_spacing, content_layout_spacing) - - # Parameter row layout settings (between label, widget, button) - parameter_row_spacing: int = 2 - parameter_row_margins: tuple = (1, 1, 1, 1) - - # Optional parameter layout settings (checkbox + nested content) - optional_layout_spacing: int = 2 - optional_layout_margins: tuple = (2, 2, 1, 1) - - # GroupBox/Section settings (Dtype Config, Processing Config, etc.) - groupbox_spacing: int = 2 # ⭐ Spacing inside groupbox sections - groupbox_margins: tuple = (5, 5, 5, 5) # ⭐ Margins inside groupbox (left, top, right, bottom) - groupbox_margin_top: int = 5 # ⭐ QSS margin-top for QGroupBox - groupbox_padding_top: int = 5 # ⭐ QSS padding-top for QGroupBox - groupbox_corner_radius: int = 3 # QSS/flash radius for form section containers - dialog_groupbox_corner_radius: int = 5 # QSS radius for standalone dialog groupboxes - - # Widget-level settings - widget_fixed_height: int | None = None # None = auto, or set to fixed pixel height - widget_padding: int = 5 # ⭐ WIDGET INTERNAL PADDING - controls height of input fields! - widget_corner_radius: int = 3 - row_fixed_height: int | None = None # ⭐ Fixed height for each parameter row (None = auto) - - # Button dimensions - reset_button_width: int = 60 - button_height: int = 32 # SSOT for all button heights - progress_chunk_corner_radius: int = 2 - - -# Default compact configuration -COMPACT_LAYOUT = ParameterFormLayoutConfig() - -# Alternative configurations for different use cases -SPACIOUS_LAYOUT = ParameterFormLayoutConfig( - main_layout_spacing=6, - main_layout_margins=(8, 8, 8, 8), - content_layout_spacing=4, - content_layout_margins=(4, 4, 4, 4), - parameter_row_spacing=8, - optional_layout_spacing=4, - reset_button_width=80 -) - -ULTRA_COMPACT_LAYOUT = ParameterFormLayoutConfig( - # Slightly tighter than COMPACT_LAYOUT but not "zero everything". - # This is tuned to feel close to the main-branch compact layout while - # still being clearly more dense. - main_layout_spacing=1, # Small spacing around entire form - main_layout_margins=(2, 2, 2, 2), # Small outer margins - - # Vertical spacing between parameter rows: let row margins do most of - # the work so we have a tiny but visible gap. - content_layout_spacing=0, - content_layout_margins=(1, 1, 1, 1), - - # Within a row, keep labels/fields/buttons comfortably separated, and - # use small margins to avoid rows visually fusing together. - parameter_row_spacing=2, - parameter_row_margins=(1, 1, 1, 1), - - optional_layout_spacing=1, - optional_layout_margins=(1, 1, 1, 1), - - # Group boxes should still read as distinct sections but with reduced - # padding compared to the default compact layout. - groupbox_spacing=1, - groupbox_margins=(3, 3, 3, 3), - groupbox_margin_top=3, - groupbox_padding_top=3, - - # Make widgets shorter than COMPACT (padding=5) but not razor-thin. - widget_fixed_height=None, - widget_padding=3, - reset_button_width=50 -) - -# Current active configuration - change this to switch layouts globally -CURRENT_LAYOUT = COMPACT_LAYOUT - - -def default_container_corner_radius_px() -> float: - """Fallback radius for container painting when live QSS has no radius.""" - - return float(CURRENT_LAYOUT.groupbox_corner_radius) diff --git a/src/pyqt_reactive/forms/parameter_form_base.py b/src/pyqt_reactive/forms/parameter_form_base.py deleted file mode 100644 index 8fd768ff..00000000 --- a/src/pyqt_reactive/forms/parameter_form_base.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Configuration for parameter form managers. -""" - -from typing import Any, Dict, Type, Optional -from dataclasses import dataclass - -from pyqt_reactive.forms.parameter_form_constants import CONSTANTS - - -@dataclass -class ParameterFormConfig: - """ - Configuration for parameter form managers. - - This dataclass encapsulates all configuration options for parameter form - managers, providing a clean interface for customizing form behavior. - - Attributes: - field_id: Unique identifier for the form - parameter_info: Optional parameter information dictionary - is_global_config_editing: Whether editing global configuration - global_config_type: Type of global configuration being edited - placeholder_prefix: Prefix for placeholder text - use_scroll_area: Whether to use scroll area (PyQt only) - enable_debug: Whether to enable debug logging - debug_target_params: Set of parameters to debug - framework: UI framework ('pyqt6' or 'textual') - color_scheme: Optional color scheme for PyQt - function_target: Optional function target for docstring fallback - """ - field_id: str - parameter_info: Optional[Dict] = None - is_global_config_editing: bool = False - global_config_type: Optional[Type] = None - placeholder_prefix: str = CONSTANTS.DEFAULT_PLACEHOLDER_PREFIX - use_scroll_area: Optional[bool] = None - enable_debug: bool = False - debug_target_params: Optional[set] = None - framework: str = CONSTANTS.TEXTUAL_FRAMEWORK - color_scheme: Optional[Any] = None - function_target: Optional[Any] = None - - def with_debug(self, enabled: bool = True, target_params: Optional[set] = None) -> 'ParameterFormConfig': - """Return a copy with debug settings configured.""" - import copy - config = copy.deepcopy(self) - config.enable_debug = enabled - if target_params is not None: - config.debug_target_params = target_params - return config - - def with_global_config(self, global_config_type: Type, editing: bool = True) -> 'ParameterFormConfig': - """Return a copy with global configuration settings.""" - import copy - config = copy.deepcopy(self) - config.is_global_config_editing = editing - config.global_config_type = global_config_type - return config diff --git a/src/pyqt_reactive/forms/parameter_form_constants.py b/src/pyqt_reactive/forms/parameter_form_constants.py deleted file mode 100644 index f6d93a2a..00000000 --- a/src/pyqt_reactive/forms/parameter_form_constants.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Parameter form constants for eliminating magic strings throughout the parameter form managers. - -This module centralizes all hardcoded strings used in both PyQt and Textual parameter form -implementations to improve maintainability and reduce duplication. -""" - -from dataclasses import dataclass -from typing import FrozenSet - - -@dataclass(frozen=True) -class ParameterFormConstants: - """ - Centralized constants for parameter form implementations. - - This dataclass eliminates magic strings throughout the parameter form system - by providing a single source of truth for all hardcoded values used in both - PyQt and Textual implementations. - - Categories: - - UI text and formatting patterns - - Widget identification and naming - - Framework-specific constants - - Debug and validation settings - """ - - # Field ID generation patterns - FIELD_ID_SEPARATOR: str = "_" - NESTED_STATIC_PREFIX: str = "nested_static_" - MIXED_CLASS_PREFIX: str = "Mixed" - ENABLE_CHECKBOX_PREFIX: str = "Enable " - RESET_BUTTON_PREFIX: str = "reset_" - - # Placeholder text patterns - DEFAULT_PLACEHOLDER_PREFIX: str = "Pipeline default" - PLACEHOLDER_WITH_COLON: str = "Pipeline default:" - PLACEHOLDER_NONE_TEXT: str = "(none)" - - # Widget text constants - RESET_BUTTON_TEXT: str = "Reset" - NONE_STRING_LITERAL: str = "None" - EMPTY_STRING: str = "" - - # Parameter name formatting - UNDERSCORE_REPLACEMENT: str = " " - FIELD_LABEL_SUFFIX: str = ":" - PARAMETER_DESCRIPTION_PREFIX: str = "Parameter: " - - # Debug and logging constants - DEBUG_PREFIX: str = "*** " - DEBUG_SUFFIX: str = " ***" - DEBUG_TARGET_PARAMS: FrozenSet[str] = frozenset({ - "output_dir_suffix", - "path_planning" - }) - - # Nested manager patterns - NESTED_MANAGERS_ATTR: str = "nested_managers" - OPTIONAL_CHECKBOXES_ATTR: str = "optional_checkboxes" - ENABLED_SUFFIX: str = "_enabled" - - # Type checking strings - DATACLASS_FIELDS_ATTR: str = "__dataclass_fields__" - RESOLVE_FIELD_VALUE_ATTR: str = "_resolve_field_value" - BASES_ATTR: str = "__bases__" - VALUE_ATTR: str = "value" - - # Boolean conversion strings - TRUE_STRINGS: FrozenSet[str] = frozenset({ - "true", "1", "yes", "on" - }) - - # CSS and styling constants - PARAM_LABEL_CLASS: str = "param-label clickable" - PLACEHOLDER_STYLE_PROPERTY: str = "is_placeholder_state" - - # Layout and sizing constants - AUTO_SIZE: str = "auto" - FLEXIBLE_WIDTH: str = "1fr" - LEFT_ALIGN: str = "left" - - # Error and validation messages - UNKNOWN_CONFIG_KEY_MSG: str = "Unknown config key: {}" - INVALID_CONFIG_KEYS_MSG: str = "Invalid config keys: {}" - CONFIG_MUST_BE_DICT_MSG: str = "Config must be a non-empty dictionary" - NO_WIDGET_CREATOR_MSG: str = "No widget creator registered for type: {}" - CONVERSION_ERROR_MSG: str = "Cannot convert '{}' to {}: {}" - NO_VALID_CONVERSION_MSG: str = "No valid conversion found for Union type {}" - - # File path and naming patterns - GLOBAL_CONTEXT_LAZY_PREFIX: str = "GlobalContextLazy" - STATIC_LAZY_PREFIX: str = "StaticLazy" - - # Special field analysis types - DIFFERENT_VALUES_TYPE: str = "different" - - # Tooltip and help text patterns - TOOLTIP_SEPARATOR: str = ": " - HELP_PARAMETER_PREFIX: str = "Parameter: " - - # Widget state constants - COLLAPSED_STATE: bool = True - EXPANDED_STATE: bool = False - COMPACT_WIDGET: bool = True - - # Margin and spacing constants (for Textual layouts) - NO_MARGIN: tuple = (0, 0, 0, 0) - LEFT_MARGIN_ONLY: tuple = (0, 0, 0, 1) - - # Thread-local and context constants - CURRENT_PIPELINE_CONFIG_ATTR: str = "value" - - # Method and attribute name constants - SET_PATH_METHOD: str = "set_path" - GET_VALUE_METHOD: str = "get_value" - SET_VALUE_METHOD: str = "setValue" # PyQt6 uses camelCase - BLOCK_SIGNALS_METHOD: str = "blockSignals" - SET_TEXT_METHOD: str = "setText" - SET_CHECKED_METHOD: str = "setChecked" - - # Logging and debug message templates - NESTED_DEBUG_MSG: str = "*** NESTED DEBUG *** param_name={}, parent_nested_name={}" - NESTED_UPDATE_MSG: str = "*** NESTED UPDATE *** Updating {}.{} = {}" - RESET_DEBUG_MSG: str = "*** RESET DEBUG *** param_name={}, parent_nested_name={}" - FALLBACK_DEBUG_MSG: str = "*** FALLBACK DEBUG *** Checking fallback for {}" - TEXTUAL_UPDATE_DEBUG_MSG: str = "*** TEXTUAL UPDATE DEBUG *** {} update_parameter called with: {} (type: {})" - - # Path and hierarchy constants - DOT_SEPARATOR: str = "." - PATH_PLANNING_FIELD: str = "path_planning" - OUTPUT_DIR_SUFFIX_FIELD: str = "output_dir_suffix" - - # Widget creation and registry constants - INPUT_TYPE_INTEGER: str = "integer" - INPUT_TYPE_NUMBER: str = "number" - INPUT_TYPE_TEXT: str = "text" - - # Configuration context constants - GLOBAL_CONFIG_EDITING_CONTEXT: str = "global_config_editing" - LAZY_CONTEXT: str = "lazy_context" - - # Framework identification constants - PYQT6_FRAMEWORK: str = "pyqt6" - TEXTUAL_FRAMEWORK: str = "textual" - - -# Create a singleton instance for easy access throughout the codebase -CONSTANTS = ParameterFormConstants() diff --git a/src/pyqt_reactive/forms/parameter_form_manager.py b/src/pyqt_reactive/forms/parameter_form_manager.py index 775ca443..c49110e8 100644 --- a/src/pyqt_reactive/forms/parameter_form_manager.py +++ b/src/pyqt_reactive/forms/parameter_form_manager.py @@ -19,7 +19,7 @@ from pyqt_reactive.animation import FlashMixin from pyqt_reactive.core.deferred_callback import DeferredCallback -from ..flash_trace import flash_trace +from formbridge.flash_trace import flash_trace if TYPE_CHECKING: @@ -29,8 +29,8 @@ from contextlib import contextmanager -from pyqt_reactive.forms.parameter_info_types import ParameterInfo -from pyqt_reactive.forms.parameter_value_contracts import ( +from formbridge.forms.parameter_info_types import ParameterInfo +from formbridge.forms.parameter_value_contracts import ( FormContext, ParameterDefaultsByName, ParameterTypesByName, @@ -40,7 +40,7 @@ from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent # LiveContextService deleted - functionality moved to ObjectStateRegistry -from pyqt_reactive.services.flag_context_manager import FlagContextManager +from formbridge.services.flag_context_manager import FlagContextManager from pyqt_reactive.services.signal_service import SignalService from pyqt_reactive.services.value_collection_service import ValueCollectionService from pyqt_reactive.services.window_navigation import ObjectStateNavigationTargetAuthority @@ -48,7 +48,7 @@ from pyqt_reactive.widgets.shared.config_tree_contracts import ConfigTreeFlashManager from .form_init_service import FormBuildOrchestrator, FormBuildTransaction -from .layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from .parameter_form_chrome_sync import ParameterFormChromeSync from .parameter_form_tree_index import ParameterFormTreeIndex from .widget_creation_types import ParameterFormManager as ParameterFormManagerABC @@ -59,7 +59,7 @@ from .widget_strategies import create_pyqt6_widget try: - from pyqt_reactive.core.performance_monitor import timer + from formbridge.core.performance_monitor import timer except Exception: # pragma: no cover - optional performance monitoring @contextmanager @@ -441,7 +441,7 @@ def __init__(self, state: "ObjectState", config: Optional[FormManagerConfig] = N ConfigBuilderService, ExtractedParameters, ) - from pyqt_reactive.forms.parameter_form_service import ParameterFormService + from formbridge.forms.parameter_form_service import ParameterFormService self.service = ParameterFormService() # Use the canonical dotted-path `field_id`. For nested PFMs a @@ -904,7 +904,7 @@ def _update_reset_button_styling(self, param_name: str) -> None: if param_name not in self.reset_buttons: return - from pyqt_reactive.utils.styling_utils import update_reset_button_styling + from formbridge.utils.styling_utils import update_reset_button_styling reset_button = self.reset_buttons[param_name] update_reset_button_styling(reset_button, self.state, self.field_id, param_name) diff --git a/src/pyqt_reactive/forms/parameter_form_service.py b/src/pyqt_reactive/forms/parameter_form_service.py deleted file mode 100644 index 047bb3e2..00000000 --- a/src/pyqt_reactive/forms/parameter_form_service.py +++ /dev/null @@ -1,924 +0,0 @@ -""" -Shared service layer for parameter form managers. - -This module provides a framework-agnostic service layer that eliminates the -architectural dependency between PyQt and Textual implementations by providing -shared business logic and data management. - -Uses React-style discriminated unions for type-safe parameter handling. -""" - -import dataclasses -from collections.abc import ( - Callable as CallableABC, - Mapping as MappingABC, - Sequence as SequenceABC, -) - -from objectstate import ( - DataclassFieldAccess, - LazyDefaultPlaceholderService, - UIParameterVisibilityRequest, - has_lazy_resolution, - should_hide_ui_parameter, -) -from dataclasses import dataclass -from typing import Any, Dict, Type, Optional, List, get_args, get_origin, get_type_hints - -from python_introspect import ( - AnnotationChoices, - declared_annotation_choices, - coerce_enum_member, - is_union_type, - resolve_annotated, -) -from pyqt_reactive.forms.parameter_form_constants import CONSTANTS -from .parameter_type_utils import ParameterTypeUtils -from pyqt_reactive.forms.ui_utils import FieldDisplayText, debug_param -from .parameter_info_types import ( - ParameterInfo, - create_parameter_info -) -from pyqt_reactive.forms.parameter_value_contracts import ( - FormObject, - NestedManagerMap, - ParameterDefaultsByName, - ParameterDescriptionByPath, - ParameterDescriptionProvider, - ParameterTypesByName, - ParameterValue, -) - - -_NO_CONVERSION = object() - - -def _type_label(param_type: Type) -> str: - """Return a stable label for diagnostics and tooltips.""" - return param_type.__name__ if isinstance(param_type, type) else str(param_type) - - -@dataclass -class ParameterAnalysisInput: - """ - Type-safe input for parameter analysis. - - Field names match UnifiedParameterInfo for automatic extraction. - This enforces unification across all functions that analyze parameters. - """ - default_value: ParameterDefaultsByName - param_type: Dict[str, Type] - field_id: str - # Dotted-path description map (may be provided lazily via a callable). - description: Optional[ParameterDescriptionByPath | ParameterDescriptionProvider] = None - parent_obj_type: Optional[Type] = None - - -@dataclass -class FormStructure: - """ - Structure information for a parameter form. - - Uses discriminated union ParameterInfo types for type-safe dispatch. - - Attributes: - field_id: Unique identifier for the form - parameters: List of parameter information (discriminated union types) - nested_forms: Dictionary of nested form structures - has_optional_dataclasses: Whether form has optional dataclass parameters - """ - field_id: str - parameters: List[ParameterInfo] - nested_forms: Dict[str, 'FormStructure'] - has_optional_dataclasses: bool = False - - def get_parameter_info(self, param_name: str) -> Optional[ParameterInfo]: - """ - Get ParameterInfo for a parameter by name. - - Args: - param_name: Name of the parameter - - Returns: - ParameterInfo instance (discriminated union type) or None if not found - - Note: - Returns None for parameters like 'enabled' that are rendered in header - and not part of the regular form structure. - """ - for param_info in self.parameters: - if param_info.name == param_name: - return param_info - return None - - -class ParameterFormService: - """ - Framework-agnostic service for parameter form business logic. - - This service provides shared functionality for both PyQt and Textual - parameter form managers, eliminating the need for cross-framework - dependencies and providing a clean separation of concerns. - """ - - def __init__(self): - """ - Initialize the parameter form service. - """ - self._type_utils = ParameterTypeUtils() - - def analyze_parameters(self, input: ParameterAnalysisInput) -> FormStructure: - """ - Analyze parameters and create form structure. - - This method analyzes the parameters and their types to create a complete - form structure that can be used by any UI framework. - - Args: - input: Type-safe parameter analysis input (field names match UnifiedParameterInfo) - - Returns: - Complete form structure information - """ - debug_param("analyze_parameters", f"field_id={input.field_id}, parameter_count={len(input.default_value)}") - - import logging - logger = logging.getLogger(__name__) - logger.debug( - "analyze_parameters: field_id=%s param_type.keys()=%s", - input.field_id, - list(input.param_type.keys()), - ) - - param_infos = [] - nested_forms = {} - has_optional_dataclasses = False - - for param_name, parameter_type in input.param_type.items(): - current_value = input.default_value.get(param_name) - - # Check if this parameter should be hidden from UI - if self._should_hide_from_ui(input.parent_obj_type, param_name, parameter_type): - debug_param("analyze_parameters", f"Hiding parameter {param_name} from UI (ui_hidden=True)") - continue - - # CRITICAL FIX: Build full dotted path for description lookup - # When nested_field_id is set (e.g., "well_filter_config"), we need to pass - # the full dotted path to _create_parameter_info so it can construct the correct key - # for looking up descriptions from state._parameter_descriptions (which uses dotted paths) - full_param_path = f'{input.field_id}.{param_name}' if input.field_id else param_name - - # Create parameter info with full dotted path - param_info = self._create_parameter_info( - param_name, parameter_type, current_value, input.description, full_param_path - ) - param_infos.append(param_info) - - # Check for nested dataclasses using isinstance (type-safe!) - from .parameter_info_types import OptionalDataclassInfo, DirectDataclassInfo - - if isinstance(param_info, (OptionalDataclassInfo, DirectDataclassInfo)): - # Get actual field path from FieldPathDetector (no artificial "nested_" prefix) - # Unwrap Optional types to get the actual dataclass type for field path detection - unwrapped_param_type = self._unwrap_optional_dataclass_type(parameter_type) - - # For function parameters (no parent dataclass), use parameter name directly - if input.parent_obj_type is None: - nested_field_id = param_name - else: - nested_field_id = self.resolve_declared_field_path( - input.parent_obj_type, - param_name, - unwrapped_param_type, - ) - - nested_structure = self._analyze_nested_dataclass( - param_name, - parameter_type, - current_value, - nested_field_id, - input.parent_obj_type, - ) - nested_forms[param_name] = nested_structure - - # Check for optional dataclasses using isinstance (type-safe!) - if isinstance(param_info, OptionalDataclassInfo): - has_optional_dataclasses = True - - return FormStructure( - field_id=input.field_id, - parameters=param_infos, - nested_forms=nested_forms, - has_optional_dataclasses=has_optional_dataclasses - ) - - def _should_hide_from_ui( - self, - parent_obj_type: Optional[Type], - param_name: str, - param_type: Type, - ) -> bool: - """ - Check if a parameter should be hidden from the UI. - - Args: - parent_obj_type: The parent dataclass type (None for function parameters) - param_name: Name of the parameter - param_type: Type of the parameter - - Returns: - True if the parameter should be hidden from UI - """ - return should_hide_ui_parameter( - UIParameterVisibilityRequest( - owner_type=parent_obj_type, - field_name=param_name, - field_type_candidate=self._unwrap_optional_dataclass_type(param_type), - field_metadata_hidden=self._field_metadata_declares_hidden( - parent_obj_type, - param_name, - ), - ) - ) - - def _unwrap_optional_dataclass_type(self, param_type: Type) -> Type: - """Return the dataclass type behind Optional[T], or the type itself.""" - if self._type_utils.is_optional_dataclass(param_type): - return self._type_utils.get_optional_inner_type(param_type) - return param_type - - def _field_metadata_declares_hidden( - self, - parent_obj_type: Optional[Type], - param_name: str, - ) -> bool: - """Return True when a dataclass field carries UI-hidden metadata.""" - if parent_obj_type is None: - return False - if not dataclasses.is_dataclass(parent_obj_type): - return False - - field_by_name = { - field.name: field - for field in dataclasses.fields(parent_obj_type) - } - field_obj = field_by_name.get(param_name) - if field_obj is None: - return False - return field_obj.metadata.get("ui_hidden", False) - - def convert_value_to_type(self, value: ParameterValue, param_type: Type, param_name: str, obj_type: Type = None) -> ParameterValue | None: - """ - Convert a value to the appropriate type for a parameter. - - This method provides centralized type conversion logic that can be - used by any UI framework. - - Args: - value: The value to convert - param_type: The target parameter type - param_name: The parameter name (for debugging) - obj_type: The dataclass type (for sibling inheritance checks) - - Returns: - The converted value - """ - debug_param("convert_value", f"param={param_name}, input_type={type(value).__name__}, target_type={_type_label(param_type)}") - - if value is None: - return None - - # Handle string "None" literal - if isinstance(value, str) and value == CONSTANTS.NONE_STRING_LITERAL: - return None - - choices = declared_annotation_choices(param_type) - if choices is not None: - return self._convert_choice_value(value, choices, param_name) - - resolved_type = resolve_annotated(param_type) - structured_value = self._convert_value_by_annotation( - value, - resolved_type, - param_name, - ) - if structured_value is not _NO_CONVERSION: - return structured_value - - # Handle enum types - if self._type_utils.is_enum_type(resolved_type): - return coerce_enum_member(resolved_type, value) - - # Handle list of enums - if self._type_utils.is_list_of_enums(resolved_type): - # If value is already a list (from checkbox group widget), return as-is - if isinstance(value, list): - return value - enum_type = self._type_utils.get_enum_from_list_type(resolved_type) - if enum_type: - return [coerce_enum_member(enum_type, value)] - - # Handle basic types - if resolved_type is bool and isinstance(value, str): - return self._type_utils.convert_string_to_bool(value) - if resolved_type in (int, float) and isinstance(value, str): - if value == CONSTANTS.EMPTY_STRING: - return None - try: - return resolved_type(value) - except (ValueError, TypeError) as exc: - raise ValueError( - f"Invalid {resolved_type.__name__} value for parameter " - f"{param_name!r}: {value!r}" - ) from exc - - # Handle empty strings in lazy context - convert to None for all parameter types - # This is critical for lazy dataclass behavior where None triggers placeholder resolution - if isinstance(value, str) and value == CONSTANTS.EMPTY_STRING: - return None - - # Handle string types - also convert empty strings to None for consistency - if ( - resolved_type is str - and isinstance(value, str) - and value == CONSTANTS.EMPTY_STRING - ): - return None - - return value - - def _convert_value_by_annotation( - self, - value: ParameterValue, - param_type: Type, - param_name: str, - ) -> ParameterValue | object: - """Recursively rebuild structured values from JSON-like containers.""" - choices = declared_annotation_choices(param_type) - if choices is not None and value is not None: - return self._convert_choice_value(value, choices, param_name) - param_type = resolve_annotated(param_type) - origin = get_origin(param_type) - - if origin is type: - return value if isinstance(value, type) else _NO_CONVERSION - - if param_type is CallableABC or origin is CallableABC: - return value if callable(value) else _NO_CONVERSION - - if is_union_type(param_type): - return self._convert_union_value(value, param_type, param_name) - - if self._type_utils.is_enum_type(param_type): - if isinstance(value, param_type): - return value - return coerce_enum_member(param_type, value) - - if dataclasses.is_dataclass(param_type): - return self._convert_dataclass_value(value, param_type, param_name) - - if origin in (tuple, SequenceABC): - return self._convert_tuple_value(value, param_type, param_name) - - if origin is list: - return self._convert_list_value(value, param_type, param_name) - - if origin in (dict, MappingABC): - return self._convert_dict_value(value, param_type, param_name) - - return _NO_CONVERSION - - @staticmethod - def _convert_choice_value( - value: ParameterValue, - choices: AnnotationChoices, - param_name: str, - ) -> ParameterValue: - """Decode declared choices (or their labels) for one choice field.""" - - admitted = choices.choices() - - def decode(item: ParameterValue) -> ParameterValue: - if item in admitted: - return item - if isinstance(item, str): - return choices.choice_for_label(item) - raise ValueError( - f"Invalid choice for parameter {param_name!r}: {item!r}; expected " - f"one of {[choices.label(choice) for choice in admitted]}." - ) - - if isinstance(value, (list, tuple)): - return type(value)(decode(item) for item in value) - return decode(value) - - def _convert_union_value( - self, - value: ParameterValue, - param_type: Type, - param_name: str, - ) -> ParameterValue | object: - last_error: Exception | None = None - for candidate_type in get_args(param_type): - if candidate_type is type(None): - continue - candidate_type = resolve_annotated(candidate_type) - try: - converted = self._convert_value_by_annotation( - value, - candidate_type, - param_name, - ) - except Exception as exc: - last_error = exc - continue - if converted is not _NO_CONVERSION: - return converted - if isinstance(candidate_type, type) and isinstance(value, candidate_type): - if candidate_type is int and type(value) is bool: - continue - return value - if candidate_type is float and type(value) is int: - return float(value) - if last_error is not None: - raise last_error - raise ValueError( - f"Invalid value for parameter {param_name!r}: " - f"expected {_type_label(param_type)}, got {type(value).__name__}." - ) - - def _convert_dataclass_value( - self, - value: ParameterValue, - dataclass_type: Type, - param_name: str, - ) -> ParameterValue | object: - if isinstance(value, dataclass_type): - return value - if not isinstance(value, dict): - return _NO_CONVERSION - - try: - type_hints = get_type_hints(dataclass_type, include_extras=True) - except Exception: - type_hints = {} - - kwargs = {} - for field in dataclasses.fields(dataclass_type): - if field.name not in value: - continue - field_value = value[field.name] - field_type = type_hints.get(field.name, field.type) - converted = self._convert_value_by_annotation( - field_value, - field_type, - field.name, - ) - kwargs[field.name] = ( - field_value if converted is _NO_CONVERSION else converted - ) - - try: - return dataclass_type(**kwargs) - except Exception as exc: - raise ValueError( - f"Invalid dataclass value for parameter {param_name!r}: {value!r}" - ) from exc - - def _convert_tuple_value( - self, - value: ParameterValue, - param_type: Type, - param_name: str, - ) -> ParameterValue | object: - if not isinstance(value, (list, tuple)): - return _NO_CONVERSION - - args = get_args(param_type) - if get_origin(param_type) is SequenceABC: - item_type = args[0] if args else Any - return tuple( - self._converted_container_item(item, item_type, param_name) - for item in value - ) - if len(args) == 2 and args[1] is Ellipsis: - item_type = args[0] - return tuple( - self._converted_container_item(item, item_type, param_name) - for item in value - ) - - if args and len(value) != len(args): - raise ValueError( - f"Invalid tuple length for parameter {param_name!r}: " - f"expected {len(args)}, got {len(value)}." - ) - if not args: - return tuple(value) - - return tuple( - self._converted_container_item(item, item_type, param_name) - for item, item_type in zip(value, args) - ) - - def _convert_list_value( - self, - value: ParameterValue, - param_type: Type, - param_name: str, - ) -> ParameterValue | object: - if not isinstance(value, list): - return _NO_CONVERSION - - args = get_args(param_type) - if not args: - return value - item_type = args[0] - return [ - self._converted_container_item(item, item_type, param_name) - for item in value - ] - - def _convert_dict_value( - self, - value: ParameterValue, - param_type: Type, - param_name: str, - ) -> ParameterValue | object: - if not isinstance(value, dict): - return _NO_CONVERSION - - args = get_args(param_type) - if len(args) != 2: - return value - - key_type, item_type = args - return { - self._converted_container_item(key, key_type, param_name): - self._converted_container_item(item, item_type, param_name) - for key, item in value.items() - } - - def _converted_container_item( - self, - value: ParameterValue, - item_type: Type, - param_name: str, - ) -> ParameterValue: - item_type = resolve_annotated(item_type) - converted = self._convert_value_by_annotation( - value, - item_type, - param_name, - ) - if converted is not _NO_CONVERSION: - return converted - - if item_type is Any: - return value - - if item_type is float and type(value) is int: - return float(value) - - if isinstance(item_type, type) and isinstance(value, item_type): - if item_type is int and type(value) is bool: - raise ValueError( - f"Invalid item for parameter {param_name!r}: " - "expected int, got bool." - ) - return value - - raise ValueError( - f"Invalid item for parameter {param_name!r}: " - f"expected {_type_label(item_type)}, got {type(value).__name__}." - ) - - def get_parameter_display_info(self, param_name: str, param_type: Type, - description: Optional[str] = None) -> Dict[str, str]: - """ - Get display information for a parameter. - - Args: - param_name: The parameter name - param_type: The parameter type - description: Optional parameter description - - Returns: - Dictionary with display information - """ - text = FieldDisplayText.from_field_name(param_name) - return { - 'display_name': text.display_name, - 'field_label': text.field_label, - 'checkbox_label': text.checkbox_label, - 'group_title': text.group_title, - 'description': description or f"Parameter: {text.display_name}", - 'tooltip': f"{text.display_name} ({_type_label(param_type)})" - } - - def resolve_declared_field_path( - self, - parent_type: Type, - param_name: str, - param_type: Type, - ) -> str: - """Validate and return the already-declared nested field name.""" - if not param_name: - raise ValueError("Nested form field name cannot be empty.") - - if not dataclasses.is_dataclass(parent_type): - return param_name - - try: - field_types = get_type_hints(parent_type) - except Exception as exc: - raise ValueError( - f"Cannot resolve declared fields for {parent_type!r}." - ) from exc - - if param_name not in field_types: - raise ValueError( - f"{parent_type.__name__} has no declared field {param_name!r}." - ) - - declared_type = self._unwrap_optional_dataclass_type( - field_types[param_name] - ) - if declared_type is not param_type: - raise ValueError( - f"{parent_type.__name__}.{param_name} declares " - f"{_type_label(declared_type)}, not {_type_label(param_type)}." - ) - return param_name - - def generate_field_ids_direct(self, base_field_id: str, param_name: str) -> Dict[str, str]: - """Generate field IDs directly without artificial complexity.""" - widget_id = f"{base_field_id}_{param_name}" - return { - 'field_id': base_field_id, - 'widget_id': widget_id, - 'reset_button_id': f"reset_{widget_id}", - 'optional_checkbox_id': f"{base_field_id}_{param_name}_enabled" - } - - def should_use_concrete_values(self, current_value: ParameterValue | None, is_global_editing: bool = False) -> bool: - """ - Determine whether to use concrete values for a dataclass parameter. - - Args: - current_value: The current parameter value - is_global_editing: Whether in global configuration editing mode - - Returns: - True if concrete values should be used - """ - if current_value is None: - return False - - if is_global_editing: - return True - - # If current_value is a concrete dataclass instance, use its values - if self._type_utils.is_concrete_dataclass(current_value): - return True - - # For lazy dataclasses, return True so we can extract raw values from them - if self._type_utils.is_lazy_dataclass(current_value): - return True - - return False - - def extract_nested_parameters( - self, - dataclass_instance: FormObject | None, - obj_type: Type, - parent_obj_type: Optional[Type] = None, - ) -> tuple[ParameterDefaultsByName, ParameterTypesByName]: - """ - Extract parameters and types from a dataclass instance. - - This method always preserves concrete field values when a dataclass instance exists, - regardless of parent context. Placeholder behavior is handled at the widget level, - not by discarding concrete values during parameter extraction. - """ - if not dataclasses.is_dataclass(obj_type): - return ParameterDefaultsByName(), ParameterTypesByName() - - parameters = ParameterDefaultsByName() - parameter_types = ParameterTypesByName() - - for field in dataclasses.fields(obj_type): - # Always extract actual field values when dataclass instance exists - # This preserves concrete user-entered values in nested lazy dataclass forms - if dataclass_instance is not None: - current_value = self._get_field_value(dataclass_instance, field) - else: - current_value = None # Only use None when no instance exists - - parameters[field.name] = current_value - parameter_types[field.name] = field.type - - return parameters, parameter_types - - def _get_field_value(self, dataclass_instance: FormObject | None, field: dataclasses.Field) -> ParameterValue: - """Extract a single field value from a dataclass instance.""" - if dataclass_instance is None: - return field.default - return DataclassFieldAccess.raw_value(dataclass_instance, field.name) - - def _create_parameter_info(self, param_name: str, param_type: Type, current_value: ParameterValue, - parameter_info: Optional[Dict] = None, full_param_path: str = None) -> ParameterInfo: - """ - Create parameter information object using discriminated union factory. - - Uses type introspection to automatically select the correct ParameterInfo - subclass (OptionalDataclassInfo, DirectDataclassInfo, or GenericInfo). - """ - description = None - resolved_info = parameter_info() if callable(parameter_info) else parameter_info - if isinstance(resolved_info, dict) and full_param_path: - description = resolved_info.get(full_param_path) - - # Use factory to create correct ParameterInfo subclass - # Factory uses type introspection to determine which type to create - return create_parameter_info( - name=param_name, - param_type=param_type, - current_value=current_value, - description=description - ) - - # Class-level cache for nested dataclass parameter info (descriptions only) - _nested_param_info_cache = {} - - def _analyze_nested_dataclass(self, param_name: str, param_type: Type, current_value: ParameterValue | None, - nested_field_id: str, parent_obj_type: Type = None) -> FormStructure: - """Analyze a nested dataclass parameter.""" - # Get the actual dataclass type - if self._type_utils.is_optional_dataclass(param_type): - obj_type = self._type_utils.get_optional_inner_type(param_type) - else: - obj_type = param_type - - # Extract nested parameters using parent context - nested_params, nested_types = self.extract_nested_parameters( - current_value, obj_type, parent_obj_type - ) - - # OPTIMIZATION: Cache parameter info (descriptions) by dataclass type - # We only need descriptions, not instance values, so analyze the type once and reuse - cache_key = obj_type - if cache_key in self._nested_param_info_cache: - nested_param_info = self._nested_param_info_cache[cache_key] - else: - # Recursively analyze nested structure with proper descriptions for nested fields - # Use existing infrastructure to extract field descriptions for the nested dataclass - from python_introspect import UnifiedParameterAnalyzer - # OPTIMIZATION: Always analyze the TYPE, not the instance - # This allows caching and avoids extracting field values we don't need - nested_param_info = UnifiedParameterAnalyzer.analyze(obj_type) - self._nested_param_info_cache[cache_key] = nested_param_info - - # Create type-safe input for recursive analysis - # CRITICAL FIX: Use dotted paths for descriptions to match state._parameter_descriptions - # This ensures lookups work correctly when descriptions come from ObjectState - description = ParameterDescriptionByPath() - if nested_param_info: - prefix = f'{nested_field_id}.' if nested_field_id else '' - description = ParameterDescriptionByPath( - (f'{prefix}{name}', info.description) - for name, info in nested_param_info.items() - ) - - nested_input = ParameterAnalysisInput( - default_value=nested_params, - param_type=nested_types, - field_id=nested_field_id, - description=description, - parent_obj_type=obj_type - ) - - return self.analyze_parameters(nested_input) - - def get_placeholder_text(self, param_name: str, obj_type: Type, - placeholder_prefix: str = "Pipeline default") -> Optional[str]: - """ - Get placeholder text using existing OpenHCS infrastructure. - - Context must be established by the caller using config_context() before calling this method. - This allows the caller to build proper context stacks (parent + overlay) for accurate - placeholder resolution. - - Args: - param_name: Name of the parameter to get placeholder for - obj_type: The specific dataclass type (GlobalPipelineConfig or PipelineConfig) - placeholder_prefix: Prefix for the placeholder text - - Returns: - Formatted placeholder text or None if no resolution possible - - The editing mode is automatically derived from the dataclass type's lazy resolution capabilities: - - Has lazy resolution (PipelineConfig) → orchestrator config editing - - No lazy resolution (GlobalPipelineConfig) → global config editing - """ - # Service just resolves placeholders, caller manages context - return LazyDefaultPlaceholderService.get_lazy_resolved_placeholder( - obj_type, param_name, placeholder_prefix - ) - - def reset_nested_managers(self, nested_managers: NestedManagerMap, - obj_type: Type, current_config) -> None: - """Reset all nested managers - fail loud, no defensive programming.""" - for nested_manager in nested_managers.values(): - # All nested managers must have reset_all_parameters method - nested_manager.reset_all_parameters() - - def get_reset_value_for_parameter(self, param_name: str, param_type: Type, - obj_type: Type, is_global_config_editing: Optional[bool] = None) -> ParameterValue | None: - """ - Get appropriate reset value using existing OpenHCS patterns. - - Args: - param_name: Name of the parameter to reset - param_type: Type of the parameter (int, str, bool, etc.) - obj_type: The specific dataclass type - is_global_config_editing: Whether we're in global config editing mode (auto-detected if None) - - Returns: - - For global config editing: Actual default values - - For lazy config editing: None to show placeholder text - """ - # Context-driven behavior: Use the editing context to determine reset behavior - # This follows the architectural principle that behavior is determined by context - # of usage rather than intrinsic properties of the dataclass. - - # Context-driven behavior: Use explicit context when provided - # Auto-detect editing mode if not explicitly provided - if is_global_config_editing is None: - is_global_config_editing = not has_lazy_resolution(obj_type) - - # Context-driven behavior: Reset behavior depends on editing context - if is_global_config_editing: - # Global config editing: Reset to actual default values - # Users expect to see concrete defaults when editing global configuration - return self._get_actual_dataclass_field_default(param_name, obj_type) - else: - # CRITICAL FIX: For lazy config editing, always return None - # This ensures reset shows inheritance chain values (like compiler resolution) - # instead of concrete values from thread-local context - return None - - def _get_actual_dataclass_field_default(self, param_name: str, obj_type: Type) -> ParameterValue | None: - """ - Get the actual default value for a parameter. - - Works uniformly for dataclasses, functions, and any other object type. - Always returns None for non-existent fields (fail-soft for dynamic properties). - - Returns: - - If class attribute is None → return None (show placeholder) - - If class attribute has concrete value → return that value - - If field(default_factory) → call default_factory and return result - - If field doesn't exist → return None (dynamic property) - """ - from dataclasses import fields, MISSING, is_dataclass - import inspect - - # For pure functions: get default from signature - if callable(obj_type) and not is_dataclass(obj_type) and not inspect.isclass(obj_type): - sig = inspect.signature(obj_type) - if param_name in sig.parameters: - default = sig.parameters[param_name].default - return None if default is inspect.Parameter.empty else default - return None # Dynamic property, not in signature - - # For all other types (dataclasses, ABCs, classes): check class attribute first - sentinel = object() - class_value = inspect.getattr_static(obj_type, param_name, sentinel) - if class_value is not sentinel: - return class_value - - # For dataclasses: check if it's a field(default_factory=...) field - if is_dataclass(obj_type): - dataclass_fields = {f.name: f for f in fields(obj_type)} - if param_name not in dataclass_fields: - return None # Dynamic property, not a dataclass field - - field_info = dataclass_fields[param_name] - - # Handle field(default_factory=...) case - if field_info.default_factory is not MISSING: - try: - return field_info.default_factory() - except Exception as e: - raise ValueError(f"Failed to call default_factory for field '{param_name}': {e}") from e - - # Handle field with explicit default - if field_info.default is not MISSING: - return field_info.default - - # Field has no default (should not happen in practice) - return None - - # For non-dataclass types: return None (dynamic property) - return None diff --git a/src/pyqt_reactive/forms/parameter_info_types.py b/src/pyqt_reactive/forms/parameter_info_types.py deleted file mode 100644 index 2b8a2ca0..00000000 --- a/src/pyqt_reactive/forms/parameter_info_types.py +++ /dev/null @@ -1,350 +0,0 @@ -""" -Discriminated union types for parameter information. - -This module implements React-style discriminated unions for type-safe parameter handling. -Instead of using boolean flags (is_optional, is_nested), we use polymorphic types that -are automatically selected based on type annotations. - -Key features: -1. Metaclass auto-registration - all ParameterInfo subclasses auto-register -2. Type-driven factory - create_parameter_info() uses type introspection -3. Zero boilerplate - just define new dataclass with matches() predicate -4. Type-safe dispatch - services use class name for automatic dispatch - -Pattern (React-style): - Instead of: - @dataclass - class ParameterInfo: - is_optional: bool - is_nested: bool - - if info.is_optional and info.is_nested: - # handle optional dataclass - - Use: - @dataclass - class OptionalDataclassInfo(metaclass=ParameterInfoMeta): - @staticmethod - def matches(param_type): ... - - if isinstance(info, OptionalDataclassInfo): - # Type checker knows info is OptionalDataclassInfo! - -Architecture: - - ParameterInfoMeta: Metaclass that auto-registers all subclasses - - ParameterInfoBase: Base class for all parameter info types - - OptionalDataclassInfo: Optional[Dataclass] parameters (checkbox-controlled) - - DirectDataclassInfo: Direct Dataclass parameters (always exists) - - GenericInfo: Generic parameters (simple widgets) - - create_parameter_info(): Factory that auto-selects correct type -""" - -from typing import Callable, Type, Optional, List, Union -from dataclasses import dataclass, is_dataclass -from abc import ABC, ABCMeta -import logging - -from objectstate.lazy_factory import get_base_type_for_lazy -from python_introspect import optional_member_type -from pyqt_reactive.forms.parameter_value_contracts import ParameterValue - -logger = logging.getLogger(__name__) - - -InlineDataclassWidgetFactory = Callable - - -class _InlineDataclassWidgetCatalog: - """Private registry authority for dataclasses with structural inline editors.""" - - _factories: dict[Type, InlineDataclassWidgetFactory] = {} - - @classmethod - def register( - cls, - dataclass_type: Type, - widget_factory: InlineDataclassWidgetFactory, - ) -> None: - if not is_dataclass(dataclass_type): - raise TypeError( - "Inline dataclass widgets can only be registered for dataclass types; " - f"got {dataclass_type!r}." - ) - cls._factories[dataclass_type] = widget_factory - - @classmethod - def factory_for(cls, dataclass_type: Type) -> InlineDataclassWidgetFactory | None: - for candidate_type in cls._candidate_types(dataclass_type): - factory = cls._factories.get(candidate_type) - if factory is not None: - return factory - return None - - @classmethod - def has_factory_for(cls, dataclass_type: Type) -> bool: - return cls.factory_for(dataclass_type) is not None - - @classmethod - def _candidate_types(cls, dataclass_type: Type) -> tuple[Type, ...]: - """Return exact, lazy-base, and inherited dataclass types for lookup.""" - - base_type = get_base_type_for_lazy(dataclass_type) - root_types = ( - (dataclass_type,) - if base_type is None or base_type is dataclass_type - else (dataclass_type, base_type) - ) - candidates: list[Type] = [] - seen: set[Type] = set() - for root_type in root_types: - for candidate_type in root_type.__mro__: - if candidate_type is object or candidate_type in seen: - continue - candidates.append(candidate_type) - seen.add(candidate_type) - return tuple(candidates) - - -def register_inline_dataclass_widget( - dataclass_type: Type, - widget_factory: InlineDataclassWidgetFactory, -) -> None: - """Register a full-row editor for a dataclass type before nested fallback.""" - _InlineDataclassWidgetCatalog.register(dataclass_type, widget_factory) - - -@dataclass -class ParameterInfoBase(ABC): - """ABC for parameter information objects - enforces explicit interface.""" - name: str - type: Type - current_value: ParameterValue | None - description: Optional[str] = None - - # Widget creation type - subclasses override. Imported lazily to avoid circular imports. - widget_creation_type: str = "REGULAR" # Default, overridden by subclasses - - -class ParameterInfoMeta(ABCMeta): - """ - Metaclass for auto-registration of ParameterInfo types. - - All classes with a matches() method are automatically registered - in the global registry for use by the factory function. - - This eliminates manual registration and enables zero-boilerplate - addition of new parameter types. - """ - _registry: List[Type] = [] - - def __new__(mcs, name, bases, namespace): - cls = super().__new__(mcs, name, bases, namespace) - - # Auto-register if it has a matches() predicate - if 'matches' in namespace and callable(namespace['matches']): - mcs._registry.append(cls) - logger.debug(f"Auto-registered ParameterInfo type: {name}") - - return cls - - @classmethod - def get_registry(mcs) -> List[Type]: - """Get all registered ParameterInfo types.""" - return mcs._registry.copy() - - -@dataclass -class OptionalDataclassInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for Optional[Dataclass] types. - - These parameters: - - Have a checkbox to enable/disable - - Have a nested form that appears when enabled - - Can be None when checkbox is unchecked - - Support lazy inheritance from parent configs - - Examples: - def process(config: Optional[ProcessingConfig]): ... - def analyze(settings: Optional[AnalysisSettings]): ... - """ - default_value: ParameterValue | None = None - is_required: bool = True - widget_creation_type: str = "OPTIONAL_NESTED" - - @staticmethod - def matches(param_type: Type) -> bool: - """ - Predicate: Does this type annotation match Optional[Dataclass]? - - Returns True if: - - Type is Union[T, None] (i.e., Optional[T]) - - T is a dataclass - """ - inner_type = optional_member_type(param_type) - return inner_type is not None and is_dataclass(inner_type) - - -@dataclass -class InlineDataclassWidgetInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for dataclasses with a registered structural inline editor. - - These parameters: - - Are still regular dataclass-backed values - - Use a registered widget instead of the default nested field form - - Fall back to DirectDataclassInfo when no inline widget is registered - """ - default_value: ParameterValue | None = None - is_required: bool = True - widget_creation_type: str = "INLINE_DATACLASS" - - @staticmethod - def matches(param_type: Type) -> bool: - return ( - is_dataclass(param_type) - and _InlineDataclassWidgetCatalog.has_factory_for(param_type) - ) - - def widget_factory(self) -> InlineDataclassWidgetFactory: - factory = _InlineDataclassWidgetCatalog.factory_for(self.type) - if factory is None: - raise ValueError( - "No inline dataclass widget registered for " - f"{self.type!r}; DirectDataclassInfo should have handled it." - ) - return factory - - -@dataclass -class DirectDataclassInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for direct Dataclass types (non-optional). - - These parameters: - - Always exist (never None) - - Have a nested form that's always visible - - Preserve object identity during reset - - Don't have a checkbox - - Examples: - def process(config: ProcessingConfig): ... - def analyze(settings: AnalysisSettings): ... - """ - default_value: ParameterValue | None = None - is_required: bool = True - widget_creation_type: str = "NESTED" - - @staticmethod - def matches(param_type: Type) -> bool: - """ - Predicate: Does this type annotation match a direct Dataclass? - - Returns True if: - - Type is a dataclass - - Type is NOT Optional - """ - return is_dataclass(param_type) - - -@dataclass -class GenericInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for generic types (int, str, Path, etc.). - - These parameters: - - Use simple widgets (QLineEdit, QSpinBox, etc.) - - Don't have nested forms - - Support lazy inheritance via placeholders - - Are the most common parameter type - - Examples: - def process(threshold: int): ... - def analyze(input_path: Path): ... - def filter(sigma: float): ... - """ - default_value: ParameterValue | None = None - is_required: bool = True - - @staticmethod - def matches(param_type: Type) -> bool: - """ - Predicate: Fallback - matches everything. - - This should be registered LAST in the registry so it acts - as a catch-all for any types not matched by other predicates. - """ - return True - - -# Union type for type hints (React-style) -ParameterInfo = Union[ - OptionalDataclassInfo, - InlineDataclassWidgetInfo, - DirectDataclassInfo, - GenericInfo, -] - - -def create_parameter_info( - name: str, - param_type: Type, - current_value: ParameterValue | None, - default_value: ParameterValue | None = None, - description: Optional[str] = None, - is_required: bool = True -) -> ParameterInfo: - """ - Factory function that auto-selects the correct ParameterInfo subclass. - - Uses type introspection to determine which ParameterInfo type to create. - This eliminates manual if-elif-else chains and enables type-safe dispatch. - - Args: - name: Parameter name - param_type: Parameter type annotation - current_value: Current parameter value - default_value: Default parameter value - description: Parameter description - is_required: Whether parameter is required - - Returns: - Correct ParameterInfo subclass instance - - Raises: - ValueError: If no matching ParameterInfo type found - - Examples: - >>> from typing import Optional - >>> @dataclass - ... class Config: pass - - >>> info1 = create_parameter_info('config', Optional[Config], None) - >>> type(info1).__name__ - 'OptionalDataclassInfo' - - >>> info2 = create_parameter_info('config', Config, Config()) - >>> type(info2).__name__ - 'DirectDataclassInfo' - - >>> info3 = create_parameter_info('value', int, 42) - >>> type(info3).__name__ - 'GenericInfo' - """ - # Iterate through registered types and find first match - for info_class in ParameterInfoMeta.get_registry(): - if info_class.matches(param_type): - return info_class( - name=name, - type=param_type, - current_value=current_value, - default_value=default_value, - description=description, - is_required=is_required - ) - - # Should never reach here due to GenericInfo fallback - raise ValueError( - f"No matching ParameterInfo type for {param_type}. " - f"This should never happen - GenericInfo should match everything." - ) diff --git a/src/pyqt_reactive/forms/parameter_type_utils.py b/src/pyqt_reactive/forms/parameter_type_utils.py deleted file mode 100644 index bda46ccb..00000000 --- a/src/pyqt_reactive/forms/parameter_type_utils.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -Parameter type utilities for parameter form managers. - -This module provides centralized type checking and resolution methods to eliminate -code duplication between PyQt and Textual parameter form implementations. -""" - -import dataclasses -from typing import Optional, Type -from enum import Enum - -from objectstate.lazy_factory import LazyDataclass -from python_introspect import ( - get_enum_from_list, - is_enum_type as annotation_is_enum_type, - is_list_of_enums as annotation_is_list_of_enums, - optional_member_type, -) -from pyqt_reactive.forms.parameter_form_constants import CONSTANTS - - -class ParameterTypeUtils: - """ - Utility class for parameter type checking and resolution. - - This class provides static methods for common type operations used throughout - the parameter form system, including Optional type handling, dataclass detection, - and Union type resolution. - """ - - @staticmethod - def is_optional(param_type: Type) -> bool: - """ - Check if parameter type is Optional[T] (Union[T, None]). - - This method determines whether a type annotation represents an optional - parameter of any type. - - Args: - param_type: The type to check - - Returns: - True if the type is Optional[T], False otherwise - - Example: - >>> from typing import Optional - >>> ParameterTypeUtils.is_optional(Optional[str]) - True - >>> ParameterTypeUtils.is_optional(str) - False - """ - return optional_member_type(param_type) is not None - - @staticmethod - def is_optional_dataclass(param_type: Type) -> bool: - """ - Check if parameter type is Optional[dataclass]. - - This method determines whether a type annotation represents an optional - dataclass parameter (Union[DataclassType, None]). - - Args: - param_type: The type to check - - Returns: - True if the type is Optional[dataclass], False otherwise - - Example: - >>> from typing import Optional - >>> @dataclass - ... class Config: pass - >>> ParameterTypeUtils.is_optional_dataclass(Optional[Config]) - True - >>> ParameterTypeUtils.is_optional_dataclass(Config) - False - """ - optional_type = optional_member_type(param_type) - return optional_type is not None and dataclasses.is_dataclass(optional_type) - - @staticmethod - def get_optional_inner_type(param_type: Type) -> Type: - """ - Extract the inner type from Optional[T]. - - This method extracts the non-None type from an Optional type annotation. - - Args: - param_type: The Optional type to extract from - - Returns: - The inner type (T from Optional[T]) - - Raises: - ValueError: If the type is not Optional - - Example: - >>> from typing import Optional - >>> ParameterTypeUtils.get_optional_inner_type(Optional[str]) - - """ - optional_type = optional_member_type(param_type) - if optional_type is None: - raise ValueError(f"Type {param_type} is not Optional") - return optional_type - - @staticmethod - def is_enum_type(param_type: Type) -> bool: - """ - Check if a type is an Enum type. - - Args: - param_type: The type to check - - Returns: - True if the type is an Enum, False otherwise - """ - return annotation_is_enum_type(param_type) - - @staticmethod - def is_list_of_enums(param_type: Type) -> bool: - """ - Check if parameter type is List[Enum]. - - Args: - param_type: The type to check - - Returns: - True if the type is List[Enum], False otherwise - """ - return annotation_is_list_of_enums(param_type) - - @staticmethod - def get_enum_from_list_type(param_type: Type) -> Optional[Type]: - """ - Extract enum type from List[Enum] type. - - Args: - param_type: The List[Enum] type - - Returns: - The Enum type, or None if not a List[Enum] - """ - return get_enum_from_list(param_type) - - @staticmethod - def has_dataclass_fields(obj: any) -> bool: - """ - Check if an object has dataclass fields. - - Args: - obj: The object to check - - Returns: - True if the object has __dataclass_fields__ attribute - """ - return dataclasses.is_dataclass(obj) - - @staticmethod - def has_resolve_field_value(obj: any) -> bool: - """ - Check if an object has the _resolve_field_value method (lazy dataclass). - - Args: - obj: The object to check - - Returns: - True if the object has _resolve_field_value attribute - """ - return isinstance(obj, LazyDataclass) - - @staticmethod - def is_concrete_dataclass(obj: any) -> bool: - """ - Check if an object is a concrete (non-lazy) dataclass. - - Args: - obj: The object to check - - Returns: - True if the object is a concrete dataclass - """ - return (ParameterTypeUtils.has_dataclass_fields(obj) and - not ParameterTypeUtils.has_resolve_field_value(obj)) - - @staticmethod - def is_lazy_dataclass(obj: any) -> bool: - """ - Check if an object is a lazy dataclass. - - Args: - obj: The object to check - - Returns: - True if the object is a lazy dataclass - """ - return ParameterTypeUtils.has_resolve_field_value(obj) - - @staticmethod - def extract_value_attribute(obj: any) -> any: - """ - Extract the value attribute from an object if it exists. - - This is commonly used for enum values and other wrapped types. - - Args: - obj: The object to extract value from - - Returns: - The value attribute if it exists, otherwise the original object - """ - return obj.value if isinstance(obj, Enum) else obj - - @staticmethod - def convert_string_to_bool(value: str) -> bool: - """ - Convert string to boolean using standard true/false patterns. - - Args: - value: The string value to convert - - Returns: - True if the string represents a true value, False otherwise - """ - return value.lower() in CONSTANTS.TRUE_STRINGS diff --git a/src/pyqt_reactive/forms/parameter_value_contracts.py b/src/pyqt_reactive/forms/parameter_value_contracts.py deleted file mode 100644 index ec580717..00000000 --- a/src/pyqt_reactive/forms/parameter_value_contracts.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Nominal contracts for dynamic form parameter payloads.""" - -from __future__ import annotations - -from abc import ABC -from collections.abc import Callable - - -class ParameterValue(ABC): - """Nominal annotation for values carried by parameter forms.""" - - -class WidgetValue(ParameterValue): - """Nominal annotation for values received from PyQt widgets.""" - - -class FormObject(ABC): - """Nominal annotation for objects analyzed by the form layer.""" - - -class FormContext(ABC): - """Nominal annotation for context objects used by placeholder resolution.""" - - -class ParameterDefaultsByName(dict): - """Map of parameter name to extracted/default value.""" - - -class ParameterTypesByName(dict): - """Map of parameter name to type annotation.""" - - -class NestedManagerMap(dict): - """Map of nested field name to nested form manager.""" - - -class ParameterDescriptionByPath(dict): - """Map of dotted parameter path to rendered description.""" - - -class GeneratedServiceNamespace(dict): - """Module namespace populated with generated initialization services.""" - - -class ParameterInfoSequence(list): - """Ordered parameter information sequence for widget build orchestration.""" - - -ParameterDescriptionProvider = Callable[[], ParameterDescriptionByPath] diff --git a/src/pyqt_reactive/forms/ui_utils.py b/src/pyqt_reactive/forms/ui_utils.py deleted file mode 100644 index e85ad18a..00000000 --- a/src/pyqt_reactive/forms/ui_utils.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -UI utilities for pyqt-reactor. - -Simple formatting and debug utilities used across the forms layer. -""" - -import logging -from dataclasses import dataclass -from enum import Enum -from typing import Any - - -@dataclass(frozen=True) -class FieldDisplayText: - """Canonical display strings derived from a parameter field name.""" - - display_name: str - field_label: str - checkbox_label: str - group_title: str - - @classmethod - def from_field_name(cls, name: str) -> "FieldDisplayText": - display_name = name.replace('_', ' ').title() - return cls( - display_name=display_name, - field_label=f"{display_name}:", - checkbox_label=f"Enable {display_name}", - group_title=display_name, - ) - - -def format_field_id(parent: str, param: str) -> str: - """Generate field ID: 'parent', 'param' -> 'parent_param'""" - return f"{parent}_{param}" - - -def debug_param(param_name: str, value: Any, context: str = "") -> None: - """Simple parameter debug logging""" - context_str = f" [{context}]" if context else "" - logging.debug(f"PARAM: {param_name} = {value}{context_str}") - - -def format_enum_display(enum_value: Enum) -> str: - """Get enum display text, including nested enum-valued members.""" - if isinstance(enum_value.value, Enum): - return str(enum_value.value.value) - if isinstance(enum_value.value, str): - return enum_value.value - return enum_value.name.upper() - - -def format_enum_placeholder(enum_value: Enum, prefix: str = "Pipeline default: ") -> str: - """Get enum placeholder: Enum.VALUE -> 'Pipeline default: VALUE'""" - return f"{prefix}{format_enum_display(enum_value)}" diff --git a/src/pyqt_reactive/forms/widget_creation_config.py b/src/pyqt_reactive/forms/widget_creation_config.py index 80ea5082..14358839 100644 --- a/src/pyqt_reactive/forms/widget_creation_config.py +++ b/src/pyqt_reactive/forms/widget_creation_config.py @@ -34,7 +34,7 @@ from pyqt_reactive.services.field_change_dispatcher import FieldChangeDispatcher, FieldChangeEvent from pyqt_reactive.services.widget_service import WidgetService from pyqt_reactive.widgets.shared.responsive_layout_widgets import ResponsiveParameterRow -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT if TYPE_CHECKING: from PyQt6.QtWidgets import QGroupBox, QLayout, QWidget @@ -337,7 +337,7 @@ def prepare_reset_button(self, context: EnabledTitleWidgetMoveContext) -> None: if context.enabled_reset_button is None: return - from pyqt_reactive.utils.styling_utils import update_reset_button_styling + from formbridge.utils.styling_utils import update_reset_button_styling self.prepare_title_reset_button( context.enabled_reset_button, @@ -565,7 +565,7 @@ def _create_optional_title_widget(ctx: WidgetBuildContext): from PyQt6.QtGui import QFont from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox from pyqt_reactive.widgets.shared.clickable_help_components import HelpButton - from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT + from formbridge.forms.layout_constants import CURRENT_LAYOUT manager = ctx.manager param_info = ctx.param_info @@ -771,7 +771,7 @@ def _create_inline_dataclass_widget( ctx: WidgetBuildContext, ) -> QWidget: """Create the registered inline editor for a structural dataclass value.""" - from .parameter_info_types import InlineDataclassWidgetInfo + from formbridge.forms.parameter_info_types import InlineDataclassWidgetInfo manager = ctx.manager param_info = ctx.param_info diff --git a/src/pyqt_reactive/forms/widget_creation_types.py b/src/pyqt_reactive/forms/widget_creation_types.py index 13b9461b..99a7b14b 100644 --- a/src/pyqt_reactive/forms/widget_creation_types.py +++ b/src/pyqt_reactive/forms/widget_creation_types.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, replace as dataclass_replace # Import ParameterInfo ABC from shared UI module -from .parameter_info_types import ParameterInfoBase as ParameterInfo +from formbridge.forms.parameter_info_types import ParameterInfoBase as ParameterInfo class DisplayInfo(TypedDict, total=False): diff --git a/src/pyqt_reactive/forms/widget_strategies.py b/src/pyqt_reactive/forms/widget_strategies.py index 1fa6d751..333b6333 100644 --- a/src/pyqt_reactive/forms/widget_strategies.py +++ b/src/pyqt_reactive/forms/widget_strategies.py @@ -37,12 +37,8 @@ resolve_optional, ) -from pyqt_reactive.forms.parameter_info_types import ParameterInfo -from pyqt_reactive.forms.parameter_value_contracts import ( - FormObject, - ParameterValue, - WidgetValue, -) +from formbridge.forms.parameter_info_types import ParameterInfo +from formbridge.forms.parameter_value_contracts import FormObject, ParameterValue, WidgetValue from pyqt_reactive.protocols import ( ChangeSignalEmitter, CurrentValueValidatable, @@ -67,7 +63,7 @@ from pyqt_reactive.widgets.enhanced_path_widget import EnhancedPathWidget try: - from pyqt_reactive.core.performance_monitor import timer + from formbridge.core.performance_monitor import timer except Exception: # pragma: no cover - optional performance monitoring @contextmanager def timer(*args, **kwargs): @@ -271,7 +267,7 @@ def _parse_text(self, text: str) -> ParameterValue | None: return None value = parse_literal_container(text, self._container_type) self._validate_container(value) - from pyqt_reactive.forms.parameter_form_service import ParameterFormService + from formbridge.forms.parameter_form_service import ParameterFormService return ParameterFormService().convert_value_to_type( value, @@ -464,7 +460,7 @@ def _parse_text(self, text: str) -> ParameterValue | None: ) from exc value = text - from pyqt_reactive.forms.parameter_form_service import ParameterFormService + from formbridge.forms.parameter_form_service import ParameterFormService return ParameterFormService().convert_value_to_type( value, @@ -999,7 +995,7 @@ def create_enum_widget_unified( accepts_none: bool = False, ) -> QComboBox: """Unified enum widget creator with consistent display text.""" - from pyqt_reactive.forms.ui_utils import format_enum_display + from formbridge.forms.ui_utils import format_enum_display widget = NoScrollComboBox() @@ -1698,7 +1694,7 @@ def apply_global_config_placeholder(widget: QWidget, field_name: str, global_con # Format the placeholder text appropriately for different types if isinstance(field_value, Enum): - from pyqt_reactive.forms.ui_utils import format_enum_placeholder + from formbridge.forms.ui_utils import format_enum_placeholder placeholder_text = format_enum_placeholder(field_value) else: placeholder_text = f"Pipeline default: {field_value}" diff --git a/src/pyqt_reactive/pattern_metadata.py b/src/pyqt_reactive/pattern_metadata.py deleted file mode 100644 index a9b41fe7..00000000 --- a/src/pyqt_reactive/pattern_metadata.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Lightweight metadata keys shared by function-pattern tooling.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any, ClassVar - - -class PatternScopeToken: - """Nominal owner for UI-only function-pattern scope-token metadata.""" - - key: ClassVar[str] = "__pyqt_reactive_scope_token__" - - @classmethod - def key_name(cls) -> str: - return cls.key - - @classmethod - def key_in(cls, values: Mapping[Any, Any]) -> bool: - return cls.key_name() in values - - @classmethod - def is_key(cls, key: Any) -> bool: - return key == cls.key_name() - - @classmethod - def without_token(cls, values: Mapping[Any, Any]) -> dict[Any, Any]: - return {key: value for key, value in values.items() if not cls.is_key(key)} diff --git a/src/pyqt_reactive/protocols/__init__.py b/src/pyqt_reactive/protocols/__init__.py index 829f3402..0e383e40 100644 --- a/src/pyqt_reactive/protocols/__init__.py +++ b/src/pyqt_reactive/protocols/__init__.py @@ -5,29 +5,6 @@ explicit, fail-loud inheritance-based architecture. """ -from .codegen_provider import ( - CodegenProviderABC, - get_codegen_provider, - register_codegen_provider, -) -from .component_selection import ( - ComponentSelectionProviderABC, - FunctionSelectionProviderABC, - get_component_selection_provider, - get_function_selection_provider, - register_component_selection_provider, - register_function_selection_provider, -) -from .form_config import FormGenConfig, get_form_config, set_form_config -from .log_providers import ( - LogDiscoveryProviderABC, - ServerScanProviderABC, - get_log_discovery_provider, - get_server_scan_provider, - register_log_discovery_provider, - register_server_scan_provider, -) -from .preview_formatter import PreviewFormatterRegistry, register_preview_formatter from .widget_adapters import ( CheckBoxAdapter, ComboBoxAdapter, @@ -94,24 +71,4 @@ "CheckBoxAdapter", "PlaceholderStateMixin", "PyQtWidgetMeta", - "PreviewFormatterRegistry", - "register_preview_formatter", - "FormGenConfig", - "set_form_config", - "get_form_config", - "CodegenProviderABC", - "register_codegen_provider", - "get_codegen_provider", - "LogDiscoveryProviderABC", - "ServerScanProviderABC", - "register_log_discovery_provider", - "get_log_discovery_provider", - "register_server_scan_provider", - "get_server_scan_provider", - "ComponentSelectionProviderABC", - "FunctionSelectionProviderABC", - "register_component_selection_provider", - "get_component_selection_provider", - "register_function_selection_provider", - "get_function_selection_provider", ] diff --git a/src/pyqt_reactive/protocols/codegen_provider.py b/src/pyqt_reactive/protocols/codegen_provider.py deleted file mode 100644 index 9085d6bf..00000000 --- a/src/pyqt_reactive/protocols/codegen_provider.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Nominal code-generation contract for app-specific code emitters.""" - -from abc import ABC, abstractmethod -from typing import TypeVar - -DeclarationT = TypeVar("DeclarationT") - - -class CodegenProviderABC(ABC): - """Host code-generation behavior consumed by generic code editors.""" - - @abstractmethod - def render_assignment( - self, - value: object, - *, - assignment_name: str, - header: str, - clean_mode: bool, - ) -> str: - """Render one named value as editable source.""" - raise NotImplementedError - - @abstractmethod - def normalize_source( - self, - source: str, - *, - declaration_type: type[DeclarationT], - clean_mode: bool, - ) -> str: - """Normalize source through the declaration authority being edited.""" - raise NotImplementedError - - -_codegen_provider: CodegenProviderABC | None = None - - -def register_codegen_provider(provider: CodegenProviderABC) -> None: - """Register a global code generation provider.""" - if not isinstance(provider, CodegenProviderABC): - raise TypeError("Code-generation providers must inherit CodegenProviderABC.") - global _codegen_provider - _codegen_provider = provider - - -def get_codegen_provider() -> CodegenProviderABC | None: - """Get the registered code generation provider.""" - return _codegen_provider diff --git a/src/pyqt_reactive/protocols/component_selection.py b/src/pyqt_reactive/protocols/component_selection.py deleted file mode 100644 index 3cafa9ce..00000000 --- a/src/pyqt_reactive/protocols/component_selection.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Nominal host contracts for component and function selection.""" - -from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable -from typing import Any - - -class ComponentSelectionProviderABC(ABC): - """Host-owned component discovery and selection behavior.""" - - @abstractmethod - def is_grouped(self, group_by: Any) -> bool: - """Return whether ``group_by`` names a grouping (not absent or ungrouped).""" - raise NotImplementedError - - @abstractmethod - def grouping_label(self, group_by: Any) -> str: - """Return the display label of a grouping value.""" - raise NotImplementedError - - @abstractmethod - def grouping_overlaps_variable_components( - self, - group_by: Any, - variable_components: Iterable[Any], - ) -> bool: - """Return whether the grouping is also one of the variable components.""" - raise NotImplementedError - - @abstractmethod - def get_component_keys(self, group_by: Any) -> list[str]: - """Return available component keys for a given group_by.""" - raise NotImplementedError - - @abstractmethod - def has_components_available(self, group_by: Any) -> bool: - """Check if components are available for the given group_by without fetching them all. - - Used to determine if UI elements (like component selection buttons) should be enabled. - Should return False if the underlying data source (e.g., orchestrator) is not ready. - """ - raise NotImplementedError - - @abstractmethod - def get_component_display_name( - self, - group_by: Any, - component_key: str, - ) -> str | None: - """Return a human-readable name for a component key.""" - raise NotImplementedError - - @abstractmethod - def select_components( - self, - available_components: Iterable[str], - selected_components: Iterable[str], - group_by: Any, - parent: Any | None = None, - **context: Any, - ) -> list[str] | None: - """Show selection UI and return chosen components (or None if canceled).""" - raise NotImplementedError - - -class FunctionSelectionProviderABC(ABC): - """Host-owned function selection behavior.""" - - @abstractmethod - def select_function( - self, - parent: Any | None = None, - **context: Any, - ) -> Callable | None: - """Return a selected function or None.""" - raise NotImplementedError - - -_component_selection_provider: ComponentSelectionProviderABC | None = None -_function_selection_provider: FunctionSelectionProviderABC | None = None - - -def register_component_selection_provider( - provider: ComponentSelectionProviderABC, -) -> None: - """Register a global component selection provider.""" - if not isinstance(provider, ComponentSelectionProviderABC): - raise TypeError( - "Component selection providers must inherit " "ComponentSelectionProviderABC." - ) - global _component_selection_provider - _component_selection_provider = provider - - -def get_component_selection_provider() -> ComponentSelectionProviderABC | None: - """Get the registered component selection provider.""" - return _component_selection_provider - - -def register_function_selection_provider( - provider: FunctionSelectionProviderABC, -) -> None: - """Register a global function selection provider.""" - if not isinstance(provider, FunctionSelectionProviderABC): - raise TypeError( - "Function selection providers must inherit " "FunctionSelectionProviderABC." - ) - global _function_selection_provider - _function_selection_provider = provider - - -def get_function_selection_provider() -> FunctionSelectionProviderABC | None: - """Get the registered function selection provider.""" - return _function_selection_provider diff --git a/src/pyqt_reactive/protocols/form_config.py b/src/pyqt_reactive/protocols/form_config.py deleted file mode 100644 index 1f504f83..00000000 --- a/src/pyqt_reactive/protocols/form_config.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Base configuration class for form generation. - -Provides hooks for applications to customize form generation behavior. -""" - -from typing import Any, Dict, Optional, Type, List -from dataclasses import dataclass, field - - -@dataclass -class FormGenConfig: - """Base configuration for form generation behavior. - - Applications can subclass this to provide custom configuration. - - Attributes: - enable_placeholder_styling: Whether to apply special styling to placeholder values - enable_help_buttons: Whether to show help buttons for fields with docstrings - enable_inheritance_tracking: Whether to show inheritance source indicators - custom_widget_factories: Custom widget factories by type - """ - - enable_placeholder_styling: bool = True - enable_help_buttons: bool = True - enable_inheritance_tracking: bool = True - custom_widget_factories: Dict[Type, Any] = field(default_factory=dict) - jedi_project_paths: List[str] = field(default_factory=list) - log_dir: Optional[str] = None - log_prefixes: List[str] = field(default_factory=lambda: ["pyqt_reactive_"]) - log_root_logger_name: Optional[str] = None - performance_logger_name: str = "pyqt_reactive.performance" - performance_log_filename: str = "performance.log" - path_cache_file: Optional[str] = None - external_editor_env_var: str = "PYQT_REACTIVE_USE_EXTERNAL_EDITOR" - """Environment variable that, set to 1/true/yes, edits code in an external editor.""" - - -# Global config instance (set by application) -_form_config: Optional[FormGenConfig] = None - - -def set_form_config(config: FormGenConfig) -> None: - """Set the global form generation configuration. - - Args: - config: FormGenConfig instance - """ - global _form_config - _form_config = config - - -def get_form_config() -> FormGenConfig: - """Get the current form generation configuration. - - Returns: - Current FormGenConfig or default if not set - """ - if _form_config is None: - return FormGenConfig() - return _form_config diff --git a/src/pyqt_reactive/protocols/log_providers.py b/src/pyqt_reactive/protocols/log_providers.py deleted file mode 100644 index 99971476..00000000 --- a/src/pyqt_reactive/protocols/log_providers.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Nominal host contracts for log discovery and server scanning.""" - -from abc import ABC, abstractmethod -from pathlib import Path - -from pyqt_reactive.core.log_utils import LogFileInfo - - -class LogDiscoveryProviderABC(ABC): - """Host-owned source of application log files.""" - - @abstractmethod - def get_current_log_path(self) -> Path: - """Return current log file path.""" - raise NotImplementedError - - @abstractmethod - def discover_logs( - self, - base_log_path: str | None = None, - include_main_log: bool = True, - log_directory: Path | None = None, - ) -> list[LogFileInfo]: - """Return discovered logs.""" - raise NotImplementedError - - -class ServerScanProviderABC(ABC): - """Host-owned source of logs advertised by live servers.""" - - @abstractmethod - def scan_for_server_logs(self) -> list[LogFileInfo]: - """Return logs discovered from live servers.""" - raise NotImplementedError - - -_log_discovery_provider: LogDiscoveryProviderABC | None = None -_server_scan_provider: ServerScanProviderABC | None = None - - -def register_log_discovery_provider(provider: LogDiscoveryProviderABC) -> None: - """Register a global log discovery provider.""" - if not isinstance(provider, LogDiscoveryProviderABC): - raise TypeError("Log discovery providers must inherit LogDiscoveryProviderABC.") - global _log_discovery_provider - _log_discovery_provider = provider - - -def get_log_discovery_provider() -> LogDiscoveryProviderABC | None: - """Get the registered log discovery provider.""" - return _log_discovery_provider - - -def register_server_scan_provider(provider: ServerScanProviderABC) -> None: - """Register a global server scan provider.""" - if not isinstance(provider, ServerScanProviderABC): - raise TypeError("Server scan providers must inherit ServerScanProviderABC.") - global _server_scan_provider - _server_scan_provider = provider - - -def get_server_scan_provider() -> ServerScanProviderABC | None: - """Get the registered server scan provider.""" - return _server_scan_provider diff --git a/src/pyqt_reactive/protocols/preview_formatter.py b/src/pyqt_reactive/protocols/preview_formatter.py deleted file mode 100644 index b4ecd4e6..00000000 --- a/src/pyqt_reactive/protocols/preview_formatter.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Host registrations for application-specific config preview formatting.""" - -from collections.abc import Callable -from typing import TypeAlias - -# Type alias for formatter functions -# Takes (config_instance, field_name) -> formatted_string -PreviewFormatter: TypeAlias = Callable[[object, str], str | None] - - -class PreviewFormatterRegistry: - """Registry for preview formatters by config type. - - Applications can register formatters for specific config types to customize - how fields are displayed in list item previews. - - Example: - from pyqt_reactive.protocols import PreviewFormatterRegistry - - def format_zarr_config(config, field_name): - if field_name == 'compression': - return f"comp={config.compression[:3]}" # Abbreviate - return str(getattr(config, field_name)) - - PreviewFormatterRegistry.register(ZarrConfig, format_zarr_config) - """ - - _formatters: dict[type[object], PreviewFormatter] = {} - - @classmethod - def register( - cls, - config_type: type[object], - formatter: PreviewFormatter, - ) -> None: - """Register a formatter for a config type. - - Args: - config_type: Config class to format - formatter: Formatter function taking (config, field_name) -> str - """ - cls._formatters[config_type] = formatter - - @classmethod - def get_formatter(cls, config_type: type[object]) -> PreviewFormatter | None: - """Get formatter for a config type. - - Args: - config_type: Config class - - Returns: - Formatter function if registered, None otherwise - """ - from pyqt_reactive.utils.preview_formatters import canonical_declaration_mro - - for declaration_type in canonical_declaration_mro(config_type): - formatter = cls._formatters.get(declaration_type) - if formatter is not None: - return formatter - - return None - - @classmethod - def format_field(cls, config: object, field_name: str) -> str | None: - """Format a field using registered formatter if available. - - Args: - config: Config instance - field_name: Field name to format - - Returns: - Formatted string if formatter available, None otherwise - """ - formatter = cls.get_formatter(type(config)) - if formatter is None: - return None - return formatter(config, field_name) - - -# Convenience function for registration -def register_preview_formatter( - config_type: type[object], - formatter: PreviewFormatter, -) -> None: - """Register a preview formatter for a config type. - - Args: - config_type: Config class - formatter: Formatter function - """ - PreviewFormatterRegistry.register(config_type, formatter) diff --git a/src/pyqt_reactive/services/async_operation_executor.py b/src/pyqt_reactive/services/async_operation_executor.py deleted file mode 100644 index 9bb4279b..00000000 --- a/src/pyqt_reactive/services/async_operation_executor.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Owned execution of coroutine callables outside the Qt application thread.""" - -from __future__ import annotations - -import asyncio -import threading -from collections.abc import Callable, Coroutine -from concurrent.futures import Future, ThreadPoolExecutor -from typing import Any, ParamSpec, TypeVar - -ResultT = TypeVar("ResultT") -ParametersT = ParamSpec("ParametersT") - - -class AsyncOperationExecutorClosedError(RuntimeError): - """Raised when work is submitted after executor shutdown begins.""" - - -class AsyncOperationExecutor: - """Own a worker pool and every coroutine operation submitted to it.""" - - def __init__(self, *, max_workers: int = 4) -> None: - self._executor = ThreadPoolExecutor(max_workers=max_workers) - self._lock = threading.Lock() - self._closed = False - self._futures: set[Future] = set() - - def submit( - self, - async_callable: Callable[ParametersT, Coroutine[Any, Any, ResultT]], - *args: ParametersT.args, - **kwargs: ParametersT.kwargs, - ) -> Future[ResultT]: - """Submit one coroutine factory and retain its Future until completion.""" - - with self._lock: - if self._closed: - raise AsyncOperationExecutorClosedError( - "Async operation executor is shutting down." - ) - future = self._executor.submit( - self._run_coroutine, - async_callable, - args, - kwargs, - ) - self._futures.add(future) - future.add_done_callback(self._retire) - return future - - def close(self) -> None: - """Reject new work and cancel pending work. - - Operations which have already started remain owned in ``_futures`` until - their completion callbacks retire them. Shutdown deliberately does not - wait: a coroutine may be blocked on a UI dispatcher which is itself being - closed by the application thread. - """ - - with self._lock: - if self._closed: - return - self._closed = True - futures = tuple(self._futures) - for future in futures: - future.cancel() - self._executor.shutdown(wait=False, cancel_futures=True) - - def active_futures(self) -> tuple[Future, ...]: - """Return the exact unfinished operations currently owned.""" - - with self._lock: - return tuple(future for future in self._futures if not future.done()) - - @staticmethod - def _run_coroutine(async_callable, args, kwargs): - loop = asyncio.new_event_loop() - try: - asyncio.set_event_loop(loop) - return loop.run_until_complete(async_callable(*args, **kwargs)) - finally: - asyncio.set_event_loop(None) - loop.close() - - def _retire(self, future: Future) -> None: - with self._lock: - self._futures.discard(future) diff --git a/src/pyqt_reactive/services/coalesced_scan_requests.py b/src/pyqt_reactive/services/coalesced_scan_requests.py deleted file mode 100644 index b4e3b122..00000000 --- a/src/pyqt_reactive/services/coalesced_scan_requests.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Concurrency gate for invalidation-driven background scans.""" - -from __future__ import annotations - -import threading - - -class CoalescedScanRequests: - """Single authority for one active scan and any newer invalidations.""" - - def __init__(self) -> None: - self._outstanding = 0 - self._lock = threading.Lock() - - def request(self) -> bool: - """Record an invalidation and report whether its scan must start now.""" - - with self._lock: - self._outstanding += 1 - return self._outstanding == 1 - - def complete(self) -> bool: - """Complete the active scan and retain at most one follow-up request.""" - - with self._lock: - follow_up_required = self._outstanding > 1 - self._outstanding = 1 if follow_up_required else 0 - return follow_up_required - - def clear(self) -> None: - """Discard all outstanding work when its owner lifecycle ends.""" - - with self._lock: - self._outstanding = 0 - - def has_outstanding(self) -> bool: - """Return whether a scan or a coalesced follow-up is outstanding.""" - - with self._lock: - return self._outstanding > 0 diff --git a/src/pyqt_reactive/services/code_editor_form_updater.py b/src/pyqt_reactive/services/code_editor_form_updater.py deleted file mode 100644 index f0528968..00000000 --- a/src/pyqt_reactive/services/code_editor_form_updater.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Shared utility for updating forms from code editor changes. - -This module provides utilities for parsing edited code and updating form -managers with only the explicitly set fields, preserving ``None`` values for -unspecified fields. -""" - -from __future__ import annotations - -import logging -from dataclasses import fields, is_dataclass -from typing import Any, get_args, get_origin - -from objectstate import semantic_values_equal - -logger = logging.getLogger(__name__) - - -class CodeEditorFormUpdater: - """Utility for updating forms from code editor changes. - - The code editor always produces a *concrete* instance (dataclass or - regular object). For lazy configs, we rely on the pattern that raw - attribute access (``object.__getattribute__``) returns ``None`` for - inherited / unset fields. The form layer then interprets ``None`` as - "show placeholder", while concrete values override placeholders. - """ - - @staticmethod - def update_form_from_instance( - form_manager, - new_instance: Any, - broadcast_callback=None, - ) -> None: - """Update a form manager with values from a new instance. - - Args: - form_manager: ParameterFormManager instance. - new_instance: New object/dataclass instance with updated values. - broadcast_callback: Optional callback to broadcast the new - instance (e.g., to an event bus). - """ - - is_instance_dataclass = is_dataclass(new_instance) - if is_instance_dataclass: - instance_fields = [field.name for field in fields(new_instance)] - else: - # Fallback: drive off whatever the form currently knows about. - instance_fields = list(form_manager.parameters.keys()) - - for field_name in instance_fields: - if field_name not in form_manager.parameters: - logger.debug( - "CodeEditorFormUpdater: field %s missing from form_manager %s", - field_name, - getattr(form_manager, "field_id", ""), - ) - continue - - if is_instance_dataclass: - new_value = CodeEditorFormUpdater._get_raw_field_value(new_instance, field_name) - else: - new_value = getattr(new_instance, field_name, None) - - if is_dataclass(new_value) and not isinstance(new_value, type): - CodeEditorFormUpdater._update_nested_dataclass(form_manager, field_name, new_value) - else: - logger.debug("CodeEditorFormUpdater: updating %s to %r", field_name, new_value) - CodeEditorFormUpdater._update_parameter_if_changed( - form_manager, - field_name, - new_value, - ) - - # NOTE: - # Cross-window propagation and placeholder refresh are handled by - # ParameterFormManager.update_parameter together with the - # FieldChangeDispatcher / LiveContextService stack. Callers usually - # do NOT need to trigger a global refresh after using this helper; - # only special flows (e.g., global-config snapshot restore/cancel) - # should call ParameterFormManager.trigger_global_cross_window_refresh(). - - if broadcast_callback: - broadcast_callback(new_instance) - - # ------------------------------------------------------------------ - # Nested dataclass handling - # ------------------------------------------------------------------ - - @staticmethod - def _update_nested_dataclass( - form_manager, - field_name: str, - new_value: Any, - ) -> None: - """Recursively update a nested dataclass field and all its children. - - ``new_value`` is a concrete dataclass instance coming from the code - editor. We walk its fields and drive the *nested* manager entirely via - ``update_parameter`` so that FieldChangeDispatcher and - ValueCollectionService can do the right thing (parent reconstruction, - _user_set_fields tracking, cross-window propagation, etc.). - """ - - nested_manager = form_manager.nested_managers.get(field_name) - if not nested_manager: - # No dedicated nested manager – treat as a regular field on the - # parent. This is still routed through FieldChangeDispatcher. - CodeEditorFormUpdater._update_parameter_if_changed( - form_manager, - field_name, - new_value, - ) - return - - for field in fields(new_value): - nested_field_value = CodeEditorFormUpdater._get_raw_field_value(new_value, field.name) - if field.name not in nested_manager.parameters: - continue - - if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): - CodeEditorFormUpdater._update_nested_dataclass_in_manager( - nested_manager, field.name, nested_field_value - ) - else: - CodeEditorFormUpdater._update_parameter_if_changed( - nested_manager, - field.name, - nested_field_value, - ) - - @staticmethod - def _update_nested_dataclass_in_manager( - manager, - field_name: str, - new_value: Any, - ) -> None: - """Helper to update nested dataclass within a specific manager.""" - - nested_manager = manager.nested_managers.get(field_name) - if not nested_manager: - CodeEditorFormUpdater._update_parameter_if_changed( - manager, - field_name, - new_value, - ) - return - - for field in fields(new_value): - nested_field_value = CodeEditorFormUpdater._get_raw_field_value(new_value, field.name) - if field.name not in nested_manager.parameters: - continue - - if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): - CodeEditorFormUpdater._update_nested_dataclass_in_manager( - nested_manager, field.name, nested_field_value - ) - else: - CodeEditorFormUpdater._update_parameter_if_changed( - nested_manager, - field.name, - nested_field_value, - ) - - @staticmethod - def _update_parameter_if_changed( - form_manager, - field_name: str, - new_value: Any, - ) -> bool: - """Dispatch one code-mode field only when its raw value changed. - - ``ObjectState`` already rejects identical assignments before cache - invalidation, but ``ParameterFormManager.update_parameter`` performs - widget synchronization and emits root/cross-window signals before the - caller can benefit from that no-op. Code documents reconstruct a whole - object, so filtering against the manager's raw, path-scoped parameters - avoids turning a one-field edit into one dispatch per dataclass field. - - Returns: - ``True`` when the field was sent through the normal form dispatcher. - """ - - current_parameters = form_manager.parameters - if field_name not in current_parameters: - return False - if semantic_values_equal( - current_parameters[field_name], - new_value, - ): - logger.debug( - "CodeEditorFormUpdater: skipping unchanged field %s", - field_name, - ) - return False - - form_manager.update_parameter(field_name, new_value) - return True - - @staticmethod - def _get_raw_field_value(obj: Any, field_name: str): - """Fetch field without triggering lazy ``__getattr__`` logic.""" - - try: - return object.__getattribute__(obj, field_name) - except AttributeError: - return getattr(obj, field_name, None) - - @staticmethod - def _is_dataclass(field_type: Any) -> bool: - """Check if a field type represents (or wraps) a dataclass.""" - - origin = get_origin(field_type) - if origin is not None: - return any( - CodeEditorFormUpdater._is_dataclass(arg) - for arg in get_args(field_type) - if arg is not type(None) - ) - try: - return is_dataclass(field_type) - except TypeError: - return False - - @staticmethod - def _get_dataclass_field_value(instance: Any, field_obj) -> Any: - """Get a field value from a dataclass, preserving raw ``None``. - - For nested dataclasses we want to avoid triggering any lazy resolution - logic, so we go through ``_get_raw_field_value``. For primitives and - non-dataclass fields we fall back to normal ``getattr``. - """ - - if CodeEditorFormUpdater._is_dataclass(field_obj.type): - return CodeEditorFormUpdater._get_raw_field_value(instance, field_obj.name) - return getattr(instance, field_obj.name, None) diff --git a/src/pyqt_reactive/services/enabled_field_styling_service.py b/src/pyqt_reactive/services/enabled_field_styling_service.py index e3c25e4b..9b9cb4cf 100644 --- a/src/pyqt_reactive/services/enabled_field_styling_service.py +++ b/src/pyqt_reactive/services/enabled_field_styling_service.py @@ -220,7 +220,7 @@ def _should_skip_optional_dataclass_styling(self, manager, log_prefix: str) -> b if nested_manager is manager: param_type = manager._parent_manager.parameter_types.get(param_name) if param_type: - from pyqt_reactive.forms.parameter_type_utils import ParameterTypeUtils + from formbridge.forms.parameter_type_utils import ParameterTypeUtils if ParameterTypeUtils.is_optional_dataclass(param_type): instance = manager._parent_manager.parameters.get(param_name) logger.debug(f"[{log_prefix}] field_id={manager.field_id}, optional dataclass check: param_name={param_name}, instance={instance}, is_none={instance is None}") diff --git a/src/pyqt_reactive/services/enum_dispatch_service.py b/src/pyqt_reactive/services/enum_dispatch_service.py deleted file mode 100644 index d7d380a2..00000000 --- a/src/pyqt_reactive/services/enum_dispatch_service.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -Abstract base class for enum-driven polymorphic dispatch services. - -This ABC eliminates duplication across services that use the same pattern: -1. Define an enum for strategies/types -2. Create a dispatch table mapping enum values to handler methods -3. Determine which strategy to use based on input -4. Dispatch to the appropriate handler - -Services using this pattern: -- ParameterResetService (ResetStrategy enum) -- NestedValueCollectionService (ValueCollectionStrategy enum) -- Widget creation (WidgetCreationType enum) - -Benefits: -- Single source of truth for dispatch pattern -- Enforces consistent structure across services -- Reduces boilerplate in service implementations -- Makes adding new services trivial - -Example: - class MyStrategy(Enum): - TYPE_A = "type_a" - TYPE_B = "type_b" - - class MyService(EnumDispatchService[MyStrategy]): - def __init__(self): - super().__init__() - self._register_handlers({ - MyStrategy.TYPE_A: self._handle_type_a, - MyStrategy.TYPE_B: self._handle_type_b, - }) - - def _determine_strategy(self, context, **kwargs) -> MyStrategy: - # Logic to determine which strategy to use - return MyStrategy.TYPE_A if some_condition else MyStrategy.TYPE_B - - def _handle_type_a(self, context, **kwargs): - # Handler implementation - pass - - def _handle_type_b(self, context, **kwargs): - # Handler implementation - pass - - def process(self, context, **kwargs): - # Public API - uses dispatch - return self.dispatch(context, **kwargs) -""" - -from abc import ABC, abstractmethod -from enum import Enum -from typing import TypeVar, Generic, Dict, Callable, Any -import logging - -logger = logging.getLogger(__name__) - -# Type variable for the strategy enum -StrategyEnum = TypeVar('StrategyEnum', bound=Enum) - - -class EnumDispatchService(ABC, Generic[StrategyEnum]): - """ - Abstract base class for services using enum-driven polymorphic dispatch. - - Subclasses must: - 1. Define a strategy enum (e.g., ResetStrategy, ValueCollectionStrategy) - 2. Implement _determine_strategy() to select the appropriate strategy - 3. Register handlers in __init__() using _register_handlers() - 4. Implement handler methods for each strategy - - The dispatch() method handles the actual dispatching logic. - """ - - def __init__(self): - """Initialize the service with an empty handler registry.""" - self._handlers: Dict[StrategyEnum, Callable] = {} - - def _register_handlers(self, handlers: Dict[StrategyEnum, Callable]) -> None: - """ - Register strategy handlers. - - Args: - handlers: Dictionary mapping strategy enum values to handler methods - - Raises: - ValueError: If handlers dict is empty or contains invalid strategies - """ - if not handlers: - raise ValueError(f"{self.__class__.__name__}: Handler registry cannot be empty") - - self._handlers = handlers - logger.debug(f"{self.__class__.__name__}: Registered {len(handlers)} handlers") - - @abstractmethod - def _determine_strategy(self, *args, **kwargs) -> StrategyEnum: - """ - Determine which strategy to use based on input. - - This method must be implemented by subclasses to analyze the input - and return the appropriate strategy enum value. - - Returns: - Strategy enum value indicating which handler to use - """ - pass - - def dispatch(self, *args, **kwargs) -> Any: - """ - Dispatch to the appropriate handler based on determined strategy. - - This is the core dispatch logic that: - 1. Determines the strategy using _determine_strategy() - 2. Looks up the handler in the registry - 3. Calls the handler with the provided arguments - - Args: - *args: Positional arguments to pass to the handler - **kwargs: Keyword arguments to pass to the handler - - Returns: - Result from the handler method - - Raises: - KeyError: If strategy is not registered in handlers - """ - # Determine which strategy to use - strategy = self._determine_strategy(*args, **kwargs) - - # Look up handler - if strategy not in self._handlers: - raise KeyError( - f"{self.__class__.__name__}: No handler registered for strategy {strategy}. " - f"Available strategies: {list(self._handlers.keys())}" - ) - # Dispatch to handler - handler = self._handlers[strategy] - logger.debug(f"{self.__class__.__name__}: Dispatching to {strategy.value} handler") - - # Handler call convention: the first positional argument is the - # primary context (e.g., manager/context object). Additional - # positional arguments passed to dispatch are used only for - # determining the strategy (for example a pre-computed 'mode') and - # should NOT be forwarded to the handler. Forward only the - # primary context and keyword arguments to the handler to avoid - # accidental "takes X positional arguments but Y were given" - # errors when handlers are bound instance methods. - handler_args = args[:1] if args else () - return handler(*handler_args, **kwargs) - - def get_registered_strategies(self) -> list[StrategyEnum]: - """ - Get list of all registered strategies. - - Returns: - List of strategy enum values that have registered handlers - """ - return list(self._handlers.keys()) - - def has_strategy(self, strategy: StrategyEnum) -> bool: - """ - Check if a strategy has a registered handler. - - Args: - strategy: Strategy enum value to check - - Returns: - True if strategy has a registered handler, False otherwise - """ - return strategy in self._handlers - diff --git a/src/pyqt_reactive/services/executable_action.py b/src/pyqt_reactive/services/executable_action.py deleted file mode 100644 index f368907b..00000000 --- a/src/pyqt_reactive/services/executable_action.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Nominal mechanics shared by executable UI action declarations.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Self, cast - - -class LabeledExecutableActionMixin: - """Construct and invoke labelled string-enum actions through one owner.""" - - _value_: str - label: str - _executor: Callable[..., None] - - @classmethod - def _new_member( - cls, - value: str, - label: str, - executor: Callable[..., None], - ) -> Self: - member = cast(Self, str.__new__(cast(type[str], cls), value)) - member._value_ = value - member.label = label - member._executor = executor - return member - - def invoke(self, target: object) -> None: - """Invoke this member's declared execution leaf for its owning target.""" - - self._executor(target) diff --git a/src/pyqt_reactive/services/field_change_dispatcher.py b/src/pyqt_reactive/services/field_change_dispatcher.py index 7e30ec99..c282cb39 100644 --- a/src/pyqt_reactive/services/field_change_dispatcher.py +++ b/src/pyqt_reactive/services/field_change_dispatcher.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any -from ..flash_trace import flash_trace +from formbridge.flash_trace import flash_trace if TYPE_CHECKING: diff --git a/src/pyqt_reactive/services/flag_context_manager.py b/src/pyqt_reactive/services/flag_context_manager.py deleted file mode 100644 index 52eb3d46..00000000 --- a/src/pyqt_reactive/services/flag_context_manager.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Metaprogrammed context manager factory for boolean flag management. - -This module provides a universal context manager for managing temporary boolean flags -on objects, following ObjectState's context-manager pattern. - -Key features: -1. Single implementation handles all flag patterns -2. Automatic save/restore of previous values -3. Enum-based validation for type safety -4. Composable and nestable -5. Guaranteed cleanup even on exception - -Pattern: - Instead of: - self._in_reset = True - try: - # ... logic - finally: - self._in_reset = False - - Use: - with FlagContextManager.manage_flags(self, _in_reset=True): - # ... logic - -This eliminates duplicate try/finally patterns and ensures flags are always restored. -""" - -from contextlib import contextmanager -from enum import Enum -from typing import Any, Dict, Set -import logging - -logger = logging.getLogger(__name__) - - -class ManagerFlag(Enum): - """ - Registry of valid ParameterFormManager flags. - - This enum serves as: - 1. Documentation of all available flags - 2. Validation for FlagContextManager - 3. Type-safe flag references - - Add new flags here as they're introduced to the codebase. - """ - IN_RESET = '_in_reset' - BLOCK_CROSS_WINDOW = '_block_cross_window_updates' - INITIAL_LOAD_COMPLETE = '_initial_load_complete' - - -class FlagContextManager: - """ - Metaprogrammed context manager factory for boolean flag management. - - This class provides a universal context manager that can manage any combination - of boolean flags on an object, with automatic save/restore and validation. - - Examples: - # Single flag: - with FlagContextManager.manage_flags(self, _in_reset=True): - self._reset_parameter_impl(param_name) - - # Multiple flags: - with FlagContextManager.manage_flags(self, _in_reset=True, _block_cross_window_updates=True): - for param_name in param_names: - self._reset_parameter_impl(param_name) - - # Convenience method for reset: - with FlagContextManager.reset_context(self): - # ... reset logic - """ - - # Registry of valid flags (extracted from enum) - VALID_FLAGS: Set[str] = {flag.value for flag in ManagerFlag} - - @staticmethod - @contextmanager - def manage_flags(obj: Any, **flags: bool): - """ - Context manager that sets flags on entry and restores previous values on exit. - - This is the core metaprogrammed context manager that handles all flag patterns. - It saves previous values, sets new values, and guarantees restoration even on exception. - - Args: - obj: Object to set flags on (typically ParameterFormManager instance) - **flags: Flag names and values to set (e.g., _in_reset=True) - - Raises: - ValueError: If any flag name is not in VALID_FLAGS registry - - Example: - with FlagContextManager.manage_flags(self, _in_reset=True, _block_cross_window_updates=True): - # Both flags are True here - # ... logic - # Both flags restored to previous values here - """ - # Validate flag names against registry - invalid_flags = set(flags.keys()) - FlagContextManager.VALID_FLAGS - if invalid_flags: - raise ValueError( - f"Invalid flags: {invalid_flags}. " - f"Valid flags: {FlagContextManager.VALID_FLAGS}. " - f"Add new flags to ManagerFlag enum." - ) - - # Save previous values - # ANTI-DUCK-TYPING: Use direct attribute access (fail-loud if flag doesn't exist) - # All flags must be initialized in ParameterFormManager.__init__ - prev_values: Dict[str, bool] = {} - for flag_name in flags: - prev_values[flag_name] = getattr(obj, flag_name) # No default - fail if missing - logger.debug(f"Saving flag {flag_name}={prev_values[flag_name]} on {type(obj).__name__}") - - # Set new values - for flag_name, flag_value in flags.items(): - setattr(obj, flag_name, flag_value) - logger.debug(f"Setting flag {flag_name}={flag_value} on {type(obj).__name__}") - - try: - yield - finally: - # Restore previous values (guaranteed even on exception) - for flag_name, prev_value in prev_values.items(): - setattr(obj, flag_name, prev_value) - logger.debug(f"Restoring flag {flag_name}={prev_value} on {type(obj).__name__}") - - @staticmethod - @contextmanager - def reset_context(obj: Any, block_cross_window: bool = True): - """ - Convenience context manager for reset operations. - - This is a specialized version of manage_flags() for the common reset pattern. - It sets _in_reset=True and optionally _block_cross_window_updates=True. - - Args: - obj: Object to set flags on (typically ParameterFormManager instance) - block_cross_window: If True, also block cross-window updates (default: True) - - Example: - # Single parameter reset (don't block cross-window): - with FlagContextManager.reset_context(self, block_cross_window=False): - self._reset_parameter_impl(param_name) - - # Batch reset (block cross-window): - with FlagContextManager.reset_context(self): - for param_name in param_names: - self._reset_parameter_impl(param_name) - """ - flags = {ManagerFlag.IN_RESET.value: True} - if block_cross_window: - flags[ManagerFlag.BLOCK_CROSS_WINDOW.value] = True - - with FlagContextManager.manage_flags(obj, **flags): - yield - - @staticmethod - @contextmanager - def initial_load_context(obj: Any): - """ - Convenience context manager for initial form load. - - Sets _initial_load_complete=False during form building, then sets it to True on exit. - This disables live updates during initial form construction. - - Args: - obj: Object to set flags on (typically ParameterFormManager instance) - - Example: - with FlagContextManager.initial_load_context(self): - self.build_form() - # _initial_load_complete is now True - """ - # Set flag to False during load - # ANTI-DUCK-TYPING: Use direct attribute access (fail-loud if flag doesn't exist) - prev_value = getattr(obj, ManagerFlag.INITIAL_LOAD_COMPLETE.value) # No default - fail if missing - setattr(obj, ManagerFlag.INITIAL_LOAD_COMPLETE.value, False) - - try: - yield - finally: - # Set to True on exit (load complete) - setattr(obj, ManagerFlag.INITIAL_LOAD_COMPLETE.value, True) - - @staticmethod - def is_flag_set(obj: Any, flag: ManagerFlag) -> bool: - """ - Check if a flag is currently set to True. - - Args: - obj: Object to check flag on - flag: ManagerFlag enum value - - Returns: - True if flag is set, False otherwise - - Example: - if FlagContextManager.is_flag_set(self, ManagerFlag.IN_RESET): - return # Skip expensive operation during reset - """ - # ANTI-DUCK-TYPING: Use direct attribute access (fail-loud if flag doesn't exist) - return getattr(obj, flag.value) # No default - fail if missing - - @staticmethod - def get_flag_state(obj: Any) -> Dict[str, bool]: - """ - Get current state of all registered flags. - - Useful for debugging and logging. - - Args: - obj: Object to get flag state from - - Returns: - Dict mapping flag names to their current values - - Example: - state = FlagContextManager.get_flag_state(self) - logger.debug(f"Flag state: {state}") - """ - # ANTI-DUCK-TYPING: Use direct attribute access (fail-loud if flag doesn't exist) - return { - flag.value: getattr(obj, flag.value) # No default - fail if missing - for flag in ManagerFlag - } diff --git a/src/pyqt_reactive/services/function_list_editor_actions.py b/src/pyqt_reactive/services/function_list_editor_actions.py index 303203f6..4e943063 100644 --- a/src/pyqt_reactive/services/function_list_editor_actions.py +++ b/src/pyqt_reactive/services/function_list_editor_actions.py @@ -6,7 +6,7 @@ from enum import StrEnum from typing import TYPE_CHECKING, Self -from pyqt_reactive.services.executable_action import LabeledExecutableActionMixin +from formbridge.services.executable_action import LabeledExecutableActionMixin if TYPE_CHECKING: from PyQt6.QtWidgets import QPushButton @@ -66,7 +66,7 @@ def create_button(self, widget: FunctionListEditorWidget) -> QPushButton: from PyQt6.QtWidgets import QPushButton - from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT + from formbridge.forms.layout_constants import CURRENT_LAYOUT button = QPushButton(self.label) button.setObjectName(self.object_name) diff --git a/src/pyqt_reactive/services/function_navigation.py b/src/pyqt_reactive/services/function_navigation.py deleted file mode 100644 index a9e685fa..00000000 --- a/src/pyqt_reactive/services/function_navigation.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Typed helpers for function-pattern field and navigation payloads.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import ClassVar, Mapping, Optional - -from objectstate import DottedFieldPath - - -class FunctionPatternField: - """Nominal owner for function-pattern parent-field semantics.""" - - name: ClassVar[str] = "func" - token_field_prefix: ClassVar[str] = "func.token:" - target_delimiter: ClassVar[str] = "|" - - @classmethod - def parameter_name(cls) -> str: - return cls.name - - @classmethod - def parameter_in(cls, values: Mapping[str, object]) -> bool: - return cls.parameter_name() in values - - @classmethod - def value_from(cls, values: Mapping[str, object]) -> object: - return values[cls.parameter_name()] - - @classmethod - def scope_token_prefix(cls) -> str: - return cls.parameter_name() - - @classmethod - def owns_field_path(cls, field_path: str | None) -> bool: - """Return whether a field path belongs to the function-pattern field.""" - - if not isinstance(field_path, str): - return False - return ( - DottedFieldPath(cls.parameter_name()).contains_path(field_path) - or field_path.startswith(cls.token_field_prefix) - ) - - -FUNCTION_FIELD_ROOT = FunctionPatternField.parameter_name() -FUNCTION_TOKEN_FIELD_PREFIX = FunctionPatternField.token_field_prefix -FUNCTION_TARGET_DELIMITER = FunctionPatternField.target_delimiter - - -@dataclass(frozen=True) -class FunctionFieldTarget: - """Parsed function navigation target.""" - - token: Optional[str] - index: Optional[int] - base_field_path: str - - -def is_function_field_path(field_path: str | None) -> bool: - """Return True when the field path targets function-pattern UI.""" - return FunctionPatternField.owns_field_path(field_path) - - -def build_function_token_field_path( - token: str, - fallback_base_field_path: str = FUNCTION_FIELD_ROOT, -) -> str: - """Build a token-scoped function field path payload.""" - normalized_token = token.strip() - if not normalized_token: - raise ValueError("Function token must be non-empty.") - normalized_base = fallback_base_field_path.strip() or FunctionPatternField.parameter_name() - return ( - f"{FunctionPatternField.token_field_prefix}{normalized_token}" - f"{FunctionPatternField.target_delimiter}{normalized_base}" - ) - - -def parse_function_field_target(field_path: str) -> FunctionFieldTarget: - """Parse function field path into token/index/base components.""" - normalized = field_path.strip() - token: Optional[str] = None - - if normalized.startswith(FunctionPatternField.token_field_prefix): - payload = normalized[len(FunctionPatternField.token_field_prefix) :] - if FunctionPatternField.target_delimiter in payload: - token_part, remainder = payload.split( - FunctionPatternField.target_delimiter, - 1, - ) - token = token_part.strip() or None - normalized = remainder.strip() or FunctionPatternField.parameter_name() - else: - token = payload.strip() or None - normalized = FunctionPatternField.parameter_name() - - index: Optional[int] = None - rest = normalized - if rest.startswith(FunctionPatternField.parameter_name()): - rest = rest[len(FunctionPatternField.parameter_name()) :] - - if rest.startswith("["): - close = rest.find("]") - if close > 1: - digits = rest[1:close] - if digits.isdigit(): - index = int(digits) - elif rest.startswith("."): - parts = rest[1:].split(".", 1) - if parts and parts[0].isdigit(): - index = int(parts[0]) - - return FunctionFieldTarget( - token=token, - index=index, - base_field_path=normalized, - ) diff --git a/src/pyqt_reactive/services/function_pattern_code_document.py b/src/pyqt_reactive/services/function_pattern_code_document.py deleted file mode 100644 index ae09cc1e..00000000 --- a/src/pyqt_reactive/services/function_pattern_code_document.py +++ /dev/null @@ -1,1190 +0,0 @@ -"""Function-pattern code document roundtrip services. - -This module owns the non-widget semantics for pycodifying function patterns, -preserving stable function-entry ObjectState tokens, and servicing code-mode -documents for nested function scopes. -""" - -from __future__ import annotations - -import inspect -import logging -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import Any, ClassVar, get_type_hints - -from objectstate import ( - ObjectState, - ObjectStateRegistry, - patch_lazy_constructors, - semantic_values_equal, -) -from python_introspect import ( - callable_declaration_kwargs, - parameter_exclusions, - set_parameter_exclusions, -) - -from pyqt_reactive.forms.parameter_value_contracts import ParameterValue -from pyqt_reactive.pattern_metadata import PatternScopeToken -from pyqt_reactive.protocols import get_codegen_provider -from pyqt_reactive.services.function_navigation import FunctionPatternField -from pyqt_reactive.services.pattern_data_manager import ( - FUNC_EDITOR_PATTERN_TOKENS_META_KEY, -) -from pyqt_reactive.services.scope_token_service import ( - ScopeTokenGenerator, - reconcile_occurrence_tokens, -) -from pyqt_reactive.services.window_code_document import ( - PYTHON_MIME_TYPE, - WindowCodeDocument, - WindowCodeDocumentDriver, -) -from objectstate import ScopeKey - -logger = logging.getLogger(__name__) -FUNCTION_PATTERN_AUTHORITY_ATTR = "__function_pattern_authority__" - - -def function_pattern_authority(func): - """Return the real callable authority for an editable function-pattern view.""" - return getattr(func, FUNCTION_PATTERN_AUTHORITY_ATTR, func) - - -FunctionAuthority = Callable[..., ParameterValue] -"""Callable identity owned by a function-pattern entry.""" - - -FunctionKwargs = dict[str, ParameterValue] -FunctionPatternItem = tuple[FunctionAuthority, FunctionKwargs] -FunctionPatternList = list[FunctionPatternItem] -FunctionPatternByKey = dict[str, FunctionPatternList] -PatternTokens = list[str] | dict[str, list[str]] -PatternSourceValue = ( - FunctionAuthority | FunctionPatternItem | FunctionPatternList | FunctionPatternByKey -) - - -class FunctionPatternRoundTripError(RuntimeError): - """Raised when a function-pattern code document cannot be serviced.""" - - -@dataclass(frozen=True, slots=True) -class TokenizedFunctionEntry: - """Function pattern entry paired with its stable ObjectState token.""" - - func: FunctionAuthority - kwargs: FunctionKwargs - token: str - - -@dataclass(frozen=True, slots=True) -class FunctionPatternValue: - """Current callable and kwargs for one stable function token.""" - - func: FunctionAuthority - kwargs: FunctionKwargs - - -class EditableFunctionPatternCallable: - """Callable view whose signature includes explicit function-pattern kwargs.""" - - def __init__( - self, - authority: FunctionAuthority, - kwargs: Mapping[str, ParameterValue] | None, - ) -> None: - self.__function_pattern_authority__ = authority - self.__name__ = getattr(authority, "__name__", type(authority).__name__) - self.__qualname__ = getattr(authority, "__qualname__", self.__name__) - self.__module__ = getattr(authority, "__module__", type(authority).__module__) - self.__doc__ = getattr(authority, "__doc__", None) - self.__wrapped__ = authority - self.__annotations__ = self._annotations(authority, kwargs or {}) - self.__signature__ = self._signature(authority, kwargs or {}) - hidden = parameter_exclusions(authority).difference(kwargs or ()) - if hidden: - set_parameter_exclusions(self, hidden) - - def __call__(self, *args: ParameterValue, **kwargs: ParameterValue): - return self.__function_pattern_authority__(*args, **kwargs) - - @classmethod - def for_entry( - cls, - func: FunctionAuthority, - kwargs: Mapping[str, ParameterValue] | None, - ) -> FunctionAuthority: - """Return an editable callable view when kwargs extend the signature.""" - if not kwargs: - return func - try: - signature = inspect.signature(func) - except (TypeError, ValueError): - return func - if all(name in signature.parameters for name in kwargs) and not parameter_exclusions( - func - ).intersection(kwargs): - return func - return cls(func, kwargs) - - @staticmethod - def _signature( - authority: FunctionAuthority, - kwargs: Mapping[str, ParameterValue], - ) -> inspect.Signature: - signature = inspect.signature(authority) - resolved_annotations = EditableFunctionPatternCallable._resolved_annotations(authority) - parameters = [ - ( - parameter.replace(annotation=resolved_annotations[name]) - if name in resolved_annotations - else parameter - ) - for name, parameter in signature.parameters.items() - ] - existing = set(signature.parameters) - for name, value in kwargs.items(): - if name in existing: - continue - parameters.append( - inspect.Parameter( - name, - inspect.Parameter.KEYWORD_ONLY, - default=value, - annotation=EditableFunctionPatternCallable._annotation(value), - ) - ) - return signature.replace(parameters=parameters) - - @staticmethod - def _annotations( - authority: FunctionAuthority, - kwargs: Mapping[str, ParameterValue], - ) -> dict[str, Any]: - annotations = EditableFunctionPatternCallable._resolved_annotations(authority) - for name, value in kwargs.items(): - annotations.setdefault( - name, - EditableFunctionPatternCallable._annotation(value), - ) - return annotations - - @staticmethod - def _resolved_annotations(authority: FunctionAuthority) -> dict[str, Any]: - try: - return get_type_hints(authority, include_extras=True) - except Exception: - return dict(getattr(authority, "__annotations__", {})) - - @staticmethod - def _annotation(value: ParameterValue) -> Any: - if value is None: - return Any - if isinstance(value, tuple): - element_types = {type(item) for item in value if item is not None} - if len(element_types) == 1: - return tuple[next(iter(element_types)), ...] - return tuple[Any, ...] - return type(value) - - -@dataclass(frozen=True, slots=True) -class FunctionPatternParameterExclusions: - """Editable-parameter exclusions for function-pattern ObjectStates.""" - - names: tuple[str, ...] - - @classmethod - def from_callable( - cls, - func: FunctionAuthority, - ) -> FunctionPatternParameterExclusions: - ordered_names = cls._ordered_names( - cls._first_positional_parameter_name(func), - tuple(parameter_exclusions(func)), - ) - return cls(names=ordered_names) - - @staticmethod - def _first_positional_parameter_name(func: FunctionAuthority) -> str | None: - try: - signature = inspect.signature(func) - except (TypeError, ValueError): - return None - - for param_name in signature.parameters: - if param_name in ("self", "cls"): - continue - return param_name - return None - - @staticmethod - def _ordered_names( - positional_name: str | None, - declared_names: tuple[str, ...], - ) -> tuple[str, ...]: - names: list[str] = [] - if positional_name is not None: - names.append(positional_name) - names.extend(declared_names) - return tuple(dict.fromkeys(names)) - - def as_list(self) -> list[str] | None: - if not self.names: - return None - return list(self.names) - - -@dataclass(frozen=True, slots=True) -class FunctionPatternChildScopeAddress: - """Generic ObjectState child scope for one function-pattern entry.""" - - scope_id: str - parent_scope_id: str - token: str - - @classmethod - def parse(cls, scope_id: str) -> FunctionPatternChildScopeAddress: - scope = ScopeKey(scope_id) - if scope.depth < 2: - raise FunctionPatternRoundTripError( - "Function-pattern child scopes must have a parent scope and " - f"an entry token; got {scope_id!r}." - ) - return cls( - scope_id=scope, - parent_scope_id=scope.parent, - token=scope.last, - ) - - -class FunctionPatternCodeDocumentService: - """Code/document and ObjectState semantics for function patterns.""" - - pattern_assignment_name: ClassVar[str] = "pattern" - token_prefix: ClassVar[str] = FunctionPatternField.scope_token_prefix() - - def __init__(self) -> None: - self._func_token_generator = ScopeTokenGenerator( - self.token_prefix, - attr_name=None, - ) - - @classmethod - def sanitize_pattern_kwargs( - cls, - kwargs: Mapping[str, ParameterValue] | None, - ) -> FunctionKwargs: - """Validate kwargs and reject internal scope-token metadata.""" - if kwargs is None: - return {} - if PatternScopeToken.key_in(kwargs): - raise FunctionPatternRoundTripError( - "Function kwargs contain internal scope-token metadata. " - "Tokens must be stored in ObjectState metadata." - ) - clean_kwargs: FunctionKwargs = {} - for key, value in kwargs.items(): - if not isinstance(key, str): - raise TypeError( - f"Function kwargs must be keyed by parameter name; got key {key!r}." - ) - clean_kwargs[key] = value - return clean_kwargs - - @classmethod - def function_and_kwargs( - cls, - func_item, - ) -> FunctionPatternItem | None: - """Return one normalized function-pattern entry.""" - if isinstance(func_item, tuple) and len(func_item) == 2 and callable(func_item[0]): - func, kwargs = func_item - if not isinstance(kwargs, Mapping): - raise TypeError("Function-pattern tuple entries must carry a kwargs mapping.") - return func, cls.sanitize_pattern_kwargs(kwargs) - - if callable(func_item): - return func_item, {} - - return None - - def generate_complete_function_pattern_code( - self, - pattern_data: PatternSourceValue, - *, - clean_mode: bool = False, - ) -> str: - """Render a complete Python document for a function pattern.""" - provider = get_codegen_provider() - if provider is None: - raise FunctionPatternRoundTripError( - "No codegen provider registered. Call register_codegen_provider(...)." - ) - return provider.render_assignment( - pattern_data, - assignment_name=self.pattern_assignment_name, - header="# Edit this function pattern and save to apply changes", - clean_mode=clean_mode, - ) - - def pattern_from_source(self, source: str) -> PatternSourceValue: - """Parse a function-pattern source document and return `pattern`.""" - if not isinstance(source, str): - raise TypeError( - f"Function-pattern source must be a string; got {type(source).__name__}." - ) - - namespace = {} - with patch_lazy_constructors(): - exec(source, namespace) - - if self.pattern_assignment_name not in namespace: - raise FunctionPatternRoundTripError( - f"No {self.pattern_assignment_name!r} assignment found in code." - ) - return namespace[self.pattern_assignment_name] - - def normalize_function_list( - self, - func_list, - *, - seen_tokens: set[str] | None = None, - seed_tokens: list[str] | None = None, - ) -> tuple[FunctionPatternList, list[str]]: - """Normalize function entries and align them with stable tokens.""" - active_seen_tokens = seen_tokens if seen_tokens is not None else set() - active_seed_tokens = seed_tokens if seed_tokens is not None else [] - - normalized = self._normalized_entries(func_list) - tokens: list[str] = [] - for index, _entry in enumerate(normalized): - seed_token = ( - str(active_seed_tokens[index]) - if index < len(active_seed_tokens) and active_seed_tokens[index] - else None - ) - token = seed_token or self._func_token_generator.ensure() - if token in active_seen_tokens: - token = self._func_token_generator.ensure() - active_seen_tokens.add(token) - tokens.append(str(token)) - - return normalized, tokens - - def seed_func_token_generator(self, tokens: PatternTokens) -> None: - """Seed token generator from canonical sidecar metadata tokens.""" - token_values: list[str] = [] - if isinstance(tokens, list): - token_values.extend(str(token) for token in tokens if token) - elif isinstance(tokens, dict): - for channel_tokens in tokens.values(): - token_values.extend(str(token) for token in channel_tokens if token) - if token_values: - self._func_token_generator.seed_from_tokens(token_values) - - def ensure_token(self) -> str: - """Return a fresh function-entry token.""" - return self._func_token_generator.ensure() - - def tokens_for_pattern( - self, - pattern: PatternSourceValue | None, - ) -> PatternTokens: - """Return fresh occurrence-owned tokens for a new function pattern.""" - - seen_tokens: set[str] = set() - if isinstance(pattern, dict): - tokens_by_key: dict[str, list[str]] = {} - for key, func_list in pattern.items(): - _normalized, tokens = self.normalize_function_list( - func_list, - seen_tokens=seen_tokens, - ) - tokens_by_key[str(key)] = tokens - return tokens_by_key - _normalized, tokens = self.normalize_function_list( - pattern, - seen_tokens=seen_tokens, - ) - return tokens - - def reconcile_pattern_tokens( - self, - previous_pattern: PatternSourceValue | None, - previous_tokens: PatternTokens | None, - next_pattern: PatternSourceValue | None, - ) -> PatternTokens: - """Reconcile complete-pattern tokens by declaration and callable authority.""" - - existing_tokens = previous_tokens or [] - self.seed_func_token_generator(existing_tokens) - claimed_tokens: set[str] = set() - if isinstance(next_pattern, dict): - previous_by_key = previous_pattern if isinstance(previous_pattern, dict) else {} - tokens_by_key = existing_tokens if isinstance(existing_tokens, dict) else {} - return { - str(key): self._reconcile_function_list_tokens( - previous_by_key.get(str(key)), - tokens_by_key.get(str(key), []), - func_list, - claimed_tokens=claimed_tokens, - ) - for key, func_list in next_pattern.items() - } - return self._reconcile_function_list_tokens( - previous_pattern if not isinstance(previous_pattern, dict) else [], - existing_tokens if isinstance(existing_tokens, list) else [], - next_pattern, - claimed_tokens=claimed_tokens, - ) - - def _reconcile_function_list_tokens( - self, - previous_list, - previous_tokens: list[str], - next_list, - *, - claimed_tokens: set[str], - ) -> list[str]: - """Reconcile one list-shaped region of a function pattern.""" - - previous_entries = self._normalized_entries(previous_list) - next_entries = self._normalized_entries(next_list) - return reconcile_occurrence_tokens( - previous_entries, - previous_tokens, - next_entries, - same_declaration=self.same_function_declaration, - occurrence_authorities=lambda entry: (function_pattern_authority(entry[0]),), - token_factory=self.ensure_token, - claimed_tokens=claimed_tokens, - ) - - @classmethod - def _normalized_entries(cls, func_list) -> FunctionPatternList: - """Return normalized entries without assigning occurrence identity.""" - - if ( - isinstance(func_list, tuple) - and len(func_list) == 2 - and callable(func_list[0]) - and isinstance(func_list[1], Mapping) - ) or callable(func_list): - values = [func_list] - elif not func_list: - return [] - elif isinstance(func_list, list): - values = func_list - else: - raise FunctionPatternRoundTripError( - "Function pattern reconciliation requires a callable, " - "(callable, kwargs) tuple, list, or empty value." - ) - entries: FunctionPatternList = [] - for index, value in enumerate(values): - entry = cls.function_and_kwargs(value) - if entry is None: - raise FunctionPatternRoundTripError( - f"Unsupported function-pattern entry at index {index}: {value!r}." - ) - entries.append(entry) - return entries - - def canonical_function_scope_tokens( - self, - *, - parent_scope_id: str | None, - func_list, - candidate_tokens: list[str], - ) -> list[str]: - """Return candidate tokens when they still name registered child states.""" - if self.function_scope_tokens_match( - parent_scope_id=parent_scope_id, - func_list=func_list, - tokens=candidate_tokens, - ): - return candidate_tokens - return self.existing_function_scope_tokens( - parent_scope_id=parent_scope_id, - func_list=func_list, - ) - - def existing_function_scope_tokens( - self, - *, - parent_scope_id: str | None, - func_list, - ) -> list[str]: - """Return existing child ObjectState tokens aligned with a function list.""" - if not parent_scope_id: - return [] - - parent_scope = ScopeKey(parent_scope_id) - candidate_states = [ - state - for state in ObjectStateRegistry.get_all() - if isinstance(state.scope_id, str) and parent_scope.strictly_contains(state.scope_id) - ] - if not candidate_states: - return [] - - available = sorted( - candidate_states, - key=lambda state: ScopeKey(state.scope_id).last, - ) - tokens: list[str] = [] - used_scope_ids: set[str] = set() - item_list = func_list if isinstance(func_list, list) else [func_list] - for item in item_list: - entry = self.function_and_kwargs(item) - if entry is None: - continue - func, _kwargs = entry - match = next( - ( - state - for state in available - if state.scope_id not in used_scope_ids - and self.same_function_authority(state.object_instance, func) - ), - None, - ) - if match is None: - return [] - used_scope_ids.add(match.scope_id) - tokens.append(ScopeKey(match.scope_id).last) - return tokens - - def function_scope_tokens_match( - self, - *, - parent_scope_id: str | None, - func_list, - tokens: list[str], - ) -> bool: - """Check whether tokens point at registered ObjectStates for funcs.""" - if not parent_scope_id or not tokens: - return False - - item_list = func_list if isinstance(func_list, list) else [func_list] - if len(tokens) < len(item_list): - return False - - for index, item in enumerate(item_list): - entry = self.function_and_kwargs(item) - if entry is None: - continue - func, _kwargs = entry - state = ObjectStateRegistry.get_by_scope(ScopeKey(parent_scope_id).child(tokens[index])) - if state is None: - return False - if not self.same_function_authority(state.object_instance, func): - return False - return True - - @staticmethod - def same_function_authority(left, right) -> bool: - """Return whether two callable objects represent the same authority.""" - left = function_pattern_authority(left) - right = function_pattern_authority(right) - left_declaration = inspect.unwrap(left) - right_declaration = inspect.unwrap(right) - return left_declaration is right_declaration or semantic_values_equal( - left_declaration, - right_declaration, - ) - - @classmethod - def same_function_declaration( - cls, - left: FunctionPatternItem, - right: FunctionPatternItem, - ) -> bool: - """Return whether callable authority and declared kwargs are unchanged.""" - - return cls.same_function_authority( - left[0], - right[0], - ) and semantic_values_equal( - callable_declaration_kwargs( - function_pattern_authority(left[0]), - left[1], - values_equal=semantic_values_equal, - ), - callable_declaration_kwargs( - function_pattern_authority(right[0]), - right[1], - values_equal=semantic_values_equal, - ), - ) - - @classmethod - def iter_tokenized_entries( - cls, - pattern: FunctionPatternList | FunctionPatternByKey, - tokens: PatternTokens, - ) -> list[TokenizedFunctionEntry]: - """Return flat (func, kwargs, token) entries from pattern + tokens.""" - entries: list[TokenizedFunctionEntry] = [] - if isinstance(pattern, dict): - token_map = tokens if isinstance(tokens, dict) else {} - for channel_key, items in pattern.items(): - channel_items = items if isinstance(items, list) else [items] - channel_tokens = token_map.get(str(channel_key), []) - for index, item in enumerate(channel_items): - entry = cls.function_and_kwargs(item) - if entry is None: - continue - token = ( - str(channel_tokens[index]) - if index < len(channel_tokens) and channel_tokens[index] - else "" - ) - entries.append( - TokenizedFunctionEntry( - func=entry[0], - kwargs=entry[1], - token=token, - ) - ) - return entries - - item_list = pattern if isinstance(pattern, list) else [pattern] - token_list = tokens if isinstance(tokens, list) else [] - for index, item in enumerate(item_list): - entry = cls.function_and_kwargs(item) - if entry is None: - continue - token = str(token_list[index]) if index < len(token_list) and token_list[index] else "" - entries.append( - TokenizedFunctionEntry( - func=entry[0], - kwargs=entry[1], - token=token, - ) - ) - return entries - - def update_function_object_states( - self, - *, - parent_scope_id: str | None, - old_entries: list[TokenizedFunctionEntry], - new_entries: list[TokenizedFunctionEntry], - ) -> None: - """Update existing function ObjectStates from a code-mode edit.""" - if not parent_scope_id: - return - - old_by_token = self._entry_map(old_entries) - new_by_token = self._entry_map(new_entries) - - for token in set(old_by_token) - set(new_by_token): - self.unregister_function_state(parent_scope_id, token) - - for token in set(old_by_token) & set(new_by_token): - new_value = new_by_token[token] - scope_id = ScopeKey(parent_scope_id).child(token) - state = ObjectStateRegistry.get_by_scope(scope_id) - if state is None: - continue - parent_state = ObjectStateRegistry.get_by_scope(parent_scope_id) - if parent_state is None: - raise FunctionPatternRoundTripError( - f"Missing parent ObjectState for {parent_scope_id!r}." - ) - self.synchronize_existing_function_state( - state=state, - parent_state=parent_state, - entry=new_value, - ) - - def resolve_pattern_values( - self, - *, - parent_scope_id: str | None, - pattern: FunctionPatternList | FunctionPatternByKey, - tokens: PatternTokens, - ) -> FunctionPatternList | FunctionPatternByKey: - """Project editable kwargs from materialized child states, including hidden groups. - - Entries without an opened child scope still take their values from the - declaration. Once materialized, the child ObjectState owns its edits; - cached pane kwargs are not another value authority. - """ - if parent_scope_id is None: - return pattern - resolved = pattern - for entry in self.iter_tokenized_entries(pattern, tokens): - state = ObjectStateRegistry.get_by_scope(ScopeKey(parent_scope_id).child(entry.token)) - if state is None: - continue - if not self.same_function_authority(entry.func, state.object_instance): - raise FunctionPatternRoundTripError( - f"Function token {entry.token!r} names a different callable." - ) - resolved = self._replace_pattern_entry( - pattern=resolved, - tokens=tokens, - token=entry.token, - next_value=FunctionPatternValue( - func=entry.func, - kwargs=self.reconstruct_kwargs_from_state(state), - ), - ) - return resolved - - @staticmethod - def _entry_map( - entries: list[TokenizedFunctionEntry], - ) -> dict[str, FunctionPatternValue]: - entry_map: dict[str, FunctionPatternValue] = {} - for entry in entries: - if not entry.token: - continue - entry_map[entry.token] = FunctionPatternValue( - entry.func, - FunctionPatternCodeDocumentService.sanitize_pattern_kwargs(entry.kwargs), - ) - return entry_map - - @classmethod - def apply_kwargs_to_state( - cls, - *, - state: ObjectState, - previous_kwargs: FunctionKwargs, - next_kwargs: FunctionKwargs, - ) -> None: - """Apply kwargs into an existing child ObjectState.""" - for param_name, next_param_value in next_kwargs.items(): - previous_param_value = previous_kwargs.get(param_name) - if previous_param_value != next_param_value and param_name in state.parameters: - state.update_parameter(param_name, next_param_value) - - for param_name in previous_kwargs: - if param_name not in next_kwargs and param_name in state.parameters: - state.reset_parameter(param_name) - - @staticmethod - def _editable_callable_surface(func: FunctionAuthority) -> tuple[tuple, frozenset[str]]: - """Return the parameter surface that an ObjectState can represent.""" - - signature = inspect.signature(func) - return ( - tuple( - (parameter.name, parameter.kind, parameter.annotation) - for parameter in signature.parameters.values() - ), - parameter_exclusions(func), - ) - - @classmethod - def synchronize_existing_function_state( - cls, - *, - state: ObjectState, - parent_state: ObjectState, - entry: FunctionPatternValue, - ) -> ObjectState: - """Synchronize one existing child, replacing it when its form changes.""" - - next_callable = EditableFunctionPatternCallable.for_entry( - entry.func, - entry.kwargs, - ) - if not cls.same_function_authority(state.object_instance, entry.func) or ( - cls._editable_callable_surface(state.object_instance) - != cls._editable_callable_surface(next_callable) - ): - cls.replace_function_state( - scope_id=state.scope_id, - parent_state=parent_state, - entry=entry, - ) - replacement = ObjectStateRegistry.get_by_scope(state.scope_id) - if replacement is None: - raise FunctionPatternRoundTripError( - f"Function child state {state.scope_id!r} was not replaced." - ) - return replacement - - cls.apply_kwargs_to_state( - state=state, - previous_kwargs=cls.reconstruct_kwargs_from_state(state), - next_kwargs=entry.kwargs, - ) - if cls._state_roundtrips_entry(state, entry): - return state - - # A restored ObjectState can carry the flattened parameter schema from - # an older declaration even though its callable now resolves to the - # current function. Updating a dataclass container cannot safely add - # or remove fields that the stale state never registered. Replace the - # child from the declaration rather than accepting a lossy code-mode - # application whose rendered source or execution would use defaults. - cls.replace_function_state( - scope_id=state.scope_id, - parent_state=parent_state, - entry=entry, - ) - replacement = ObjectStateRegistry.get_by_scope(state.scope_id) - if replacement is None or not cls._state_roundtrips_entry(replacement, entry): - raise FunctionPatternRoundTripError( - f"Function child state {state.scope_id!r} could not preserve its " - "declared kwargs through ObjectState reconstruction." - ) - return replacement - - @classmethod - def _state_roundtrips_entry( - cls, - state: ObjectState, - entry: FunctionPatternValue, - ) -> bool: - """Return whether ObjectState preserves the entry's declared kwargs.""" - - authority = function_pattern_authority(entry.func) - expected = callable_declaration_kwargs( - authority, - entry.kwargs, - values_equal=semantic_values_equal, - ) - reconstructed = callable_declaration_kwargs( - authority, - cls.reconstruct_kwargs_from_state(state), - values_equal=semantic_values_equal, - ) - return semantic_values_equal(expected, reconstructed) - - @staticmethod - def unregister_function_state(parent_scope_id: str, token: str) -> None: - """Unregister one function child ObjectState if it is present.""" - state = ObjectStateRegistry.get_by_scope(ScopeKey(parent_scope_id).child(token)) - if state is not None: - ObjectStateRegistry.unregister(state, _skip_snapshot=True) - - @classmethod - def create_function_state( - cls, - *, - scope_id: str, - parent_state: ObjectState, - entry: FunctionPatternValue, - ) -> ObjectState: - """Construct an unregistered child from its declared function pattern.""" - editable_func = EditableFunctionPatternCallable.for_entry( - entry.func, - entry.kwargs, - ) - return ObjectState( - object_instance=editable_func, - scope_id=scope_id, - parent_state=parent_state, - exclude_params=cls.reserved_parameter_names(editable_func), - initial_values=dict(entry.kwargs), - ) - - @classmethod - def replace_function_state( - cls, - *, - scope_id: str, - parent_state: ObjectState, - entry: FunctionPatternValue, - ) -> None: - """Replace one child ObjectState while preserving its scope token.""" - current = ObjectStateRegistry.get_by_scope(scope_id) - if current is not None: - ObjectStateRegistry.unregister(current, _skip_snapshot=True) - func_state = cls.create_function_state( - scope_id=scope_id, parent_state=parent_state, entry=entry - ) - ObjectStateRegistry.register(func_state, _skip_snapshot=True) - - @staticmethod - def reserved_parameter_names(func: FunctionAuthority) -> list[str] | None: - """Return reserved positional input parameter names for a callable.""" - return FunctionPatternParameterExclusions.from_callable(func).as_list() - - @classmethod - def reconstruct_kwargs_from_state(cls, state: ObjectState) -> FunctionKwargs: - """Reconstruct top-level kwargs from a flattened ObjectState.""" - return cls.sanitize_pattern_kwargs(state.reconstruct_top_level_parameters()) - - def is_function_child_scope(self, scope_id: str) -> bool: - """Return whether scope_id is a registered function-pattern child scope.""" - try: - address = FunctionPatternChildScopeAddress.parse(scope_id) - except FunctionPatternRoundTripError: - return False - - parent_state = ObjectStateRegistry.get_by_scope(address.parent_scope_id) - child_state = ObjectStateRegistry.get_by_scope(address.scope_id) - return ( - parent_state is not None - and child_state is not None - and self._parent_state_has_token(parent_state, address.token) - ) - - def child_scope_entry( - self, - scope_id: str, - ) -> FunctionPatternValue: - """Return the current function-pattern value for a child ObjectState.""" - address = FunctionPatternChildScopeAddress.parse(scope_id) - parent_state = self.require_parent_state(address) - child_state = self.require_child_state(address) - self.require_parent_token(parent_state, address.token) - if not callable(child_state.object_instance): - raise FunctionPatternRoundTripError( - f"Child scope {scope_id!r} does not own a callable value." - ) - return FunctionPatternValue( - func=function_pattern_authority(child_state.object_instance), - kwargs=self.reconstruct_kwargs_from_state(child_state), - ) - - def child_scope_source(self, scope_id: str, *, clean_mode: bool = True) -> str: - """Render a child function scope as a single-entry pattern document.""" - entry = self.child_scope_entry(scope_id) - return self.generate_complete_function_pattern_code( - (entry.func, entry.kwargs), - clean_mode=clean_mode, - ) - - def child_scope_title(self, scope_id: str) -> str: - """Return a stable title for a child function scope code document.""" - entry = self.child_scope_entry(scope_id) - func = entry.func - try: - name = func.__name__ - except AttributeError: - name = type(func).__name__ - return f"Edit Function: {name}" - - def validate_child_scope_source(self, scope_id: str, source: str) -> None: - """Validate source for one child function scope.""" - self.single_entry_from_source(source) - self.child_scope_entry(scope_id) - - def apply_child_scope_source(self, scope_id: str, source: str) -> None: - """Apply a single-entry source document to one function child scope.""" - address = FunctionPatternChildScopeAddress.parse(scope_id) - parent_state = self.require_parent_state(address) - self.child_scope_entry(scope_id) - next_value = self.single_entry_from_source(source) - self._replace_parent_pattern_entry( - parent_state=parent_state, - token=address.token, - next_value=next_value, - ) - - self.synchronize_existing_function_state( - state=self.require_child_state(address), - parent_state=parent_state, - entry=next_value, - ) - - def single_entry_from_source(self, source: str) -> FunctionPatternValue: - """Parse a source document that must describe exactly one entry.""" - pattern = self.pattern_from_source(source) - entry = self._single_pattern_entry(pattern) - return FunctionPatternValue(func=entry[0], kwargs=entry[1]) - - @classmethod - def _single_pattern_entry(cls, pattern: PatternSourceValue) -> FunctionPatternItem: - entry = cls.function_and_kwargs(pattern) - if entry is not None: - return entry - - if isinstance(pattern, list) and len(pattern) == 1: - entry = cls.function_and_kwargs(pattern[0]) - if entry is not None: - return entry - - raise FunctionPatternRoundTripError( - "Child function scope code documents must assign exactly one " - "callable or (callable, kwargs) entry to 'pattern'." - ) - - @staticmethod - def require_parent_state( - address: FunctionPatternChildScopeAddress, - ) -> ObjectState: - """Return the parent ObjectState for a child scope.""" - state = ObjectStateRegistry.get_by_scope(address.parent_scope_id) - if state is None: - raise FunctionPatternRoundTripError( - f"Missing parent ObjectState {address.parent_scope_id!r}." - ) - return state - - @staticmethod - def require_child_state( - address: FunctionPatternChildScopeAddress, - ) -> ObjectState: - """Return the child ObjectState for a child scope.""" - state = ObjectStateRegistry.get_by_scope(address.scope_id) - if state is None: - raise FunctionPatternRoundTripError(f"Missing child ObjectState {address.scope_id!r}.") - return state - - def require_parent_token( - self, - parent_state: ObjectState, - token: str, - ) -> None: - """Fail loudly when the parent pattern metadata does not own token.""" - if not self._parent_state_has_token(parent_state, token): - raise FunctionPatternRoundTripError( - f"Parent scope {parent_state.scope_id!r} does not declare function token {token!r}." - ) - - @staticmethod - def _parent_state_has_token(parent_state: ObjectState, token: str) -> bool: - tokens = parent_state.metadata.get(FUNC_EDITOR_PATTERN_TOKENS_META_KEY) - if isinstance(tokens, list): - return token in tokens - if isinstance(tokens, dict): - return any(token in token_list for token_list in tokens.values()) - return False - - def _replace_parent_pattern_entry( - self, - *, - parent_state: ObjectState, - token: str, - next_value: FunctionPatternValue, - ) -> None: - pattern_parameter_name = FunctionPatternField.parameter_name() - if not FunctionPatternField.parameter_in(parent_state.parameters): - raise FunctionPatternRoundTripError( - f"Parent scope {parent_state.scope_id!r} has no " - f"{pattern_parameter_name!r} parameter." - ) - tokens = parent_state.metadata.get(FUNC_EDITOR_PATTERN_TOKENS_META_KEY) - next_pattern = self._replace_pattern_entry( - pattern=FunctionPatternField.value_from(parent_state.parameters), - tokens=tokens, - token=token, - next_value=next_value, - ) - parent_state.update_parameter(pattern_parameter_name, next_pattern) - - def _replace_pattern_entry( - self, - *, - pattern, - tokens, - token: str, - next_value: FunctionPatternValue, - ): - if isinstance(tokens, list): - index = self._token_index(tokens, token) - if isinstance(pattern, list): - if index >= len(pattern): - raise FunctionPatternRoundTripError( - "Function token metadata is longer than the function pattern." - ) - updated = list(pattern) - updated[index] = self._entry_for_existing_shape( - pattern[index], - next_value, - ) - return updated - if len(tokens) == 1 and index == 0: - return self._entry_for_existing_shape(pattern, next_value) - raise FunctionPatternRoundTripError( - "List token metadata can only address a list pattern or a " - "single-entry function pattern." - ) - - if isinstance(tokens, dict): - if not isinstance(pattern, dict): - raise FunctionPatternRoundTripError( - "Dict token metadata requires a dict function pattern." - ) - for pattern_key, token_list in tokens.items(): - if token not in token_list: - continue - index = self._token_index(token_list, token) - if pattern_key not in pattern: - raise FunctionPatternRoundTripError( - f"Function token metadata references missing pattern key {pattern_key!r}." - ) - items = pattern[pattern_key] - if not isinstance(items, list): - raise FunctionPatternRoundTripError( - "Dict function pattern values must be function lists." - ) - if index >= len(items): - raise FunctionPatternRoundTripError( - "Function token metadata is longer than the keyed pattern." - ) - updated_items = list(items) - updated_items[index] = self._entry_for_existing_shape( - items[index], - next_value, - ) - updated_pattern = dict(pattern) - updated_pattern[pattern_key] = updated_items - return updated_pattern - - raise FunctionPatternRoundTripError( - "Function pattern metadata must be a list or dict of child tokens." - ) - - @staticmethod - def _token_index(tokens: list[str], token: str) -> int: - if token not in tokens: - raise FunctionPatternRoundTripError( - f"Function token {token!r} is not present in parent metadata." - ) - return tokens.index(token) - - @staticmethod - def _entry_for_existing_shape( - existing_entry, - next_value: FunctionPatternValue, - ): - if callable(existing_entry) and not next_value.kwargs: - return next_value.func - return next_value.func, next_value.kwargs - - -class FunctionPatternScopeCodeDocumentDriver(WindowCodeDocumentDriver): - """WindowCodeDocumentDriver for registered child function ObjectStates.""" - - def __init__( - self, - scope_id: str, - service: FunctionPatternCodeDocumentService | None = None, - ) -> None: - self._scope_id = scope_id - self._service = service or FunctionPatternCodeDocumentService() - - @classmethod - def handles_scope(cls, scope_id: str) -> bool: - """Return whether this driver can service the requested scope.""" - return FunctionPatternCodeDocumentService().is_function_child_scope(scope_id) - - def read_document(self, clean: bool = True) -> WindowCodeDocument: - return WindowCodeDocument( - title=self._service.child_scope_title(self._scope_id), - source=self._service.child_scope_source( - self._scope_id, - clean_mode=clean, - ), - mime_type=PYTHON_MIME_TYPE, - declaration_type=FunctionPatternValue, - ) - - def validate_source(self, source: str) -> None: - self._service.validate_child_scope_source(self._scope_id, source) - - def apply_source(self, source: str) -> None: - self._service.apply_child_scope_source(self._scope_id, source) diff --git a/src/pyqt_reactive/services/help_document.py b/src/pyqt_reactive/services/help_document.py deleted file mode 100644 index 7d1b3a41..00000000 --- a/src/pyqt_reactive/services/help_document.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Generic rich help-document content shared by help surfaces.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, replace -from enum import StrEnum -from pathlib import PurePath - -from docutils.core import publish_parts -from python_introspect import DocstringInfo - -DEFAULT_HELP_DOCUMENT_MAX_CHARS = 50_000 - - -class HelpDocumentFormat(StrEnum): - """Markup format owned by one help document.""" - - PLAIN_TEXT = "plain_text" - MARKDOWN = "markdown" - RESTRUCTURED_TEXT = "restructured_text" - - @classmethod - def from_source_path(cls, source_path: str) -> HelpDocumentFormat: - """Resolve markup from a source document's declared filename.""" - suffix = PurePath(source_path).suffix.casefold() - if suffix in {".md", ".markdown"}: - return cls.MARKDOWN - if suffix == ".rst": - return cls.RESTRUCTURED_TEXT - return cls.PLAIN_TEXT - - -@dataclass(frozen=True, slots=True) -class HelpDocument: - """One renderer-ready help document with explicit markup provenance.""" - - content: str - markup: HelpDocumentFormat = HelpDocumentFormat.PLAIN_TEXT - title: str | None = None - base_url: str | None = None - - @classmethod - def from_docstring_info( - cls, - docstring_info: DocstringInfo, - *, - title: str | None = None, - ) -> HelpDocument: - """Project structured introspection into one Markdown document.""" - sections: list[str] = [] - if title: - sections.append(f"# {_markdown_heading(title)}") - if docstring_info.summary: - sections.append(f"**{_markdown_inline(docstring_info.summary)}**") - if docstring_info.description: - sections.append(docstring_info.description.strip()) - if docstring_info.parameters: - parameter_sections = ["## Parameters"] - for name, description in docstring_info.parameters.items(): - parameter_sections.append(f"### {_markdown_code_span(name)}") - if description: - parameter_sections.append(description.strip()) - sections.append("\n\n".join(parameter_sections)) - if docstring_info.returns: - sections.append(f"## Returns\n\n{docstring_info.returns.strip()}") - if docstring_info.examples: - fence = _code_fence(docstring_info.examples) - sections.append( - f"## Examples\n\n{fence}python\n" f"{docstring_info.examples.rstrip()}\n{fence}" - ) - return cls( - content="\n\n".join(section for section in sections if section), - markup=HelpDocumentFormat.MARKDOWN, - title=title, - ) - - @classmethod - def from_parameter_content( - cls, - *, - summary: str, - description: str, - title: str | None = None, - ) -> HelpDocument: - """Project one parameter-help response into the shared document model.""" - sections = [] - if title: - sections.append(f"# {_markdown_heading(title)}") - if summary: - sections.append(f"**{_markdown_inline(summary)}**") - if description: - sections.append(description.strip()) - return cls( - content="\n\n".join(sections), - markup=HelpDocumentFormat.MARKDOWN, - title=title, - ) - - def rendered_html(self) -> str: - """Render reStructuredText safely for Qt's rich-text engine.""" - if self.markup is not HelpDocumentFormat.RESTRUCTURED_TEXT: - raise ValueError("rendered_html() is only valid for reStructuredText documents") - parts = publish_parts( - self.content, - writer_name="html5", - settings_overrides={ - "raw_enabled": False, - "file_insertion_enabled": False, - "report_level": 5, - "halt_level": 6, - "output_encoding": "unicode", - "embed_stylesheet": False, - }, - ) - return str(parts["html_body"]) - - def bounded( - self, - max_chars: int = DEFAULT_HELP_DOCUMENT_MAX_CHARS, - ) -> HelpDocument: - """Return this document with a renderer-safe content bound.""" - if max_chars < 1: - raise ValueError("max_chars must be at least 1") - if len(self.content) <= max_chars: - return self - if max_chars < 4: - content = f"{self.content[: max_chars - 1]}…" - else: - content = f"{self.content[: max_chars - 3].rstrip()}\n\n…" - return replace(self, content=content) - - def without_leading_heading(self, expected_title: str) -> HelpDocument: - """Remove a matching source heading when chrome already displays it.""" - lines = self.content.splitlines() - first_content_index = next( - (index for index, line in enumerate(lines) if line.strip()), - None, - ) - if first_content_index is None: - return self - normalized_expected = _normalized_heading(expected_title) - - if self.markup is HelpDocumentFormat.MARKDOWN: - match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", lines[first_content_index]) - if match is None or _normalized_heading(match.group(1)) != normalized_expected: - return self - del lines[first_content_index] - elif self.markup is HelpDocumentFormat.RESTRUCTURED_TEXT: - underline_index = first_content_index + 1 - if underline_index >= len(lines): - return self - title = lines[first_content_index].strip() - underline = lines[underline_index].strip() - if ( - _normalized_heading(title) != normalized_expected - or len(set(underline)) != 1 - or next(iter(set(underline)), "") not in "=-~^\"'`:+*#<>_" - or len(underline) < len(title) - ): - return self - del lines[first_content_index : underline_index + 1] - else: - return self - - while lines and not lines[0].strip(): - del lines[0] - return replace(self, content="\n".join(lines)) - - -def _markdown_heading(value: str) -> str: - return " ".join(value.split()).replace("#", r"\#") - - -def _markdown_inline(value: str) -> str: - normalized = " ".join(value.split()) - return re.sub(r"([\\`*_[\]<>])", r"\\\1", normalized) - - -def _markdown_code_span(value: str) -> str: - delimiter = "`" * max(1, _longest_character_run(value, "`") + 1) - padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else "" - return f"{delimiter}{padding}{value}{padding}{delimiter}" - - -def _code_fence(value: str) -> str: - return "`" * max(3, _longest_character_run(value, "`") + 1) - - -def _longest_character_run(value: str, target: str) -> int: - longest_run = 0 - current_run = 0 - for character in value: - if character == target: - current_run += 1 - longest_run = max(longest_run, current_run) - else: - current_run = 0 - return longest_run - - -def _normalized_heading(value: str) -> str: - return " ".join(value.split()).casefold() diff --git a/src/pyqt_reactive/services/interval_snapshot_poller.py b/src/pyqt_reactive/services/interval_snapshot_poller.py deleted file mode 100644 index c85d74f8..00000000 --- a/src/pyqt_reactive/services/interval_snapshot_poller.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Generic interval-based background snapshot poller.""" - -from __future__ import annotations - -import threading -import time -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Callable, Generic, TypeVar - - -TSnapshot = TypeVar("TSnapshot") - - -class IntervalSnapshotPollerPolicyABC(ABC, Generic[TSnapshot]): - """Policy boundary for background polling behavior.""" - - @abstractmethod - def fetch_snapshot(self) -> TSnapshot: - """Fetch latest snapshot from source.""" - - @abstractmethod - def clone_snapshot(self, snapshot: TSnapshot) -> TSnapshot: - """Return an isolated clone of one snapshot value.""" - - @property - def poll_interval_seconds(self) -> float: - return 1.0 - - def on_snapshot_changed(self, snapshot: TSnapshot) -> None: - pass - - def on_poll_error(self, error: Exception) -> None: - pass - - -@dataclass(frozen=True) -class CallbackIntervalSnapshotPollerPolicy( - IntervalSnapshotPollerPolicyABC[TSnapshot], Generic[TSnapshot] -): - """Callback-backed interval polling policy.""" - - fetch_snapshot_fn: Callable[[], TSnapshot] - clone_snapshot_fn: Callable[[TSnapshot], TSnapshot] - poll_interval_seconds_value: float = 1.0 - on_snapshot_changed_fn: Callable[[TSnapshot], None] | None = None - on_poll_error_fn: Callable[[Exception], None] | None = None - - def fetch_snapshot(self) -> TSnapshot: - return self.fetch_snapshot_fn() - - def clone_snapshot(self, snapshot: TSnapshot) -> TSnapshot: - return self.clone_snapshot_fn(snapshot) - - @property - def poll_interval_seconds(self) -> float: - return self.poll_interval_seconds_value - - def on_snapshot_changed(self, snapshot: TSnapshot) -> None: - if self.on_snapshot_changed_fn is not None: - self.on_snapshot_changed_fn(snapshot) - - def on_poll_error(self, error: Exception) -> None: - if self.on_poll_error_fn is not None: - self.on_poll_error_fn(error) - - -class IntervalSnapshotPoller(Generic[TSnapshot]): - """Runs at most one background poll at a configured interval.""" - - def __init__( - self, - policy: IntervalSnapshotPollerPolicyABC[TSnapshot], - ) -> None: - self._policy = policy - self._lock = threading.Lock() - self._snapshot: TSnapshot | None = None - self._inflight = False - self._inflight_generation: int | None = None - self._last_poll_ts = 0.0 - self._generation = 0 - - def tick(self) -> None: - """Schedule one background poll if interval and inflight gates allow it.""" - now = time.time() - with self._lock: - if self._inflight: - return - if now - self._last_poll_ts < self._policy.poll_interval_seconds: - return - self._inflight = True - self._last_poll_ts = now - generation = self._generation - self._inflight_generation = generation - - threading.Thread( - target=self._poll_worker, - args=(generation,), - daemon=True, - ).start() - - def get_snapshot_copy(self) -> TSnapshot | None: - """Read the current snapshot using policy clone semantics.""" - with self._lock: - if self._snapshot is None: - return None - return self._policy.clone_snapshot(self._snapshot) - - def is_poll_inflight(self) -> bool: - with self._lock: - return self._inflight - - def reset(self) -> None: - """Drop current snapshot and invalidate any in-flight worker result.""" - with self._lock: - self._snapshot = None - self._last_poll_ts = 0.0 - self._generation += 1 - - def _poll_worker(self, generation: int) -> None: - try: - snapshot = self._policy.fetch_snapshot() - changed = False - with self._lock: - if generation != self._generation: - return - changed = snapshot != self._snapshot - self._snapshot = snapshot - if changed: - self._policy.on_snapshot_changed(self._policy.clone_snapshot(snapshot)) - except Exception as error: - self._policy.on_poll_error(error) - finally: - with self._lock: - if self._inflight_generation == generation: - self._inflight = False - self._inflight_generation = None diff --git a/src/pyqt_reactive/services/manager_host_abc.py b/src/pyqt_reactive/services/manager_host_abc.py deleted file mode 100644 index 807e7b15..00000000 --- a/src/pyqt_reactive/services/manager_host_abc.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Nominal host contract for manager service integrations.""" -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class ManagerHostABC(ABC): - """Base callback contract for manager-hosted services.""" - - @abstractmethod - def update_item_list(self) -> None: - """Refresh visible list/tree content.""" - - @abstractmethod - def update_button_states(self) -> None: - """Refresh button enabled/disabled state.""" - - @abstractmethod - def emit_status(self, message: str) -> None: - """Emit status text for UI presentation.""" diff --git a/src/pyqt_reactive/services/parameter_help_service.py b/src/pyqt_reactive/services/parameter_help_service.py deleted file mode 100644 index 3f3973e6..00000000 --- a/src/pyqt_reactive/services/parameter_help_service.py +++ /dev/null @@ -1,462 +0,0 @@ -"""Qt-free parameter help introspection shared by forms, help windows, and agents.""" - -from __future__ import annotations - -import ast -import inspect -import logging -import re -from dataclasses import dataclass, fields, is_dataclass -from enum import Enum -from types import UnionType -from typing import Annotated, Callable, Optional, Union, get_args, get_origin - -from objectstate.lazy_factory import get_base_type_for_lazy, is_lazy_dataclass -from python_introspect import ( - DocstringExtractor, - DocstringInfo, - UnifiedParameterAnalyzer, - resolve_annotated, -) - -logger = logging.getLogger(__name__) - -NO_PARAMETER_DESCRIPTION = "No description available" - - -@dataclass(frozen=True, slots=True) -class ParameterHelpContent: - """Display-ready content for one parameter help popup.""" - - summary: str - description: str - - -@dataclass(frozen=True, slots=True) -class ParsedParameterDescription: - """Structured projection of the generated parameter documentation prefix.""" - - type_name: str | None - default_value: str | None - description: str - - -@dataclass(frozen=True, slots=True) -class ParameterDescriptionBody: - """Formatted body fields for one parsed parameter description.""" - - setting_name: str | None - description: str - - -class DataclassDocstringResolutionKind(Enum): - """Provenance for source-authored dataclass docstring resolution.""" - - FOUND = "found" - CLASS_NOT_FOUND = "class_not_found" - DOCSTRING_MISSING = "docstring_missing" - - -@dataclass(frozen=True, slots=True) -class DataclassDocstringResolution: - """Result of resolving a source-authored dataclass docstring.""" - - kind: DataclassDocstringResolutionKind - docstring: str | None - - -@dataclass(frozen=True, slots=True) -class ParameterDescriptionFormatter: - """Display formatter for compact parameter help prose.""" - - def strip_rst_directives(self, text: str) -> str: - """Remove inline RST directives that are not useful in a compact popup.""" - text = re.sub(r"\s*\.\. image:: \{[^}]+\}", "", text) - text = re.sub(r"\s*\.\. _\w+:\s+\S+", "", text) - return text.strip() - - def format_inline_list_markers(self, text: str) -> str: - """Give flattened CellProfiler list markers paragraph breaks.""" - text = re.sub(r"\s+-\s+(\{[^}]+\}:)", r"\n\n- \1", text) - text = re.sub(r"\s+References\s+-\s+", "\n\nReferences\n\n- ", text) - text = text.replace(" NOTE ", "\n\nNOTE ") - return text.strip() - - def format_body(self, text: str) -> str: - """Return display-ready body prose for parameter help.""" - return self.format_inline_list_markers(self.strip_rst_directives(text)) - -PARAMETER_DESCRIPTION_FORMATTER = ParameterDescriptionFormatter() - - -def dataclass_type_for_target(target: Union[Callable, type, None]) -> type | None: - """Return the dataclass type represented by a help target.""" - if target is None: - return None - if inspect.isclass(target) and is_dataclass(target): - return target - target_type = type(target) - if is_dataclass(target_type): - return target_type - return None - - -def source_dataclass_type(dataclass_type: type) -> type: - """Return the source-authored dataclass for generated lazy dataclass types.""" - if is_lazy_dataclass(dataclass_type): - base_type = get_base_type_for_lazy(dataclass_type) - if base_type is None: - raise RuntimeError( - f"Lazy dataclass {dataclass_type.__qualname__} is missing its " - "registered source dataclass type." - ) - if not is_dataclass(base_type): - raise RuntimeError( - f"Lazy dataclass {dataclass_type.__qualname__} resolves to " - f"non-dataclass source type {base_type!r}." - ) - return base_type - - return dataclass_type - - -def source_class_docstring_resolution(dataclass_type: type) -> DataclassDocstringResolution: - """Extract the authored class docstring instead of dataclass' rendered signature.""" - source_type = source_dataclass_type(dataclass_type) - try: - source = inspect.getsource(source_type) - except (OSError, TypeError): - return DataclassDocstringResolution( - kind=DataclassDocstringResolutionKind.CLASS_NOT_FOUND, - docstring=None, - ) - tree = ast.parse(source) - - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef) and node.name == source_type.__name__: - docstring = ast.get_docstring(node) - if docstring is None: - return DataclassDocstringResolution( - kind=DataclassDocstringResolutionKind.DOCSTRING_MISSING, - docstring=None, - ) - return DataclassDocstringResolution( - kind=DataclassDocstringResolutionKind.FOUND, - docstring=docstring, - ) - return DataclassDocstringResolution( - kind=DataclassDocstringResolutionKind.CLASS_NOT_FOUND, - docstring=None, - ) - - -def is_signature_docstring(text: str | None, type_name: str) -> bool: - """Return whether a dataclass docstring is just the generated constructor signature.""" - if text is None: - return False - return text.lstrip().startswith(f"{type_name}(") - - -def class_docstring_text(dataclass_type: type) -> str | None: - """Return a human-authored dataclass docstring when one is available.""" - source_type = source_dataclass_type(dataclass_type) - source_docstring = source_class_docstring_resolution(source_type) - if source_docstring.kind is DataclassDocstringResolutionKind.FOUND: - return source_docstring.docstring - logger.debug( - "Dataclass source docstring resolution for %s ended with %s.", - source_type.__qualname__, - source_docstring.kind.value, - ) - - inspected_docstring = inspect.getdoc(source_type) - if is_signature_docstring(inspected_docstring, source_type.__name__): - return None - return inspected_docstring - - -def split_docstring_summary(docstring: str | None, fallback: str) -> tuple[str, str | None]: - """Split a docstring into summary and optional remaining description.""" - if not docstring: - return fallback, None - lines = docstring.strip().splitlines() - if not lines: - summary = fallback - else: - summary = lines[0].strip() - description = "\n".join(line.rstrip() for line in lines[1:]).strip() - if description == "": - return summary, None - return summary, description - - -def dataclass_type_from_annotation(annotation) -> type | None: - """Return a dataclass type represented by a field annotation.""" - if inspect.isclass(annotation) and is_dataclass(annotation): - return annotation - - origin = get_origin(annotation) - if origin is Annotated: - annotated_args = get_args(annotation) - if not annotated_args: - return None - return dataclass_type_from_annotation(annotated_args[0]) - - if origin in (Union, UnionType): - dataclass_args = tuple( - arg for arg in get_args(annotation) - if inspect.isclass(arg) and is_dataclass(arg) - ) - if len(dataclass_args) == 1: - return dataclass_args[0] - return None - - -def dataclass_field_description(description: str | None, annotation) -> str | None: - """Return displayable docs for one dataclass field.""" - field_type = dataclass_type_from_annotation(annotation) - signature_type_names: tuple[str, ...] = () - if field_type is not None: - source_type = source_dataclass_type(field_type) - signature_type_names = tuple( - dict.fromkeys((field_type.__name__, source_type.__name__)) - ) - if description and ( - field_type is None - or not any( - is_signature_docstring(description, type_name) - for type_name in signature_type_names - ) - ): - return description - - if field_type is None: - return description - - class_docstring = class_docstring_text(field_type) - summary, description_body = split_docstring_summary( - class_docstring, - f"{source_dataclass_type(field_type).__name__} configuration fields.", - ) - if description_body: - return f"{summary}\n\n{description_body}" - return summary - - -def dataclass_parameter_descriptions(dataclass_type: type) -> dict[str, str]: - """Return field descriptions for a dataclass using the shared introspection path.""" - analyzed = UnifiedParameterAnalyzer.analyze(dataclass_type) - dataclass_fields = {field.name: field for field in fields(dataclass_type)} - descriptions: dict[str, str] = {} - for name, info in analyzed.items(): - if name in dataclass_fields: - annotation = dataclass_fields[name].type - else: - annotation = None - description = dataclass_field_description(info.description, annotation) - if description: - descriptions[name] = description - return descriptions - - -def docstring_info_for_target(target: Union[Callable, type]) -> DocstringInfo: - """Return help-window documentation, using field docs for dataclass targets.""" - dataclass_type = dataclass_type_for_target(target) - if dataclass_type is None: - docstring_info = DocstringExtractor.extract(target) - parameters = dict(docstring_info.parameters or {}) - for name, parameter_info in UnifiedParameterAnalyzer.analyze(target).items(): - if name not in parameters and parameter_info.description: - parameters[name] = parameter_info.description - return docstring_info._replace(parameters=parameters) - - source_type = source_dataclass_type(dataclass_type) - summary, description = split_docstring_summary( - class_docstring_text(dataclass_type), - source_type.__name__, - ) - return DocstringInfo( - summary=summary, - description=description, - parameters=dataclass_parameter_descriptions(source_type), - returns="", - examples="", - ) - - -def parameter_description_from_target( - help_target: Union[Callable, type, None], - param_name: str, -) -> Optional[str]: - """Return parsed documentation for one parameter from a callable/class target.""" - if help_target is None: - return None - - parameters = docstring_info_for_target(help_target).parameters or {} - return parameters.get(param_name) - - -def resolved_parameter_description( - *, - help_target: Union[Callable, type, None], - param_name: str, - widget_description: str, -) -> str: - """Resolve parameter help text from target docs, then explicit widget metadata.""" - target_description = parameter_description_from_target(help_target, param_name) - if target_description: - return target_description - if widget_description: - return widget_description - return NO_PARAMETER_DESCRIPTION - - -def parameter_type_display(param_type: type | None) -> str: - """Return the compact type label used by parameter help.""" - if param_type is None: - return "" - display_type = resolve_annotated(param_type) - if isinstance(display_type, type): - return f" ({display_type.__name__})" - return f" ({display_type})" - - -def split_default_prefix(text: str) -> tuple[str, str]: - """Split a rendered default literal from the following sentence body.""" - sentence_separator = ". " - separator_index = text.find(sentence_separator) - if separator_index >= 0: - return ( - text[:separator_index], - text[separator_index + len(sentence_separator) :], - ) - return text.rstrip("."), "" - - -def parse_parameter_description(description: str) -> ParsedParameterDescription: - """Parse the type/default prefix emitted by DocstringExtractor parameter docs.""" - if not description.startswith("'"): - unquoted_match = re.match( - r"^(?P[^.;]+);\s+default\s+(?P.*)$", - description, - ) - if unquoted_match is not None: - default_value, body = split_default_prefix( - unquoted_match.group("default_and_body"), - ) - return ParsedParameterDescription( - type_name=unquoted_match.group("type").strip(), - default_value=default_value.strip(), - description=body.strip(), - ) - return ParsedParameterDescription( - type_name=None, - default_value=None, - description=description, - ) - - closing_quote_index = description.find("'", 1) - if closing_quote_index < 0: - return ParsedParameterDescription( - type_name=None, - default_value=None, - description=description, - ) - - type_name = description[1:closing_quote_index] - remainder = description[closing_quote_index + 1 :].lstrip() - if remainder.startswith("."): - remainder = remainder[1:].lstrip() - - default_value = None - default_prefix = "; default " - if remainder.startswith(default_prefix): - default_value, remainder = split_default_prefix(remainder[len(default_prefix) :]) - - return ParsedParameterDescription( - type_name=type_name, - default_value=default_value, - description=remainder, - ) - - -def parameter_description_body(description: str) -> ParameterDescriptionBody: - """Split CellProfiler setting metadata from the prose body.""" - setting_prefix = "CellProfiler setting '" - if not description.startswith(setting_prefix): - return ParameterDescriptionBody( - setting_name=None, - description=PARAMETER_DESCRIPTION_FORMATTER.format_body(description), - ) - - setting_start = len(setting_prefix) - setting_end = description.find("'", setting_start) - if setting_end < 0: - return ParameterDescriptionBody( - setting_name=None, - description=PARAMETER_DESCRIPTION_FORMATTER.format_body(description), - ) - - setting_name = description[setting_start:setting_end] - remainder = description[setting_end + 1 :].lstrip() - if remainder.startswith("."): - remainder = remainder[1:].lstrip() - return ParameterDescriptionBody( - setting_name=setting_name, - description=PARAMETER_DESCRIPTION_FORMATTER.format_body(remainder), - ) - - -def _default_values_match(left: str, right: str) -> bool: - """Return whether two rendered default literals describe the same value.""" - if left == right: - return True - numeric_pattern = r"[-+]?\d+(?:\.\d+)?" - if re.fullmatch(numeric_pattern, left) and re.fullmatch(numeric_pattern, right): - return float(left) == float(right) - return False - - -def remove_duplicate_default_sentence(description: str, default_value: str | None) -> str: - """Remove body-level default sentence already shown in the default section.""" - if default_value is None: - return description - - def replacement(match: re.Match[str]) -> str: - rendered_default = match.group("value").strip() - if _default_values_match(default_value, rendered_default): - return "" - return match.group(0) - - return re.sub( - r"(?:\s+|^)Default is (?P[-+]?\d+(?:\.\d+)?|[^.]+)\.", - replacement, - description, - ).strip() - - -def parameter_help_content( - *, - param_name: str, - param_type: type | None, - description: str, -) -> ParameterHelpContent: - """Build compact popup content without leaking raw Python annotations.""" - parsed = parse_parameter_description(description) - type_str = f" ({parsed.type_name})" if parsed.type_name else parameter_type_display(param_type) - body = parameter_description_body(parsed.description) - lines: list[str] = [] - if parsed.default_value: - lines.append(f"Default: {parsed.default_value}") - if body.setting_name: - lines.append(f"CellProfiler setting: {body.setting_name}") - body_description = remove_duplicate_default_sentence( - body.description, - parsed.default_value, - ) - if body_description: - lines.append(body_description) - return ParameterHelpContent( - summary=f"• {param_name}{type_str}", - description="\n\n".join(lines) if lines else NO_PARAMETER_DESCRIPTION, - ) diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index da19cef9..367e3595 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -17,11 +17,11 @@ import logging from functools import wraps -from .parameter_service_abc import ParameterServiceABC +from formbridge.services.parameter_service_abc import ParameterServiceABC # Optional performance monitoring - stub if not available try: - from pyqt_reactive.core.performance_monitor import timer, get_monitor + from formbridge.core.performance_monitor import timer, get_monitor except ImportError: def timer(name): """No-op timer decorator when performance_monitor not available.""" @@ -37,7 +37,7 @@ def get_monitor(): return None if TYPE_CHECKING: - from pyqt_reactive.forms.parameter_info_types import ( + from formbridge.forms.parameter_info_types import ( OptionalDataclassInfo, DirectDataclassInfo, GenericInfo, diff --git a/src/pyqt_reactive/services/parameter_service_abc.py b/src/pyqt_reactive/services/parameter_service_abc.py deleted file mode 100644 index 61f90513..00000000 --- a/src/pyqt_reactive/services/parameter_service_abc.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Abstract base class for parameter services with auto-discovery dispatch. - -This module provides a unified pattern for all services that operate on parameters -based on their ParameterInfo type. It eliminates code duplication and enforces -consistent architecture across all parameter services. - -Key features: -1. Auto-discovery of handler methods based on naming convention -2. Type-safe dispatch using ParameterInfo discriminated unions -3. Zero boilerplate - just define handler methods -4. Consistent pattern across all services - -Pattern: - Instead of: - class MyService: - def process(self, info): - if isinstance(info, OptionalDataclassInfo): - # handle optional dataclass - elif isinstance(info, DirectDataclassInfo): - # handle direct dataclass - else: - # handle generic - - Use: - class MyService(ParameterServiceABC): - def _get_handler_prefix(self) -> str: - return '_process_' - - def _process_OptionalDataclassInfo(self, info, ...): - # Type checker knows info is OptionalDataclassInfo! - ... - - def _process_DirectDataclassInfo(self, info, ...): - # Type checker knows info is DirectDataclassInfo! - ... - - def _process_GenericInfo(self, info, ...): - # Type checker knows info is GenericInfo! - ... - -Services using this pattern: -- ParameterResetService: _reset_OptionalDataclassInfo, etc. -- NestedValueCollectionService: _collect_OptionalDataclassInfo, etc. -- Future services: just inherit and define handlers - -Architecture benefits: -- Single source of truth for dispatch logic -- No if-elif-else chains -- No manual registry maintenance -- Type-safe (type checker narrows in each handler) -- Adding new ParameterInfo type = add handler method to all services -""" - -from typing import Dict, Callable, Any -from abc import ABC, abstractmethod -import logging - -from pyqt_reactive.forms.parameter_info_types import ParameterInfo - -logger = logging.getLogger(__name__) - - -class ParameterServiceABC(ABC): - """ - Abstract base for parameter services with auto-discovery dispatch. - - Subclasses must: - 1. Implement _get_handler_prefix() to return method prefix (e.g., '_reset_') - 2. Define handler methods following naming convention: {prefix}{ClassName} - - The ABC automatically discovers all handler methods and provides - type-safe dispatch via the dispatch() method. - - Examples: - class ResetService(ParameterServiceABC): - def _get_handler_prefix(self) -> str: - return '_reset_' - - def reset_parameter(self, manager, param_name: str): - info = manager.form_structure.get_parameter_info(param_name) - self.dispatch(info, manager) - - def _reset_OptionalDataclassInfo(self, info: OptionalDataclassInfo, manager): - # Handler for Optional[Dataclass] parameters - ... - - def _reset_DirectDataclassInfo(self, info: DirectDataclassInfo, manager): - # Handler for direct Dataclass parameters - ... - - def _reset_GenericInfo(self, info: GenericInfo, manager): - # Handler for generic parameters - ... - """ - - def __init__(self): - """ - Initialize service and auto-discover handler methods. - - Discovers all methods matching the pattern: {prefix}{ClassName} - where prefix is returned by _get_handler_prefix(). - """ - self._handlers: Dict[str, Callable] = {} - prefix = self._get_handler_prefix() - - # Auto-discover handlers by introspecting methods - for attr_name in dir(self): - if attr_name.startswith(prefix): - # Extract class name from method name - # e.g., '_reset_OptionalDataclassInfo' -> 'OptionalDataclassInfo' - class_name = attr_name.replace(prefix, '') - handler = getattr(self, attr_name) - - # Verify it's callable - if callable(handler): - self._handlers[class_name] = handler - - # Log discovered handlers for debugging - if self._handlers: - logger.debug( - f"{self.__class__.__name__} auto-discovered handlers: " - f"{list(self._handlers.keys())}" - ) - else: - logger.warning( - f"{self.__class__.__name__} found no handlers with prefix '{prefix}'. " - f"Did you forget to define handler methods?" - ) - - @abstractmethod - def _get_handler_prefix(self) -> str: - """ - Return the method prefix for this service's handlers. - - Examples: - - ParameterResetService: '_reset_' - - NestedValueCollectionService: '_collect_' - - WidgetUpdateService: '_update_' - - Returns: - Method prefix string (must include leading underscore) - """ - pass - - def dispatch(self, info: ParameterInfo, *args, **kwargs) -> Any: - """ - Auto-dispatch to handler based on ParameterInfo class name. - - This method provides type-safe dispatch without if-elif-else chains. - The type checker can narrow the type in each handler method. - - Args: - info: ParameterInfo instance (discriminated union) - *args: Additional positional arguments passed to handler - **kwargs: Additional keyword arguments passed to handler - - Returns: - Result from handler method - - Raises: - ValueError: If no handler found for ParameterInfo type - - Examples: - >>> service = ResetService() - >>> info = OptionalDataclassInfo(...) - >>> service.dispatch(info, manager) # Calls _reset_OptionalDataclassInfo - """ - class_name = info.__class__.__name__ - handler = self._handlers.get(class_name) - - if handler is None: - raise ValueError( - f"No handler for {class_name} in {self.__class__.__name__}. " - f"Available handlers: {list(self._handlers.keys())}. " - f"Did you forget to define {self._get_handler_prefix()}{class_name}()?" - ) - - # Call handler with info as first argument, followed by additional args - return handler(info, *args, **kwargs) - - def has_handler(self, info: ParameterInfo) -> bool: - """ - Check if a handler exists for the given ParameterInfo type. - - Useful for conditional logic or validation. - - Args: - info: ParameterInfo instance to check - - Returns: - True if handler exists, False otherwise - """ - class_name = info.__class__.__name__ - return class_name in self._handlers - - def get_supported_types(self) -> list[str]: - """ - Get list of supported ParameterInfo type names. - - Useful for debugging and validation. - - Returns: - List of class names that have handlers - """ - return list(self._handlers.keys()) - diff --git a/src/pyqt_reactive/services/pattern_data_manager.py b/src/pyqt_reactive/services/pattern_data_manager.py deleted file mode 100644 index 05c7a2af..00000000 --- a/src/pyqt_reactive/services/pattern_data_manager.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -Pattern Data Manager - Pure data operations for function patterns. - -This service handles pattern data structure operations and transformations -with order determinism and immutable operations. - -Framework-agnostic - can be used by any UI framework (PyQt, Textual, etc.). -""" - -import copy -from collections.abc import Callable, Mapping -from typing import NewType - -from objectstate.object_state_metadata import ( - ObjectStateMetadataContract, - ObjectStateMetadataContractRegistry, -) - -PatternKey = NewType("PatternKey", str) - - -# Internal metadata key stored in kwargs for per-entry identity. -# This is used by the PyQt/ObjectState integration to create stable, per-occurrence -# scopes for duplicate functions in patterns. - -# Namespaced metadata keys for ObjectState.metadata -FUNC_EDITOR_SELECTED_PATTERN_KEY_META_KEY = "pyqt_reactive.func_editor.selected_pattern_key" -FUNC_EDITOR_PATTERN_TOKENS_META_KEY = "pyqt_reactive.func_editor.pattern_tokens" - -ObjectStateMetadataContractRegistry.register( - ObjectStateMetadataContract( - key=FUNC_EDITOR_SELECTED_PATTERN_KEY_META_KEY, - owner="pyqt_reactive.function_editor", - description="Currently selected dict-pattern key for function editor time travel.", - ) -) -ObjectStateMetadataContractRegistry.register( - ObjectStateMetadataContract( - key=FUNC_EDITOR_PATTERN_TOKENS_META_KEY, - owner="pyqt_reactive.function_editor", - description="Stable child ObjectState scope tokens for function pattern entries.", - ) -) - - -class PatternDataManager: - """ - Pure data operations for function patterns. - - Handles List↔Dict conversions, cloning, and data transformations - with order determinism and immutable operations. - """ - - @staticmethod - def clone_pattern(pattern: list | dict) -> list | dict: - """ - Deep clone preserving callable references exactly. - - Args: - pattern: Pattern to clone (List or Dict) - - Returns: - Deep cloned pattern with preserved callable references - """ - if pattern is None: - return [] - return copy.deepcopy(pattern) - - @staticmethod - def convert_list_to_dict(pattern: list) -> dict: - """ - Convert List pattern to empty Dict - user must add component keys manually. - - Args: - pattern: List pattern to convert (will be discarded) - - Returns: - Empty dict for user to populate with experimental component identifiers - """ - if not isinstance(pattern, list): - raise ValueError(f"Expected list, got {type(pattern)}") - - # Return empty dict - user will add experimental component keys manually - return {} - - @staticmethod - def convert_dict_to_list(pattern: dict) -> list | dict: - """ - Convert Dict pattern to List when empty. - - Args: - pattern: Dict pattern to potentially convert - - Returns: - Empty list if dict is empty, otherwise returns original dict - """ - if not isinstance(pattern, dict): - raise ValueError(f"Expected dict, got {type(pattern)}") - - # Convert to empty list if dict is empty - if not pattern: - return [] - - # Keep as dict if it has keys - return pattern - - @staticmethod - def extract_func_and_kwargs(func_item) -> tuple[Callable, dict]: - """Return ``(callable, kwargs)`` for a ``(callable, kwargs)`` tuple or a bare callable. - - Raises: - TypeError: for any other item, so a malformed entry is never dropped silently. - """ - if isinstance(func_item, tuple) and len(func_item) == 2 and callable(func_item[0]): - func, kwargs = func_item - if not isinstance(kwargs, Mapping): - raise TypeError("Function-pattern tuple entries must carry a kwargs mapping.") - return func, dict(kwargs) - if callable(func_item): - return func_item, {} - raise TypeError( - "Function-pattern entries are callables or (callable, kwargs) pairs, " - f"got {func_item!r}." - ) - - @staticmethod - def validate_pattern_structure(pattern: list | dict) -> bool: - """ - Basic structural validation of pattern. - - Args: - pattern: Pattern to validate - - Returns: - True if structure is valid, False otherwise - """ - if pattern is None: - return True - - if isinstance(pattern, list): - # Validate list items are callables or (callable, dict) tuples - for item in pattern: - try: - PatternDataManager.extract_func_and_kwargs(item) - except TypeError: - return False - return True - - elif isinstance(pattern, dict): - # Validate dict values are lists of callables - for key, value in pattern.items(): - if not isinstance(value, list): - return False - # Recursively validate the list - if not PatternDataManager.validate_pattern_structure(value): - return False - return True - - else: - return False - - @staticmethod - def get_current_functions(pattern: list | dict, key: PatternKey, is_dict: bool) -> list: - """ - Extract function list for current context. - - Args: - pattern: Full pattern (List or Dict) - key: Current key (for Dict patterns) - is_dict: Whether pattern is currently in dict mode - - Returns: - List of functions for current context - """ - if is_dict and isinstance(pattern, dict): - if key in pattern: - return pattern[key] - return [] - elif not is_dict and isinstance(pattern, list): - return pattern - else: - return [] - - @staticmethod - def update_pattern_functions( - pattern: list | dict, key: PatternKey, is_dict: bool, new_functions: list - ) -> list | dict: - """ - Update functions in pattern for current context. - - Returns new pattern object (immutable operation). - - Args: - pattern: Original pattern - key: Current key (for Dict patterns) - is_dict: Whether pattern is in dict mode - new_functions: New function list - - Returns: - New pattern with updated functions - """ - if is_dict and isinstance(pattern, dict): - new_pattern = copy.deepcopy(pattern) - new_pattern[key] = new_functions - return new_pattern - elif not is_dict and isinstance(pattern, list): - return copy.deepcopy(new_functions) - else: - # Fallback - return original pattern - return copy.deepcopy(pattern) - - @staticmethod - def add_new_key(pattern: dict, new_key: str) -> dict: - """ - Add new key to dict pattern. - - Args: - pattern: Dict pattern - new_key: Key to add - - Returns: - New dict with added key - """ - new_pattern = copy.deepcopy(pattern) - if new_key not in new_pattern: - new_pattern[new_key] = [] - return new_pattern - - @staticmethod - def remove_key(pattern: dict, key_to_remove: PatternKey) -> list | dict: - """ - Remove key from dict pattern. - - If dict becomes empty after removal, converts back to list. - - Args: - pattern: Dict pattern - key_to_remove: Key to remove - - Returns: - New pattern (List if dict becomes empty, Dict otherwise) - """ - new_pattern = copy.deepcopy(pattern) - if key_to_remove in new_pattern: - del new_pattern[key_to_remove] - - # Check if should convert back to list (when empty) - return PatternDataManager.convert_dict_to_list(new_pattern) diff --git a/src/pyqt_reactive/services/persistent_system_monitor.py b/src/pyqt_reactive/services/persistent_system_monitor.py index 69a8db16..f8b4c397 100644 --- a/src/pyqt_reactive/services/persistent_system_monitor.py +++ b/src/pyqt_reactive/services/persistent_system_monitor.py @@ -10,14 +10,8 @@ from PyQt6.QtCore import QThread, pyqtSignal, QMutex, QMutexLocker, Qt -from pyqt_reactive.services.system_metrics_sampler import ( - SystemMetrics, - SystemMetricsSampler, -) -from pyqt_reactive.services.system_monitor_core import ( - SystemMetricsHistory, - SystemMonitorCore, -) +from formbridge.services.system_metrics_sampler import SystemMetrics, SystemMetricsSampler +from formbridge.services.system_monitor_core import SystemMetricsHistory, SystemMonitorCore logger = logging.getLogger(__name__) diff --git a/src/pyqt_reactive/services/process_tracker.py b/src/pyqt_reactive/services/process_tracker.py deleted file mode 100644 index 553c1c8b..00000000 --- a/src/pyqt_reactive/services/process_tracker.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -Process Tracker Utility - -Tracks which processes are currently alive for log file status indication. -Used by log viewer to distinguish logs from running vs terminated processes. -""" - -import logging -import re -from dataclasses import dataclass -from enum import Enum -from importlib.util import find_spec -from pathlib import Path -from typing import Iterable, Optional, Set - -from pyqt_reactive.core.log_utils import LogFileInfo - -logger = logging.getLogger(__name__) - - -class ProcessLiveness(Enum): - """PID-reuse-safe liveness and its log-view presentation.""" - - RUNNING = ("Running", "🟢") - TERMINATED = ("Terminated", "⚫") - UNKNOWN = ("Unknown", "❓") - - def __init__(self, label: str, icon: str) -> None: - self.label = label - self.icon = icon - - -@dataclass -class ProcessInfo: - """Information about a tracked process.""" - pid: int - name: str - status: str - create_time: float - - -class ProcessTracker: - """ - Tracks which processes are currently alive. - - Used to determine if log files correspond to running or terminated processes. - """ - - def __init__(self): - """Initialize process tracker.""" - self.alive_pids: Set[int] = set() - self.process_info: dict[int, ProcessInfo] = {} - self._psutil_available = find_spec("psutil") is not None - if self._psutil_available: - logger.debug("ProcessTracker initialized with psutil support") - else: - logger.warning("psutil not available - process tracking disabled") - - def update(self, target_pids: Iterable[int] | None = None) -> bool: - """Update tracked process state. - - Args: - target_pids: Optional PID set to check directly. When provided, - avoids scanning every process on the machine. - - Returns: - True when tracked alive/dead state changed. - """ - if not self._psutil_available: - return False - - try: - import psutil - - old_alive_pids = self.alive_pids - target_pid_set = set(target_pids) if target_pids is not None else None - - new_alive_pids = set() - new_process_info = {} - - if target_pid_set is None: - process_iter = psutil.process_iter(['pid', 'name', 'status', 'create_time']) - for proc in process_iter: - try: - pid = proc.info['pid'] - new_alive_pids.add(pid) - new_process_info[pid] = ProcessInfo( - pid=pid, - name=proc.info.get('name', 'unknown'), - status=proc.info.get('status', 'unknown'), - create_time=proc.info.get('create_time', 0), - ) - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - else: - for pid in target_pid_set: - try: - proc = psutil.Process(pid) - new_alive_pids.add(pid) - new_process_info[pid] = ProcessInfo( - pid=pid, - name=proc.name(), - status=proc.status(), - create_time=proc.create_time(), - ) - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - pass - - self.alive_pids = new_alive_pids - self.process_info = new_process_info - changed = old_alive_pids != new_alive_pids - - logger.debug(f"Updated process tracker: {len(self.alive_pids)} alive processes") - return changed - - except Exception as e: - logger.warning(f"Failed to update process tracker: {e}") - return False - - def is_alive(self, pid: Optional[int]) -> bool: - """ - Check if a PID is currently alive. - - Args: - pid: Process ID to check (None returns False) - - Returns: - bool: True if process is alive, False otherwise - """ - if pid is None or not self._psutil_available: - return False - return pid in self.alive_pids - - def log_liveness(self, log_info: LogFileInfo) -> ProcessLiveness: - """Resolve liveness from declared identity, falling back to file PID.""" - - if log_info.process_identity is not None: - return ( - ProcessLiveness.RUNNING - if log_info.process_identity.is_alive() - else ProcessLiveness.TERMINATED - ) - pid = extract_pid_from_log_filename(log_info.path) - if pid is None or not self._psutil_available: - return ProcessLiveness.UNKNOWN - if not self.is_alive(pid): - return ProcessLiveness.TERMINATED - # A live PID alone cannot prove that an old log belongs to the current - # process occupying that PID. Only a declared ProcessIdentity can. - return ProcessLiveness.UNKNOWN - - def get_process_info(self, pid: int) -> Optional[ProcessInfo]: - """ - Get information about a process. - - Args: - pid: Process ID - - Returns: - ProcessInfo if process is alive, None otherwise - """ - return self.process_info.get(pid) - - def get_status_icon(self, pid: Optional[int]) -> str: - """ - Get status icon for a process. - - Args: - pid: Process ID (None returns unknown icon) - - Returns: - str: Status icon (🟢 alive, ⚫ dead, ❓ unknown) - """ - return self.pid_liveness(pid).icon - - def get_status_text(self, pid: Optional[int]) -> str: - """ - Get human-readable status text for a process. - - Args: - pid: Process ID (None returns "Unknown") - - Returns: - str: Status text ("Running", "Terminated", "Unknown") - """ - return self.pid_liveness(pid).label - - def pid_liveness(self, pid: Optional[int]) -> ProcessLiveness: - """Resolve best-effort liveness when only a PID is available.""" - - if pid is None or not self._psutil_available: - return ProcessLiveness.UNKNOWN - if self.is_alive(pid): - return ProcessLiveness.RUNNING - return ProcessLiveness.TERMINATED - - -def extract_pid_from_log_filename(log_path: Path) -> Optional[int]: - """ - Extract PID from application log filename. - - Supports various log filename patterns: - - app_unified_20251007_102845_worker_12345.log - - zmq_worker_exec_abc123_worker_12345.log - - app_worker_12345.log - - Args: - log_path: Path to log file - - Returns: - int: Extracted PID, or None if no PID found - """ - # Pattern to match worker PID in filename - match = re.search(r'worker_(\d+)', log_path.name) - if match: - return int(match.group(1)) - - # Pattern to match PID in other formats (e.g., process_12345.log) - match = re.search(r'process_(\d+)', log_path.name) - if match: - return int(match.group(1)) - - # Pattern to match PID at end of filename (e.g., server_12345.log) - match = re.search(r'_(\d+)\.log$', log_path.name) - if match: - pid_candidate = int(match.group(1)) - # Only return if it looks like a PID (not a timestamp) - if pid_candidate < 1000000: # PIDs are typically < 1M - return pid_candidate - - return None - - -def get_log_display_name( - log_info: LogFileInfo, - process_tracker: ProcessTracker, -) -> str: - """ - Get display name for log file with process status indicator. - - Args: - log_info: Classified log and optional authoritative process identity - process_tracker: ProcessTracker instance - - Returns: - str: Display name with status icon (e.g., "🟢 worker_12345.log") - """ - liveness = process_tracker.log_liveness(log_info) - return f"{liveness.icon} {log_info.display_name or log_info.path.name}" - - -def get_log_tooltip( - log_info: LogFileInfo, - process_tracker: ProcessTracker, -) -> str: - """ - Get tooltip text for log file with process information. - - Args: - log_info: Classified log and optional authoritative process identity - process_tracker: ProcessTracker instance - - Returns: - str: Tooltip text with process status and info - """ - identity = log_info.process_identity - pid = ( - identity.pid - if identity is not None - else extract_pid_from_log_filename(log_info.path) - ) - - if pid is None: - return f"Log file: {log_info.path.name}\nProcess: Unknown" - - liveness = process_tracker.log_liveness(log_info) - tooltip = ( - f"Log file: {log_info.path.name}\nPID: {pid}\nStatus: {liveness.label}" - ) - - # Add additional process info if available - proc_info = process_tracker.get_process_info(pid) - if proc_info: - tooltip += f"\nProcess: {proc_info.name}" - - return tooltip diff --git a/src/pyqt_reactive/services/scope_color_service.py b/src/pyqt_reactive/services/scope_color_service.py index a3ee022d..ec9189c1 100644 --- a/src/pyqt_reactive/services/scope_color_service.py +++ b/src/pyqt_reactive/services/scope_color_service.py @@ -4,7 +4,7 @@ from PyQt6.QtCore import QObject, pyqtSignal import logging -from pyqt_reactive.widgets.shared.scope_color_strategy import ( +from formbridge.theming.scope_color_strategy import ( ScopeColorStrategy, ManualColorStrategy, ColorStrategyType, diff --git a/src/pyqt_reactive/services/scope_token_service.py b/src/pyqt_reactive/services/scope_token_service.py deleted file mode 100644 index 0b8e6561..00000000 --- a/src/pyqt_reactive/services/scope_token_service.py +++ /dev/null @@ -1,455 +0,0 @@ -""" -Helpers for generating stable scope tokens for ObjectState hierarchy nodes. - -Used to avoid cross-window collisions when multiple child editors share the -same scope prefix (e.g., steps, nested functions). -""" - -from __future__ import annotations - -import logging -from abc import ABC -from collections.abc import Callable, Iterable, Sequence -from typing import Optional, TypeVar - -from objectstate import semantic_values_equal -from objectstate import ScopeKey - -logger = logging.getLogger(__name__) - -Occurrence = TypeVar("Occurrence") - - -def reconcile_occurrence_tokens( - previous_occurrences: Sequence[Occurrence], - previous_tokens: Sequence[str], - next_occurrences: Sequence[Occurrence], - *, - same_declaration: Callable[[Occurrence, Occurrence], bool], - occurrence_authorities: Callable[[Occurrence], Sequence[object]], - token_factory: Callable[[], str], - requested_tokens: Sequence[str | None] | None = None, - claimed_tokens: set[str] | None = None, -) -> list[str]: - """Reconcile occurrence identity without treating list position as authority. - - Existing explicit tokens are authoritative. Tokenless declarations first match - exact prior declarations, then match a changed declaration only when the - authority relation is unambiguous in both directions. Every remaining - declaration receives a fresh token. - """ - - requested = requested_tokens or () - claimed = claimed_tokens if claimed_tokens is not None else set() - available = { - index - for index, token in enumerate(previous_tokens[: len(previous_occurrences)]) - if token and token not in claimed - } - token_to_previous = { - str(previous_tokens[index]): index - for index in available - } - previous_token_set = { - str(token) for token in previous_tokens[: len(previous_occurrences)] if token - } - resolved: list[str | None] = [None] * len(next_occurrences) - - for next_index, requested_token in enumerate(requested): - if next_index >= len(resolved) or not requested_token: - continue - token = str(requested_token) - if token in claimed: - continue - previous_index = token_to_previous.get(token) - if previous_index in available: - available.remove(previous_index) - elif token in previous_token_set: - continue - resolved[next_index] = token - claimed.add(token) - - for next_index, next_occurrence in enumerate(next_occurrences): - if resolved[next_index] is not None: - continue - previous_index = next( - ( - candidate - for candidate in sorted(available) - if same_declaration( - previous_occurrences[candidate], - next_occurrence, - ) - ), - None, - ) - if previous_index is None: - continue - token = str(previous_tokens[previous_index]) - resolved[next_index] = token - available.remove(previous_index) - claimed.add(token) - - unresolved_next = { - index for index, token in enumerate(resolved) if token is None - } - previous_authorities = [ - tuple(occurrence_authorities(value)) for value in previous_occurrences - ] - next_authorities = [ - tuple(occurrence_authorities(value)) for value in next_occurrences - ] - authority_level_count = max( - (len(authorities) for authorities in previous_authorities + next_authorities), - default=0, - ) - for authority_level in range(authority_level_count): - unresolved_next = { - index for index, token in enumerate(resolved) if token is None - } - authority_candidates = { - next_index: { - previous_index - for previous_index in available - if authority_level < len(previous_authorities[previous_index]) - and authority_level < len(next_authorities[next_index]) - and semantic_values_equal( - previous_authorities[previous_index][authority_level], - next_authorities[next_index][authority_level], - ) - } - for next_index in unresolved_next - } - for next_index in sorted(unresolved_next): - candidates = authority_candidates[next_index] & available - if len(candidates) != 1: - continue - previous_index = next(iter(candidates)) - competing_next = { - candidate - for candidate in unresolved_next - if resolved[candidate] is None - and previous_index in authority_candidates[candidate] - } - if len(competing_next) != 1: - continue - token = str(previous_tokens[previous_index]) - resolved[next_index] = token - available.remove(previous_index) - claimed.add(token) - - for index, token in enumerate(resolved): - if token is not None: - continue - fresh_token = token_factory() - while fresh_token in claimed: - fresh_token = token_factory() - resolved[index] = fresh_token - claimed.add(fresh_token) - - return [str(token) for token in resolved] - - -class ScopeTokenTarget(ABC): - """Nominal role for objects that can participate in scope-token identity.""" - - -class ScopeTokenObjectStore: - """Persist scope tokens without exposing attribute probing to callers.""" - - @classmethod - def read(cls, obj: ScopeTokenTarget, attr_name: str) -> Optional[str]: - attributes = cls._instance_attributes(obj) - if attributes is not None and attr_name in attributes: - token = attributes[attr_name] - if isinstance(token, str) and token: - return token - return None - - @classmethod - def write(cls, obj: ScopeTokenTarget, attr_name: str, token: str) -> None: - attributes = cls._instance_attributes(obj) - attributes[attr_name] = token - - @staticmethod - def _instance_attributes(obj: ScopeTokenTarget): - try: - return vars(obj) - except TypeError as exc: - raise TypeError( - "Scope-token targets must expose an instance attribute mapping." - ) from exc - - -class ScopeTokenGenerator: - """Generate unique, human-readable scope tokens with an optional attribute store. - - - If attr_name is provided and the target object exposes an instance mapping, - tokens are persisted there (e.g., FunctionStep._scope_token). - - Tracks seen tokens to avoid collisions when objects already carry tokens - (e.g., after deserialization). - """ - - def __init__(self, prefix: str, attr_name: Optional[str] = None): - self.prefix = prefix - self.attr_name = attr_name - self._counter: int = 0 - self._used_tokens: set[str] = set() - - # ---------- Seeding ---------- - def seed_from_tokens(self, tokens: Iterable[str] | None) -> None: - """Prime the generator with existing tokens (keeps counter ahead of them).""" - if tokens is None: - return - for token in tokens: - if not token: - continue - self._register_existing(token) - - def seed_from_objects( - self, - objects: Iterable[ScopeTokenTarget] | None, - ) -> None: - """Seed from objects that may already carry tokens on attr_name.""" - if not self.attr_name: - return - if objects is None: - return - for obj in objects: - token = self._get_existing(obj) - if token: - self._register_existing(token) - - # ---------- Public API ---------- - def ensure(self, obj: Optional[ScopeTokenTarget] = None) -> str: - """Return an existing token on obj or generate a new one.""" - existing = self._get_existing(obj) - if existing: - self._register_existing(existing) - return existing - - token = self._generate_new() - self._attach(obj, token) - return token - - def transfer(self, source: ScopeTokenTarget, target: ScopeTokenTarget) -> str: - """Copy source token to target (or generate a new one for target).""" - token = self._get_existing(source) - if not token: - token = self.ensure(target) - return token - - self._register_existing(token) - self._attach(target, token) - return token - - def normalize(self, objects: Iterable[ScopeTokenTarget] | None) -> None: - """Ensure every object in a list has a token.""" - if objects is None: - return - for obj in objects: - self.ensure(obj) - - # ---------- Internals ---------- - def _get_existing(self, obj: Optional[ScopeTokenTarget]) -> Optional[str]: - if obj is None or not self.attr_name: - return None - token = self.existing_token(obj, self.attr_name) - return token - - @staticmethod - def existing_token(obj: ScopeTokenTarget, attr_name: str) -> Optional[str]: - """Return an existing token from the configured storage attribute.""" - return ScopeTokenObjectStore.read(obj, attr_name) - - def _attach(self, obj: Optional[ScopeTokenTarget], token: str) -> None: - if obj is None or not self.attr_name: - return - ScopeTokenObjectStore.write(obj, self.attr_name, token) - - def _register_existing(self, token: str) -> None: - if token in self._used_tokens: - return - self._used_tokens.add(token) - self._bump_counter(token) - - def _bump_counter(self, token: str) -> None: - prefix = f"{self.prefix}_" - if token.startswith(prefix): - suffix = token[len(prefix) :] - if suffix.isdigit(): - self._counter = max(self._counter, int(suffix) + 1) - - def _generate_new(self) -> str: - token = f"{self.prefix}_{self._counter}" - while token in self._used_tokens: - self._counter += 1 - token = f"{self.prefix}_{self._counter}" - self._used_tokens.add(token) - self._counter += 1 - return token - - -class ScopeTokenService: - """Registry of ScopeTokenGenerators keyed by (parent_scope, prefix). - - Token assigned on creation, stable across reordering. - Prefix derived from object type for readability. - - Usage: - ScopeTokenService.build_scope_id(plate_path, step) # → "plate::step_0" - ScopeTokenService.build_scope_id(step_scope, func) # → "plate::step_0::func_0" - """ - SCOPE_TOKEN_ATTRIBUTE = "_scope_token" - _generators: dict[tuple[str, str], ScopeTokenGenerator] = {} - - @classmethod - def _get_prefix(cls, obj: ScopeTokenTarget) -> str: - """Derive prefix from object type (lowercase).""" - return type(obj).__name__.lower() - - @classmethod - def _normalize_scope(cls, scope) -> str: - """Normalize scope to string. Enforces the invariant: scope keys are always strings.""" - if scope is None: - return "" - return str(scope) - - @classmethod - def get_generator(cls, parent_scope, prefix: str) -> ScopeTokenGenerator: - parent_scope = cls._normalize_scope(parent_scope) - key = (parent_scope, prefix) - if key not in cls._generators: - cls._generators[key] = ScopeTokenGenerator(prefix, cls.SCOPE_TOKEN_ATTRIBUTE) - logger.debug(f"🔑 ScopeTokenService: Created generator for parent_scope={parent_scope}, prefix={prefix}") - return cls._generators[key] - - @classmethod - def ensure_token(cls, parent_scope, obj: ScopeTokenTarget) -> str: - parent_scope = cls._normalize_scope(parent_scope) - prefix = cls._get_prefix(obj) - return cls.get_generator(parent_scope, prefix).ensure(obj) - - @classmethod - def transfer_token( - cls, - parent_scope, - source: ScopeTokenTarget, - target: ScopeTokenTarget, - ) -> str: - """Transfer source object's scope token to a replacement target.""" - parent_scope = cls._normalize_scope(parent_scope) - prefix = cls._get_prefix(source) - token = cls.get_generator(parent_scope, prefix).transfer(source, target) - cls._scope_id_cache.pop((parent_scope, id(target)), None) - return token - - @classmethod - def adopt_token( - cls, - parent_scope, - obj: ScopeTokenTarget, - token: str, - ) -> str: - """Assign a known scope token to an object through the token authority.""" - cls.restore_object_token(obj, token) - parent_scope = cls._normalize_scope(parent_scope) - prefix = cls._get_prefix(obj) - generator = cls.get_generator(parent_scope, prefix) - generator.seed_from_tokens((token,)) - cls._scope_id_cache.pop((parent_scope, id(obj)), None) - return token - - @classmethod - def restore_object_token(cls, obj: ScopeTokenTarget, token: str) -> str: - """Restore detached object identity without registering it in a scope. - - Deserialization/validation can prepare an object without changing any - live generator counter, used-token set or scope-ID cache. Ordinary - later build/seed/adopt operations perform their explicit registration. - """ - ScopeTokenObjectStore.write(obj, cls.SCOPE_TOKEN_ATTRIBUTE, token) - return token - - @classmethod - def object_token(cls, obj: ScopeTokenTarget) -> str | None: - """Return an object's existing scope token without creating one.""" - return ScopeTokenObjectStore.read(obj, cls.SCOPE_TOKEN_ATTRIBUTE) - - @classmethod - def same_object_token( - cls, - left: ScopeTokenTarget, - right: ScopeTokenTarget, - ) -> bool: - """Return whether two objects carry the same existing scope token.""" - left_token = cls.object_token(left) - if left_token is None: - return False - return left_token == cls.object_token(right) - - # PERFORMANCE: Cache scope_id strings per (parent_scope, object_id) - _scope_id_cache: dict[tuple[str, int], str] = {} - - @staticmethod - def _scope_id_for_token(parent_scope: str, token: str) -> ScopeKey: - """The one serialization projection of nominal object-token identity.""" - return ScopeKey(parent_scope).child(token) - - @classmethod - def build_scope_id(cls, parent_scope, obj: ScopeTokenTarget) -> str: - parent_scope = cls._normalize_scope(parent_scope) - # Python can reuse a departed object's numeric ID. The cache is only a - # projection; the current object's declaration-owned token remains the - # identity authority, even when a numeric cache key happens to match. - cache_key = (parent_scope, id(obj)) - cached = cls._scope_id_cache.get(cache_key) - existing_token = cls.object_token(obj) - if (cached is not None and existing_token is not None - and cached == cls._scope_id_for_token(parent_scope, existing_token)): - return cached - - token = cls.ensure_token(parent_scope, obj) - result = cls._scope_id_for_token(parent_scope, token) - cls._scope_id_cache[cache_key] = result - logger.debug(f"🔑 ScopeTokenService.build_scope_id: {result} for {type(obj).__name__}") - return result - - @classmethod - def seed_from_objects( - cls, - parent_scope, - objects: Sequence[ScopeTokenTarget], - ) -> None: - """Seed generators from existing objects (preserves their tokens).""" - if not objects: - return - parent_scope = cls._normalize_scope(parent_scope) - # Group by type prefix - by_prefix: dict[str, list[ScopeTokenTarget]] = {} - for obj in objects: - prefix = cls._get_prefix(obj) - if prefix not in by_prefix: - by_prefix[prefix] = [] - by_prefix[prefix].append(obj) - # Seed each generator - for prefix, objs in by_prefix.items(): - cls.get_generator(parent_scope, prefix).seed_from_objects(objs) - - @classmethod - def clear_scope(cls, parent_scope) -> None: - """Clear all generators for a parent scope (and nested children).""" - parent_scope = cls._normalize_scope(parent_scope) - keys_to_remove = [k for k in cls._generators if ScopeKey(parent_scope).contains(k[0])] - for key in keys_to_remove: - del cls._generators[key] - - # PERFORMANCE: Also clear scope_id cache for this scope - cache_keys_to_remove = [ - k for k in cls._scope_id_cache if ScopeKey(parent_scope).contains(k[0]) - ] - for key in cache_keys_to_remove: - del cls._scope_id_cache[key] - - if keys_to_remove: - logger.debug(f"🔑 ScopeTokenService: Cleared {len(keys_to_remove)} generators for {parent_scope}") diff --git a/src/pyqt_reactive/services/search_service.py b/src/pyqt_reactive/services/search_service.py deleted file mode 100644 index 905854dd..00000000 --- a/src/pyqt_reactive/services/search_service.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Shared search service for UI components. - -This module provides a framework-agnostic search service that can be used -by both PyQt and Textual implementations to ensure consistent search behavior -across the application. -""" - -from typing import Dict, Callable, TypeVar, Generic -import logging - -logger = logging.getLogger(__name__) - -T = TypeVar('T') - - -class SearchService(Generic[T]): - """ - Framework-agnostic search service with minimum character trigger. - - This service provides consistent search behavior across PyQt and Textual - implementations, ensuring the same code path is used for all search operations. - - Key features: - - Minimum character threshold (default: 2) - - Customizable searchable text extraction - - Performance optimization for short searches - """ - - # Class constant for minimum search characters - MIN_SEARCH_CHARS = 2 - - def __init__(self, - all_items: Dict[str, T], - searchable_text_extractor: Callable[[T], str], - min_chars: int = MIN_SEARCH_CHARS): - """ - Initialize search service. - - Args: - all_items: Dictionary of all searchable items (key -> item) - searchable_text_extractor: Function to extract searchable text from an item - min_chars: Minimum characters required to trigger search (default: 2) - """ - self.all_items = all_items - self.searchable_text_extractor = searchable_text_extractor - self.min_chars = min_chars - self.filtered_items = all_items.copy() - - def filter(self, search_term: str) -> Dict[str, T]: - """ - Filter items based on search term. - - This is the canonical search implementation used across all UI frameworks. - - Args: - search_term: Search string to filter by - - Returns: - Dictionary of filtered items - """ - search_term = search_term.strip() - - # Performance optimization: skip expensive filtering for short searches - if not search_term or len(search_term) < self.min_chars: - if len(search_term) == 0: - # Only update if completely empty (to reset from previous filter) - if self.filtered_items != self.all_items: - self.filtered_items = self.all_items.copy() - return self.filtered_items - # For searches below min_chars, keep current state - return self.filtered_items - - # Perform search - search_lower = search_term.lower() - - filtered = { - key: item for key, item in self.all_items.items() - if search_lower in self.searchable_text_extractor(item).lower() - } - - self.filtered_items = filtered - return filtered - - def reset(self): - """Reset filter to show all items.""" - self.filtered_items = self.all_items.copy() - return self.filtered_items - - def update_items(self, new_items: Dict[str, T]): - """Update the items being searched.""" - self.all_items = new_items - self.filtered_items = new_items.copy() - diff --git a/src/pyqt_reactive/services/service_registry.py b/src/pyqt_reactive/services/service_registry.py deleted file mode 100644 index 84160a42..00000000 --- a/src/pyqt_reactive/services/service_registry.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Service registry for decoupled component lookup. - -Provides a central registry where components can register themselves and -other components can look them up by interface/type. This decouples UI -layout from service discovery, preventing breakage when widgets are moved. - -Example: - # Automatic registration via mixin - class MyWidget(QWidget, AutoRegisterServiceMixin): - SERVICE_TYPE = MyWidget # Register as this type - - # Lookup anywhere - widget = ServiceRegistry.get(MyWidget) -""" - -import logging -from typing import Dict, Type, Any, Optional, TypeVar - -logger = logging.getLogger(__name__) - -T = TypeVar('T') - - -class ServiceRegistry: - """Central registry for application services/components. - - Decouples service consumers from service providers by allowing - registration and lookup by type/interface rather than widget hierarchy. - """ - - _services: Dict[Type, Any] = {} - - @classmethod - def register(cls, service_type: Type[T], instance: T) -> None: - """Register a service instance for a given type. - - Args: - service_type: The type/interface to register under - instance: The service instance - """ - cls._services[service_type] = instance - logger.debug(f"[SERVICE_REGISTRY] Registered {service_type.__name__}") - - @classmethod - def get(cls, service_type: Type[T]) -> Optional[T]: - """Get a registered service by type. - - Args: - service_type: The type/interface to look up - - Returns: - The registered instance, or None if not found - """ - instance = cls._services.get(service_type) - if instance is None: - logger.warning(f"[SERVICE_REGISTRY] No service registered for {service_type.__name__}") - logger.info(f"[SERVICE_REGISTRY] Available services: {list(cls._services.keys())}") - return instance - - @classmethod - def unregister(cls, service_type: Type) -> None: - """Unregister a service. - - Args: - service_type: The type to unregister - """ - if service_type in cls._services: - del cls._services[service_type] - logger.debug(f"[SERVICE_REGISTRY] Unregistered {service_type.__name__}") - - @classmethod - def clear(cls) -> None: - """Clear all registered services. Useful for testing.""" - cls._services.clear() - logger.debug("[SERVICE_REGISTRY] Cleared all services") - - -class AutoRegisterServiceMixin: - """Mixin for widgets that auto-register with ServiceRegistry. - - Widgets inherit from this mixin to automatically register themselves - when created and unregister when destroyed. By default, concrete - classes register using their own class type (type(self)). - - Example: - class MyWidget(QWidget, AutoRegisterServiceMixin): - # Auto-registers as MyWidget - no SERVICE_TYPE needed! - pass - - To customize the registration type (e.g., register as an interface): - class MyWidget(QWidget, AutoRegisterServiceMixin): - SERVICE_TYPE = IMyInterface # Register under interface type - - To disable auto-registration (for abstract base classes): - class AbstractWidget(QWidget, AutoRegisterServiceMixin): - SERVICE_TYPE = None # Won't auto-register - """ - - SERVICE_TYPE: Optional[Type] = ... - """The type to register as. - - - If ... (Ellipsis, default): Uses type(self) - concrete class auto-registers - - If a Type: Registers as that specific type (useful for interfaces) - - If None: Disables auto-registration (useful for abstract base classes) - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._register_with_service_registry() - - def _register_with_service_registry(self) -> None: - """Register this widget with the service registry.""" - service_type = self.SERVICE_TYPE - widget_type = type(self).__name__ - logger.info(f"[AUTO_REGISTER] {widget_type}.SERVICE_TYPE = {service_type}") - if service_type is None: - # Explicitly disabled - don't register - logger.info(f"[AUTO_REGISTER] {widget_type} - registration disabled (SERVICE_TYPE=None)") - return - if service_type is ...: - # Default: use concrete class type - service_type = type(self) - logger.info(f"[AUTO_REGISTER] {widget_type} - using type(self) = {service_type.__name__}") - ServiceRegistry.register(service_type, self) - - def closeEvent(self, event): - """Unregister when widget is closed.""" - service_type = self.SERVICE_TYPE - if service_type is None: - # Wasn't registered - pass - elif service_type is ...: - # Default: use concrete class type - ServiceRegistry.unregister(type(self)) - else: - ServiceRegistry.unregister(service_type) - super().closeEvent(event) diff --git a/src/pyqt_reactive/services/system_metrics_sampler.py b/src/pyqt_reactive/services/system_metrics_sampler.py deleted file mode 100644 index c589daee..00000000 --- a/src/pyqt_reactive/services/system_metrics_sampler.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Cached system metrics sampling for monitor widgets.""" - -from __future__ import annotations - -import platform -import shutil -import subprocess -import threading -from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass -from enum import Enum -from typing import ClassVar, Generic, TypeVar - -from metaclass_registry import AutoRegisterMeta -import psutil -from python_introspect import validate_annotated_dataclass -from zmqruntime.config import PositiveFloat -from zmqruntime.process_launch import BackgroundProcessLaunchPolicy - -try: - import GPUtil - GPU_AVAILABLE = True -except ImportError: # pragma: no cover - depends on optional runtime package - GPUtil = None - GPU_AVAILABLE = False - - -def is_wsl() -> bool: - """Check if running in Windows Subsystem for Linux.""" - return "microsoft" in platform.uname().release.lower() - - -def get_cpu_freq_mhz() -> int: - """Get CPU frequency in MHz, with WSL compatibility.""" - if is_wsl(): - try: - output = subprocess.check_output( - [ - "powershell.exe", - "-Command", - "Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty CurrentClockSpeed", - ], - stderr=subprocess.DEVNULL, - timeout=2, - **BackgroundProcessLaunchPolicy.current().popen_arguments(), - ) - return int(output.strip()) - except Exception: - return 0 - try: - freq = psutil.cpu_freq() - return int(freq.current) if freq else 0 - except Exception: - return 0 - - -MetricT = TypeVar("MetricT") - - -@dataclass(frozen=True, slots=True) -class PollingCadence: - """Validated cadence shared by every background metric poller.""" - - seconds: PositiveFloat - - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - - @property - def loop_milliseconds(self) -> int: - """Return the cadence for command-line pollers in milliseconds.""" - - return max(100, int(self.seconds * 1000)) - - -class GpuTemperatureSampling(Enum): - """Closed GPU-temperature sampling policy with member-owned projection.""" - - def __new__( - cls, - enabled: bool, - projector: Callable[[float], float], - ) -> "GpuTemperatureSampling": - member = object.__new__(cls) - member._value_ = enabled - member._projector = projector - return member - - ENABLED = (True, lambda temperature: temperature) - DISABLED = (False, lambda _temperature: 0.0) - - def project(self, temperature: float) -> float: - """Apply this member's temperature leaf.""" - - return self._projector(temperature) - - -class BackgroundPollerABC(ABC, metaclass=AutoRegisterMeta): - """Template owning shared cadence and thread lifecycle for metric pollers.""" - - __registry_key__ = "registry_key" - __skip_if_no_key__ = True - registry_key: ClassVar[str | None] = None - - def __init__(self, *, cadence: PollingCadence, thread_name: str) -> None: - self.cadence = cadence - self._thread_name = thread_name - self._lock = threading.Lock() - self._thread: threading.Thread | None = None - self._closed = False - self._start_attempted = False - - def start(self) -> None: - """Start this poller at most once through its concrete preparation hook.""" - - if self._closed or self._start_attempted: - return - self._start_attempted = True - if not self._prepare_start(): - return - self._thread = threading.Thread( - target=self._run, - name=self._thread_name, - daemon=True, - ) - self._thread.start() - - def stop(self) -> None: - """Stop concrete resources, then join the shared worker thread.""" - - self._closed = True - self._request_stop() - if self._thread is not None: - self._thread.join(timeout=1.0) - self._thread = None - - @abstractmethod - def _prepare_start(self) -> bool: - """Prepare backend resources and report whether the thread should start.""" - - @abstractmethod - def _request_stop(self) -> None: - """Request that the concrete polling loop stop.""" - - @abstractmethod - def _run(self) -> None: - """Run the concrete polling loop.""" - - -class BackgroundMetricPoller(BackgroundPollerABC, Generic[MetricT]): - """Run one blocking metric probe on a background cadence.""" - - registry_key = "metric_probe" - - def __init__( - self, - *, - name: str, - cadence: PollingCadence, - probe: Callable[[], MetricT], - default: MetricT, - ) -> None: - super().__init__(cadence=cadence, thread_name=name) - self._probe = probe - self._value = default - self._stop_event = threading.Event() - - def latest(self) -> MetricT: - self.start() - with self._lock: - return self._value - - def _prepare_start(self) -> bool: - return True - - def _request_stop(self) -> None: - self._stop_event.set() - - def _run(self) -> None: - while not self._stop_event.is_set(): - try: - value = self._probe() - except Exception: - value = self.latest() - with self._lock: - self._value = value - self._stop_event.wait(self.cadence.seconds) - - -@dataclass(frozen=True, slots=True) -class SystemMetricsSamplerConfig: - """Typed policy for expensive system metric providers. - - Args: - enable_gpu_monitoring: Sample GPU utilization and memory metrics when - enabled; disabling this avoids GPU provider and process discovery. - gpu_temperature_monitoring: Include GPU temperature in GPU samples when - GPU monitoring is enabled. - cpu_frequency_monitoring: Sample the current CPU frequency through a - cached background probe. - gpu_refresh_seconds: Positive interval in seconds between GPU samples. - cpu_frequency_refresh_seconds: Positive interval in seconds between CPU - frequency samples. - """ - - enable_gpu_monitoring: bool = True - gpu_temperature_monitoring: bool = True - cpu_frequency_monitoring: bool = True - gpu_refresh_seconds: PositiveFloat = 1.0 - cpu_frequency_refresh_seconds: PositiveFloat = 5.0 - - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - - @property - def gpu_temperature_sampling(self) -> GpuTemperatureSampling: - """Return the nominal policy selected by the editable boolean leaf.""" - - return GpuTemperatureSampling(self.gpu_temperature_monitoring) - - -@dataclass(frozen=True, slots=True) -class CpuMetrics: - """Typed CPU metrics snapshot.""" - - cpu_percent: float = 0.0 - cpu_cores: int = 0 - cpu_freq_mhz: int = 0 - - -@dataclass(frozen=True, slots=True) -class MemoryMetrics: - """Typed system-memory metrics snapshot.""" - - ram_percent: float = 0.0 - ram_used_gb: float = 0.0 - ram_total_gb: float = 0.0 - ram_available_gb: float = 0.0 - - @classmethod - def from_psutil(cls, memory) -> "MemoryMetrics": - """Create one memory snapshot from psutil's virtual-memory result.""" - - return cls( - ram_percent=memory.percent, - ram_used_gb=memory.used / (1024**3), - ram_total_gb=memory.total / (1024**3), - ram_available_gb=memory.available / (1024**3), - ) - - -@dataclass(frozen=True, slots=True) -class GpuMetrics: - """Typed GPU metrics snapshot.""" - - gpu_percent: float = 0.0 - vram_percent: float = 0.0 - gpu_name: str = "GPU Pending" - gpu_temp: float = 0.0 - vram_used_mb: float = 0.0 - vram_total_mb: float = 0.0 - - @classmethod - def unavailable(cls, name: str) -> "GpuMetrics": - return cls(gpu_name=name) - - -@dataclass(frozen=True, slots=True) -class SystemMetrics: - """Composite system snapshot that consumes each metric authority directly.""" - - cpu: CpuMetrics = CpuMetrics() - memory: MemoryMetrics = MemoryMetrics() - gpu: GpuMetrics = GpuMetrics() - - @classmethod - def from_components( - cls, - *, - cpu_percent: float, - ram, - cpu_cores: int, - cpu_freq_mhz: int, - gpu: GpuMetrics, - ) -> "SystemMetrics": - return cls( - cpu=CpuMetrics( - cpu_percent=cpu_percent, - cpu_cores=cpu_cores, - cpu_freq_mhz=cpu_freq_mhz, - ), - memory=MemoryMetrics.from_psutil(ram), - gpu=gpu, - ) - - @classmethod - def error(cls) -> "SystemMetrics": - return cls(gpu=GpuMetrics.unavailable("Error")) - - -def _parse_number(value: str) -> float: - cleaned = value.strip().replace("%", "") - if cleaned in {"", "N/A", "[N/A]"}: - return 0.0 - try: - return float(cleaned) - except ValueError: - return 0.0 - - -class PersistentNvidiaSmiPoller(BackgroundPollerABC): - """Maintain latest NVIDIA GPU metrics from one long-lived nvidia-smi process.""" - - registry_key = "nvidia_smi" - - def __init__( - self, - *, - cadence: PollingCadence, - temperature_sampling: GpuTemperatureSampling, - ) -> None: - super().__init__(cadence=cadence, thread_name="NvidiaSmiPoller") - self.temperature_sampling = temperature_sampling - self._latest_metrics = GpuMetrics.unavailable("GPU Pending") - self._process: subprocess.Popen[str] | None = None - - def latest_metrics(self) -> GpuMetrics: - """Return latest cached GPU metrics, starting the poller if needed.""" - self.start() - with self._lock: - return self._latest_metrics - - def _prepare_start(self) -> bool: - executable = shutil.which("nvidia-smi") - if executable is None: - with self._lock: - self._latest_metrics = GpuMetrics.unavailable("NVIDIA SMI Not Available") - return False - - command = [ - executable, - "--id=0", - "--query-gpu=utilization.gpu,temperature.gpu,memory.used,memory.total,name", - "--format=csv,noheader,nounits", - f"--loop-ms={self.cadence.loop_milliseconds}", - ] - try: - self._process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - bufsize=1, - **BackgroundProcessLaunchPolicy.current().popen_arguments(), - ) - except Exception: - self._process = None - with self._lock: - self._latest_metrics = GpuMetrics.unavailable("NVIDIA SMI Error") - return False - return True - - def _request_stop(self) -> None: - process = self._process - self._process = None - if process is None: - return - try: - process.terminate() - process.wait(timeout=1.0) - except Exception: - try: - process.kill() - except Exception: - pass - - def _run(self) -> None: - process = self._process - if process is None or process.stdout is None: - return - - try: - for line in process.stdout: - parsed = self._parse_gpu_line(line) - if parsed is None: - continue - with self._lock: - self._latest_metrics = parsed - finally: - with self._lock: - if self._latest_metrics.gpu_name == "GPU Pending": - self._latest_metrics = GpuMetrics.unavailable("NVIDIA SMI Error") - - def _parse_gpu_line(self, line: str) -> GpuMetrics | None: - parts = [part.strip() for part in line.strip().split(",", 4)] - if len(parts) != 5: - return None - - gpu_percent = _parse_number(parts[0]) - gpu_temp = self.temperature_sampling.project(_parse_number(parts[1])) - vram_used_mb = _parse_number(parts[2]) - vram_total_mb = _parse_number(parts[3]) - gpu_name = parts[4] or "NVIDIA GPU" - vram_percent = ( - (vram_used_mb / vram_total_mb) * 100.0 - if vram_total_mb > 0 - else 0.0 - ) - return GpuMetrics( - gpu_percent=gpu_percent, - vram_percent=vram_percent, - gpu_name=gpu_name, - gpu_temp=gpu_temp, - vram_used_mb=vram_used_mb, - vram_total_mb=vram_total_mb, - ) - - -class SystemMetricsSampler: - """Collect cheap per-tick metrics while caching slow system probes.""" - - def __init__( - self, - config: SystemMetricsSamplerConfig | None = None, - ) -> None: - self.config = config or SystemMetricsSamplerConfig() - - self._cpu_cores = psutil.cpu_count() or 0 - self._cpu_frequency_poller = ( - BackgroundMetricPoller( - name="CpuFrequencyPoller", - cadence=PollingCadence( - self.config.cpu_frequency_refresh_seconds - ), - probe=get_cpu_freq_mhz, - default=0, - ) - if self.config.cpu_frequency_monitoring - else None - ) - self._gpu_metrics = self._initial_gpu_metrics() - self._gpu_poller = ( - PersistentNvidiaSmiPoller( - cadence=PollingCadence(self.config.gpu_refresh_seconds), - temperature_sampling=self.config.gpu_temperature_sampling, - ) - if self.config.enable_gpu_monitoring - else None - ) - - def collect_metrics(self) -> SystemMetrics: - """Collect a monitor sample, refreshing expensive probes only on schedule.""" - cpu_percent = psutil.cpu_percent(interval=None) - ram = psutil.virtual_memory() - - return SystemMetrics.from_components( - cpu_percent=cpu_percent, - ram=ram, - cpu_cores=self._cpu_cores, - cpu_freq_mhz=self._cached_cpu_frequency(), - gpu=self._cached_gpu_metrics(), - ) - - def _cached_cpu_frequency(self) -> int: - if self._cpu_frequency_poller is None: - return 0 - return int(self._cpu_frequency_poller.latest() or 0) - - def _cached_gpu_metrics(self) -> GpuMetrics: - if not self.config.enable_gpu_monitoring: - return GpuMetrics.unavailable("GPU Monitoring Disabled") - if self._gpu_poller is not None: - self._gpu_metrics = self._gpu_poller.latest_metrics() - return self._gpu_metrics - self._gpu_metrics = self._read_gpu_metrics() - return self._gpu_metrics - - def _read_gpu_metrics(self) -> GpuMetrics: - if not GPU_AVAILABLE or GPUtil is None: - return GpuMetrics.unavailable("GPUtil Not Available") - - try: - gpus = GPUtil.getGPUs() - if not gpus: - return GpuMetrics.unavailable("No GPU Found") - - gpu = gpus[0] - return GpuMetrics( - gpu_percent=gpu.load * 100, - vram_percent=gpu.memoryUtil * 100, - gpu_name=gpu.name, - gpu_temp=self.config.gpu_temperature_sampling.project( - gpu.temperature - ), - vram_used_mb=gpu.memoryUsed, - vram_total_mb=gpu.memoryTotal, - ) - except Exception: - return GpuMetrics.unavailable("GPU Error") - - def _initial_gpu_metrics(self) -> GpuMetrics: - if not self.config.enable_gpu_monitoring: - return GpuMetrics.unavailable("GPU Monitoring Disabled") - return GpuMetrics.unavailable("GPU Pending") - - def close(self) -> None: - """Stop any background metric providers owned by this sampler.""" - if self._cpu_frequency_poller is not None: - self._cpu_frequency_poller.stop() - if self._gpu_poller is not None: - self._gpu_poller.stop() diff --git a/src/pyqt_reactive/services/system_monitor_actions.py b/src/pyqt_reactive/services/system_monitor_actions.py index 579894f4..2fb9c9e8 100644 --- a/src/pyqt_reactive/services/system_monitor_actions.py +++ b/src/pyqt_reactive/services/system_monitor_actions.py @@ -6,7 +6,7 @@ from enum import StrEnum from typing import TYPE_CHECKING, Self -from pyqt_reactive.services.executable_action import LabeledExecutableActionMixin +from formbridge.services.executable_action import LabeledExecutableActionMixin if TYPE_CHECKING: from pyqt_reactive.widgets.system_monitor import SystemMonitorWidget diff --git a/src/pyqt_reactive/services/system_monitor_config.py b/src/pyqt_reactive/services/system_monitor_config.py deleted file mode 100644 index 2f7320d0..00000000 --- a/src/pyqt_reactive/services/system_monitor_config.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Nominal configuration owner for the system monitor widget.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import StrEnum - -from python_introspect import validate_annotated_dataclass -from zmqruntime.config import PositiveFloat, PositiveInteger - -from pyqt_reactive.services.system_metrics_sampler import SystemMetricsSamplerConfig - - -class PerformanceGraphColor(StrEnum): - """Closed high-contrast palette offered for system-monitor graph series.""" - - BLACK = "black" - WHITE = "white" - LIGHT_GRAY = "lightgray" - GRAY = "gray" - DARK_GRAY = "darkgray" - RED = "red" - DARK_RED = "darkred" - ORANGE = "orange" - YELLOW = "yellow" - LIME = "lime" - GREEN = "green" - DARK_GREEN = "darkgreen" - CYAN = "cyan" - DARK_CYAN = "darkcyan" - BLUE = "blue" - DARK_BLUE = "darkblue" - MAGENTA = "magenta" - PURPLE = "purple" - PINK = "pink" - - -@dataclass(frozen=True, slots=True) -class PerformanceMonitorColors: - """Colors for the declared system-monitor series.""" - - cpu: PerformanceGraphColor = PerformanceGraphColor.CYAN - """Color used for the CPU utilization series.""" - - ram: PerformanceGraphColor = PerformanceGraphColor.LIME - """Color used for the system-memory series.""" - - gpu: PerformanceGraphColor = PerformanceGraphColor.ORANGE - """Color used for the GPU utilization series.""" - - vram: PerformanceGraphColor = PerformanceGraphColor.MAGENTA - """Color used for the GPU-memory series.""" - - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - - -@dataclass(frozen=True) -class PerformanceMonitorConfig: - """Complete behavior configuration for :class:`SystemMonitorWidget`.""" - - update_fps: PositiveFloat = 10.0 - """Metric sampling and plot-update frequency in frames per second.""" - - history_duration_seconds: PositiveFloat = 60.0 - """Duration of historical data displayed by the plots.""" - - max_data_points: PositiveInteger | None = None - """Explicit retained sample count, or ``None`` to derive it from time and FPS.""" - - sampler_config: SystemMetricsSamplerConfig = field(default_factory=SystemMetricsSamplerConfig) - """Policy owned by the system metrics sampler.""" - - show_grid: bool = True - """Whether monitor plots display grid lines.""" - - antialiasing: bool = True - """Whether plot curves request antialiased rendering.""" - - use_opengl: bool = False - """Whether plots request pyqtgraph's experimental OpenGL rendering path.""" - - line_width: PositiveFloat = 2.0 - """Width of all monitor plot curves in pixels.""" - - colors: PerformanceMonitorColors = field(default_factory=PerformanceMonitorColors) - """Colors for the four declared monitor series.""" - - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - - @property - def update_interval_seconds(self) -> float: - """Sampling interval derived from :attr:`update_fps`.""" - - return 1.0 / self.update_fps - - @property - def calculated_max_data_points(self) -> int: - """Retained sample count derived from the declared history policy.""" - - if self.max_data_points is not None: - return self.max_data_points - return max( - 1, - int(self.history_duration_seconds / self.update_interval_seconds), - ) diff --git a/src/pyqt_reactive/services/system_monitor_core.py b/src/pyqt_reactive/services/system_monitor_core.py deleted file mode 100644 index 9df056e6..00000000 --- a/src/pyqt_reactive/services/system_monitor_core.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -System Monitor Core - Framework-agnostic metrics collection. - -This module provides pure system metrics collection without any visualization dependencies. -Can be used by any UI framework (PyQt, Textual, etc.) for system monitoring. -""" - -from collections import deque -from dataclasses import dataclass -import platform -import time - -import psutil - -from pyqt_reactive.services.system_metrics_sampler import ( - CpuMetrics, - GPUtil, - GPU_AVAILABLE, - GpuMetrics, - MemoryMetrics, - SystemMetrics, - SystemMetricsSampler, -) - - -@dataclass(frozen=True, slots=True) -class SystemMetricsHistory: - """Immutable history snapshot from one monitor.""" - - cpu: tuple[float, ...] - ram: tuple[float, ...] - gpu: tuple[float, ...] - vram: tuple[float, ...] - timestamps: tuple[float, ...] - - -@dataclass(frozen=True, slots=True) -class SystemInformation: - """Static host identity plus the canonical metrics carriers.""" - - operating_system: str - operating_system_version: str - metrics: SystemMetrics - - -class SystemMonitorCore: - """ - Framework-agnostic system monitoring core. - - Collects CPU, RAM, GPU, and VRAM metrics without any visualization dependencies. - Maintains historical data in deques for efficient time-series tracking. - """ - - def __init__( - self, - history_length: int = 60, - *, - sampler: SystemMetricsSampler | None = None, - ): - """ - Initialize the system monitor core. - - Args: - history_length: Number of historical data points to keep - """ - self.history_length = history_length - - # Initialize data storage - self.cpu_history = deque(maxlen=history_length) - self.ram_history = deque(maxlen=history_length) - self.gpu_history = deque(maxlen=history_length) - self.vram_history = deque(maxlen=history_length) - self.time_stamps = deque(maxlen=history_length) - - # Cache current metrics to avoid duplicate system calls - self._current_metrics = SystemMetrics() - self._sampler = sampler - - # Initialize with zeros - for _ in range(history_length): - self.cpu_history.append(0) - self.ram_history.append(0) - self.gpu_history.append(0) - self.vram_history.append(0) - self.time_stamps.append(0) - - def update_metrics(self) -> None: - """ - Update system metrics and cache current values. - - Collects CPU, RAM, GPU, and VRAM usage and appends to history. - Updates the typed internal cache for efficient access. - """ - if self._sampler is None: - self._sampler = SystemMetricsSampler() - self.record_metrics(self._sampler.collect_metrics()) - - def record_metrics( - self, - metrics: SystemMetrics, - *, - timestamp: float | None = None, - ) -> None: - """Record one canonical metrics snapshot in every history projection.""" - - self.cpu_history.append(metrics.cpu.cpu_percent) - self.ram_history.append(metrics.memory.ram_percent) - self.gpu_history.append(metrics.gpu.gpu_percent) - self.vram_history.append(metrics.gpu.vram_percent) - self.time_stamps.append(time.time() if timestamp is None else timestamp) - self._current_metrics = metrics - - def get_metrics(self) -> SystemMetrics: - """Return the latest typed metrics snapshot without sampling again.""" - - return self._current_metrics - - def get_history_data(self) -> SystemMetricsHistory: - """Return one immutable projection of the owned history buffers.""" - - return SystemMetricsHistory( - cpu=tuple(self.cpu_history), - ram=tuple(self.ram_history), - gpu=tuple(self.gpu_history), - vram=tuple(self.vram_history), - timestamps=tuple(self.time_stamps), - ) - - def get_system_info(self) -> SystemInformation: - """Return one typed static system-information snapshot.""" - - gpu_metrics = GpuMetrics.unavailable("GPU Not Available") - if GPU_AVAILABLE: - try: - gpus = GPUtil.getGPUs() - if gpus: - gpu = gpus[0] - gpu_metrics = GpuMetrics( - gpu_percent=gpu.load * 100, - vram_percent=gpu.memoryUtil * 100, - gpu_name=gpu.name, - gpu_temp=gpu.temperature, - vram_used_mb=gpu.memoryUsed, - vram_total_mb=gpu.memoryTotal, - ) - except Exception: - pass - - memory = psutil.virtual_memory() - return SystemInformation( - operating_system=platform.system(), - operating_system_version=platform.version(), - metrics=SystemMetrics( - cpu=CpuMetrics(cpu_cores=psutil.cpu_count() or 0), - memory=MemoryMetrics.from_psutil(memory), - gpu=gpu_metrics, - ), - ) - - def reset_history(self) -> None: - """Reset all historical data to zeros.""" - self.cpu_history.clear() - self.ram_history.clear() - self.gpu_history.clear() - self.vram_history.clear() - self.time_stamps.clear() - - # Re-initialize with zeros - for _ in range(self.history_length): - self.cpu_history.append(0) - self.ram_history.append(0) - self.gpu_history.append(0) - self.vram_history.append(0) - self.time_stamps.append(0) - - self._current_metrics = SystemMetrics() - - def close(self) -> None: - """Stop background metric providers owned by this monitor core.""" - if self._sampler is not None: - self._sampler.close() diff --git a/src/pyqt_reactive/services/tab_identity.py b/src/pyqt_reactive/services/tab_identity.py deleted file mode 100644 index 03390050..00000000 --- a/src/pyqt_reactive/services/tab_identity.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Lightweight nominal identities for labelled UI tabs.""" - -from __future__ import annotations - - -class TabLabelDeclarationMixin: - """Nominal tab declaration resolved against a live tab-label projection.""" - - label: str - - def index_in(self, labels: tuple[str, ...]) -> int: - """Resolve this declaration without assuming a fixed tab position.""" - - matches = tuple(index for index, label in enumerate(labels) if label == self.label) - if len(matches) != 1: - raise ValueError(f"Expected one {self.label!r} tab, found {len(matches)}.") - return matches[0] diff --git a/src/pyqt_reactive/services/time_travel_navigation.py b/src/pyqt_reactive/services/time_travel_navigation.py index 9ab30dc1..37be6f5a 100644 --- a/src/pyqt_reactive/services/time_travel_navigation.py +++ b/src/pyqt_reactive/services/time_travel_navigation.py @@ -11,7 +11,7 @@ from objectstate.time_travel_profile import TimeTravelProfiler from PyQt6.QtCore import QTimer -from pyqt_reactive.services.function_navigation import ( +from formbridge.services.function_navigation import ( FUNCTION_FIELD_ROOT, build_function_token_field_path, is_function_field_path, diff --git a/src/pyqt_reactive/services/tree_item_key.py b/src/pyqt_reactive/services/tree_item_key.py deleted file mode 100644 index 851e87d9..00000000 --- a/src/pyqt_reactive/services/tree_item_key.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Nominal identity contract for typed Qt tree payloads.""" - -from abc import ABC, abstractmethod - - -class TreeItemKeyProviderABC(ABC): - """Payload that owns its stable tree-state identity.""" - - @abstractmethod - def tree_item_key(self) -> str: - """Return the stable key segment for this payload.""" - raise NotImplementedError - - -class EndpointPortProviderABC(TreeItemKeyProviderABC): - """Typed endpoint payload whose port is also its stable tree identity.""" - - @property - @abstractmethod - def port(self) -> int: - """Return the endpoint port represented by this payload.""" - raise NotImplementedError - - def tree_item_key(self) -> str: - """Derive stable tree identity from the declared endpoint port.""" - return f"port:{self.port}" diff --git a/src/pyqt_reactive/services/ui_thread_dispatch.py b/src/pyqt_reactive/services/ui_thread_dispatch.py index 460372d6..585a95fa 100644 --- a/src/pyqt_reactive/services/ui_thread_dispatch.py +++ b/src/pyqt_reactive/services/ui_thread_dispatch.py @@ -2,83 +2,15 @@ from __future__ import annotations -import threading -from collections.abc import Callable -from typing import Generic, TypeVar, cast +from typing import cast +from formbridge.services.main_thread_dispatch import ( + DispatchedCall, + MainThreadDispatchError, + MainThreadDispatcher, +) from PyQt6.QtCore import QCoreApplication, QObject, Qt, QThread, pyqtSignal -ResultT = TypeVar("ResultT") - - -class UiThreadDispatchError(RuntimeError): - """Raised when a UI-thread operation cannot be dispatched.""" - - -class UiThreadDispatcherClosedError(UiThreadDispatchError): - """Raised when dispatch is requested after shutdown begins.""" - - -class UiThreadDispatchTimeoutError(TimeoutError): - """Raised when a queued UI-thread operation does not begin in time.""" - - -class _UiThreadCall(Generic[ResultT]): - """One callable owned by a dispatcher until execution or cancellation.""" - - def __init__( - self, - callback: Callable[[], ResultT], - retire: Callable[[_UiThreadCall[ResultT]], None], - ) -> None: - self._callback = callback - self._retire = retire - self._lock = threading.Lock() - self._started = False - self._finished = False - self.done = threading.Event() - self.result: ResultT | None = None - self.error: BaseException | None = None - - def run(self) -> None: - with self._lock: - if self._finished: - return - self._started = True - try: - result = self._callback() - except BaseException as error: - self._finish(error=error) - else: - self._finish(result=result) - - def cancel(self, error: BaseException) -> bool: - with self._lock: - if self._started or self._finished: - return False - self._finished = True - self.error = error - self._complete() - return True - - def _finish( - self, - *, - result: ResultT | None = None, - error: BaseException | None = None, - ) -> None: - with self._lock: - if self._finished: - return - self._finished = True - self.result = result - self.error = error - self._complete() - - def _complete(self) -> None: - self.done.set() - self._retire(self) - class _UiThreadCallProxy(QObject): call_requested = pyqtSignal(object) @@ -90,110 +22,32 @@ def __init__(self) -> None: type=Qt.ConnectionType.QueuedConnection, ) - def _execute(self, call: _UiThreadCall) -> None: + def _execute(self, call: DispatchedCall) -> None: call.run() -class UiThreadDispatcher: - """Own every callable queued onto the Qt application thread.""" +class UiThreadDispatcher(MainThreadDispatcher): + """Deliver dispatched calls through the Qt application's event loop.""" def __init__(self) -> None: - self._lock = threading.Lock() - self._closed = False - self._calls: set[_UiThreadCall] = set() + super().__init__() self._proxy: _UiThreadCallProxy | None = None application = QCoreApplication.instance() if application is not None: self._proxy = _UiThreadCallProxy() self._proxy.moveToThread(application.thread()) - def close(self) -> None: - """Reject new work and cancel callbacks which have not begun.""" - - with self._lock: - if self._closed: - return - self._closed = True - calls = tuple(self._calls) - for call in calls: - call.cancel( - UiThreadDispatcherClosedError("UI dispatcher is shutting down.") - ) - - def call( - self, - callback: Callable[[], ResultT], - *, - timeout_ms: int = 5000, - ) -> ResultT: - """Run on the Qt thread and return its one authoritative outcome.""" - - if self._is_ui_thread(): - self._require_open() - return callback() - if self._proxy is None: - raise UiThreadDispatchError("No Qt application is available for UI dispatch.") - - call = self._register(callback) - self._proxy.call_requested.emit(call) - if not call.done.wait(timeout_ms / 1000): - timeout = self._dispatch_timeout_error() - if call.cancel(timeout): - raise timeout - call.done.wait() - if call.error is not None: - raise call.error - return cast(ResultT, call.result) - - @staticmethod - def _dispatch_timeout_error() -> UiThreadDispatchTimeoutError: - """Construct the outcome offered only to a not-yet-started call. - - The call's atomic cancel() decides whether this error is published. - Started callbacks retain their own outcome, even when that outcome is - itself a UiThreadDispatchTimeoutError. Subclasses can project the owned - cancellation without catching and misclassifying callback exceptions. - """ - return UiThreadDispatchTimeoutError("Timed out waiting for UI thread dispatch.") - - def post(self, callback: Callable[[], None]) -> None: - """Queue work on the Qt thread without waiting for its result.""" - - if self._proxy is None: - if self._is_ui_thread(): - self._require_open() - callback() - return - raise UiThreadDispatchError("No Qt application is available for UI dispatch.") - self._proxy.call_requested.emit(self._register(callback)) - - def _register( - self, - callback: Callable[[], ResultT], - ) -> _UiThreadCall[ResultT]: - with self._lock: - if self._closed: - raise UiThreadDispatcherClosedError( - "UI dispatcher is shutting down." - ) - call = _UiThreadCall(callback, self._retire) - self._calls.add(call) - return call - - def _retire(self, call: _UiThreadCall) -> None: - with self._lock: - self._calls.discard(call) - - def _require_open(self) -> None: - with self._lock: - if self._closed: - raise UiThreadDispatcherClosedError( - "UI dispatcher is shutting down." - ) - - @staticmethod - def _is_ui_thread() -> bool: + def _is_owner_thread(self) -> bool: application = QCoreApplication.instance() if application is None: return True return QThread.currentThread() == application.thread() + + def _can_deliver(self) -> bool: + return self._proxy is not None + + def _deliver(self, call: DispatchedCall) -> None: + cast(_UiThreadCallProxy, self._proxy).call_requested.emit(call) + + def _undeliverable_error(self) -> MainThreadDispatchError: + return MainThreadDispatchError("No Qt application is available for UI dispatch.") diff --git a/src/pyqt_reactive/services/value_collection_service.py b/src/pyqt_reactive/services/value_collection_service.py index c2c3e6d7..307d8b5f 100644 --- a/src/pyqt_reactive/services/value_collection_service.py +++ b/src/pyqt_reactive/services/value_collection_service.py @@ -16,7 +16,7 @@ from dataclasses import fields as dataclass_fields import logging -from .parameter_service_abc import ParameterServiceABC +from formbridge.services.parameter_service_abc import ParameterServiceABC from .widget_service import WidgetService if TYPE_CHECKING: diff --git a/src/pyqt_reactive/services/widget_tree_projection.py b/src/pyqt_reactive/services/widget_tree_projection.py index 8994853b..feca2aa9 100644 --- a/src/pyqt_reactive/services/widget_tree_projection.py +++ b/src/pyqt_reactive/services/widget_tree_projection.py @@ -36,7 +36,7 @@ QWidget, ) -from pyqt_reactive.services.widget_tree_projection_config import ( +from formbridge.services.widget_tree_projection_config import ( DEFAULT_WIDGET_TREE_PROJECTION_POLICY, WidgetNodeIdentity, WidgetPath, diff --git a/src/pyqt_reactive/services/widget_tree_projection_config.py b/src/pyqt_reactive/services/widget_tree_projection_config.py deleted file mode 100644 index c12011e9..00000000 --- a/src/pyqt_reactive/services/widget_tree_projection_config.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Lightweight widget-tree projection policy shared by window managers.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import MISSING, dataclass, field, fields, is_dataclass -from typing import Any, TypeAlias - -DEFAULT_MAXIMUM_WIDGET_TEXT_LENGTH = 4096 -DEFAULT_MAXIMUM_ITEM_MODEL_NODES = 512 -DEFAULT_TEXT_TRUNCATION_SUFFIX = "..." -WidgetPath: TypeAlias = tuple[int, ...] -CompactFieldPredicate: TypeAlias = Callable[[object, object], bool] -COMPACT_FIELD_PROJECTION_METADATA_KEY = "compact_field_projection" - - -@dataclass(frozen=True, slots=True) -class CompactFieldProjection: - """Declaration-owned rules for one field in a compact dataclass projection.""" - - includes: CompactFieldPredicate - - -def always_project_compact_field(_owner: object, _value: object) -> bool: - """Retain a field even when its current value is otherwise empty.""" - - return True - - -def _compact_value_carries_information(value: object) -> bool: - if value is None: - return False - if isinstance(value, bool): - return value - if isinstance(value, (str, bytes, tuple, list, dict, set, frozenset)): - return bool(value) - return True - - -def compact_dataclass_projection(value: object) -> dict[str, Any]: - """Project one dataclass through its field-owned compactness declarations.""" - - if isinstance(value, type) or not is_dataclass(value): - raise TypeError( - f"Compact projection requires a dataclass instance, got {value!r}." - ) - projected: dict[str, Any] = {} - for declared_field in fields(value): - field_value = getattr(value, declared_field.name) - policy = declared_field.metadata.get(COMPACT_FIELD_PROJECTION_METADATA_KEY) - if policy is not None and not isinstance(policy, CompactFieldProjection): - raise TypeError( - f"{type(value).__name__}.{declared_field.name} declares an invalid " - "compact field projection." - ) - required = ( - declared_field.default is MISSING - and declared_field.default_factory is MISSING - ) - if required: - includes = True - elif isinstance(policy, CompactFieldProjection): - includes = policy.includes(value, field_value) - else: - # Empty values may only disappear when the declaration restores - # that exact value. Do not evaluate factories during serialization. - includes = _compact_value_carries_information(field_value) or not ( - declared_field.default is not MISSING - and type(field_value) is type(declared_field.default) - and field_value == declared_field.default - ) - if includes: - projected[declared_field.name] = field_value - return projected - - -@dataclass(frozen=True, slots=True) -class WidgetTextProjection: - """Bounded text projection with explicit truncation state.""" - - value: str - truncated: bool - - -@dataclass(frozen=True, slots=True) -class WidgetNodeIdentity: - """Stable widget identity fields shared by projector and transport DTOs.""" - - path: WidgetPath = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - path_id: str = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - child_index: int | None = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - class_name: str = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=always_project_compact_field - ) - } - ) - object_name: str - accessible_name: str - accessible_description: str - - -@dataclass(frozen=True, kw_only=True) -class WidgetTreeProjectionControls: - """Projection controls that do not require importing Qt widget classes.""" - - maximum_text_length: int = DEFAULT_MAXIMUM_WIDGET_TEXT_LENGTH - maximum_item_model_nodes: int | None = DEFAULT_MAXIMUM_ITEM_MODEL_NODES - truncation_suffix: str = DEFAULT_TEXT_TRUNCATION_SUFFIX - - @classmethod - def default_maximum_text_length(cls) -> int: - return DEFAULT_MAXIMUM_WIDGET_TEXT_LENGTH - - @classmethod - def default_maximum_item_model_nodes(cls) -> int: - return DEFAULT_MAXIMUM_ITEM_MODEL_NODES - - @classmethod - def default_truncation_suffix(cls) -> str: - return DEFAULT_TEXT_TRUNCATION_SUFFIX - - def __post_init__(self) -> None: - if self.maximum_text_length < len(self.truncation_suffix): - raise ValueError( - "maximum_text_length must be at least the truncation suffix length" - ) - if ( - self.maximum_item_model_nodes is not None - and self.maximum_item_model_nodes < 0 - ): - raise ValueError("maximum_item_model_nodes must be non-negative or None") - - def project_text(self, text: str) -> WidgetTextProjection: - if len(text) <= self.maximum_text_length: - return WidgetTextProjection(value=text, truncated=False) - - prefix_length = self.maximum_text_length - len(self.truncation_suffix) - return WidgetTextProjection( - value=f"{text[:prefix_length]}{self.truncation_suffix}", - truncated=True, - ) - - def as_projection_policy( - self, - *, - maximum_depth: int | None = None, - maximum_nodes: int | None = None, - ) -> WidgetTreeProjectionPolicy: - return WidgetTreeProjectionPolicy( - maximum_text_length=self.maximum_text_length, - maximum_item_model_nodes=self.maximum_item_model_nodes, - truncation_suffix=self.truncation_suffix, - maximum_depth=maximum_depth, - maximum_nodes=maximum_nodes, - ) - - -@dataclass(frozen=True, slots=True, kw_only=True) -class WidgetTreeProjectionPolicy(WidgetTreeProjectionControls): - """Concrete projection policy used by Qt widget tree projectors.""" - - maximum_depth: int | None = None - maximum_nodes: int | None = None - - def __post_init__(self) -> None: - WidgetTreeProjectionControls.__post_init__(self) - if self.maximum_depth is not None and self.maximum_depth < 0: - raise ValueError("maximum_depth must be non-negative or None") - if self.maximum_nodes is not None and self.maximum_nodes < 1: - raise ValueError("maximum_nodes must be positive or None") - - -DEFAULT_WIDGET_TREE_PROJECTION_POLICY = WidgetTreeProjectionPolicy() diff --git a/src/pyqt_reactive/services/window_code_document.py b/src/pyqt_reactive/services/window_code_document.py deleted file mode 100644 index 2d31d314..00000000 --- a/src/pyqt_reactive/services/window_code_document.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Generic code-document capability for WindowManager-managed windows.""" - -from __future__ import annotations - -import hashlib -import os -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Generic, TypeVar - -from pyqt_reactive.protocols import get_codegen_provider, get_form_config - -PYTHON_MIME_TYPE = "text/x-python" -EXTERNAL_EDITOR_ENABLED_VALUES = frozenset(("1", "true", "yes")) -DeclarationT = TypeVar("DeclarationT") - - -class WindowCodeDocumentError(RuntimeError): - """Raised when a managed window cannot service a code-document request.""" - - -@dataclass(frozen=True, slots=True) -class WindowCodeDocument(Generic[DeclarationT]): - """Rendered code document owned by one managed window.""" - - title: str - source: str - mime_type: str = PYTHON_MIME_TYPE - declaration_type: type[DeclarationT] | None = None - - -class WindowCodeDocumentDriver(ABC, Generic[DeclarationT]): - """Read/apply code-mode content for one WindowManager scope.""" - - def current_revision_token(self) -> str: - """Return the revision of the authoritative clean document content.""" - source = self.read_document(clean=True).source - return hashlib.sha256(source.encode("utf-8")).hexdigest() - - def records_object_state_snapshot_on_apply(self) -> bool: - """Return whether applying this document must advance ObjectState history.""" - return True - - def writable(self) -> bool: - """Return whether the live document currently accepts mutations.""" - return True - - def source_is_current( - self, - source: str, - *, - current_document: WindowCodeDocument[DeclarationT] | None = None, - ) -> bool: - """Compare edited source through the nominal declaration normalizer.""" - current = current_document or self.read_document(clean=True) - provider = get_codegen_provider() - if provider is None or current.declaration_type is None: - return current.source == source - try: - normalized = provider.normalize_source( - source, - declaration_type=current.declaration_type, - clean_mode=True, - ) - except Exception: - return current.source == source - return current.source == normalized - - @abstractmethod - def read_document(self, clean: bool = True) -> WindowCodeDocument[DeclarationT]: - """Return the current code document.""" - raise NotImplementedError - - @abstractmethod - def validate_source(self, source: str) -> None: - """Validate source without changing UI state.""" - raise NotImplementedError - - @abstractmethod - def apply_source(self, source: str) -> None: - """Apply source through the same state path as interactive code mode.""" - raise NotImplementedError - - -def use_external_code_editor() -> bool: - """Whether code documents open in an external editor (the host's environment switch).""" - - value = os.environ.get(get_form_config().external_editor_env_var, "") - return value.lower() in EXTERNAL_EDITOR_ENABLED_VALUES diff --git a/src/pyqt_reactive/services/window_manager.py b/src/pyqt_reactive/services/window_manager.py index 68fb0cef..06f49321 100644 --- a/src/pyqt_reactive/services/window_manager.py +++ b/src/pyqt_reactive/services/window_manager.py @@ -45,7 +45,7 @@ ) if TYPE_CHECKING: - from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver + from formbridge.services.window_code_document import WindowCodeDocumentDriver logger = logging.getLogger(__name__) WindowProjectionT = TypeVar("WindowProjectionT") diff --git a/src/pyqt_reactive/services/window_snapshot.py b/src/pyqt_reactive/services/window_snapshot.py index 09131faa..9ec6a564 100644 --- a/src/pyqt_reactive/services/window_snapshot.py +++ b/src/pyqt_reactive/services/window_snapshot.py @@ -16,10 +16,7 @@ from python_introspect import project_dataclass from zmqruntime.timeouts import OperationDeadline -from ..flash_trace import ( - FlashTrace, - FlashTraceRecord, -) +from formbridge.flash_trace import FlashTrace, FlashTraceRecord if TYPE_CHECKING: from PyQt6.QtCore import pyqtBoundSignal diff --git a/src/pyqt_reactive/services/zmq_server_info.py b/src/pyqt_reactive/services/zmq_server_info.py deleted file mode 100644 index 431d50a7..00000000 --- a/src/pyqt_reactive/services/zmq_server_info.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Nominal UI projections of typed ZMQ server heartbeats.""" - -from __future__ import annotations - -from abc import ABC -from dataclasses import dataclass -from typing import ClassVar - -from metaclass_registry import AutoRegisterMeta -from zmqruntime.messages import ( - PongResponse, - QueuedExecutionInfo, - RunningExecutionInfo, - ServerRole, - WorkerState, -) - -from pyqt_reactive.services.tree_item_key import EndpointPortProviderABC - - -@dataclass(frozen=True, slots=True) -class BaseServerInfo(EndpointPortProviderABC, ABC, metaclass=AutoRegisterMeta): - """Base presentation view over the authoritative PONG response.""" - - __registry_key__ = "_server_role" - _server_role: ClassVar[ServerRole | None] = None - - response: PongResponse - - @classmethod - def from_response(cls, response: PongResponse) -> BaseServerInfo: - """Construct the nominal projection registered for the response role.""" - - info_type = cls.__registry__[response.server_role] - return info_type(response) - - @property - def port(self) -> int: - return self.response.port - - @property - def ready(self) -> bool: - return self.response.ready - - @property - def log_file_path(self) -> str | None: - return self.response.log_file_path - - @property - def server_name(self) -> str: - return self.response.server - -@dataclass(frozen=True, slots=True) -class ExecutionServerInfo(BaseServerInfo): - """Execution server specific fields.""" - - _server_role = ServerRole.EXECUTION - - @property - def workers(self) -> tuple[WorkerState, ...]: - return self.response.workers or () - - @property - def running_execution_entries(self) -> tuple[RunningExecutionInfo, ...]: - return self.response.running_executions or () - - @property - def queued_execution_entries(self) -> tuple[QueuedExecutionInfo, ...]: - return self.response.queued_executions or () - - @property - def running_executions(self) -> tuple[str, ...]: - return tuple(entry.execution_id for entry in self.running_execution_entries) - - @property - def queued_executions(self) -> tuple[str, ...]: - return tuple(entry.execution_id for entry in self.queued_execution_entries) - - -@dataclass(frozen=True, slots=True) -class ViewerServerInfo(BaseServerInfo): - """Viewer server (napari/fiji) fields.""" - - _server_role = ServerRole.VIEWER - - @property - def viewer_name(self) -> str: - return self.response.server_type or self.response.server - - @property - def memory_mb(self) -> float | None: - usage = self.response.process_usage - return None if usage is None else usage.memory_mb - - @property - def cpu_percent(self) -> float | None: - usage = self.response.process_usage - return None if usage is None else usage.cpu_percent - - -@dataclass(frozen=True, slots=True) -class GenericServerInfo(BaseServerInfo): - """Fallback view for servers without a specialized presentation role.""" - - _server_role = ServerRole.GENERIC diff --git a/src/pyqt_reactive/services/zmq_server_scan_service.py b/src/pyqt_reactive/services/zmq_server_scan_service.py deleted file mode 100644 index 6fe44901..00000000 --- a/src/pyqt_reactive/services/zmq_server_scan_service.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Typed ZMQ server discovery.""" - -from __future__ import annotations - -import concurrent.futures -from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence -from dataclasses import dataclass - -from zmqruntime.config import TransportMode, ZMQConfig -from zmqruntime.messages import PongResponse -from zmqruntime.startup import ( - EndpointStartupPhase, - EndpointStartupPresentationTarget, - EndpointStartupStatus, -) -from zmqruntime.transport import ( - TransportEndpoint, - request_control_ping, - resolve_transport_mode, -) - -from pyqt_reactive.services.tree_item_key import EndpointPortProviderABC - - -class EndpointObservation(EndpointPortProviderABC, ABC): - """One nominal observation occupying one endpoint port in a snapshot.""" - - @property - @abstractmethod - def port(self) -> int: - """Observed endpoint port.""" - - @property - @abstractmethod - def status(self) -> EndpointStartupStatus: - """Lifecycle status derived from this observation.""" - - @property - @abstractmethod - def response(self) -> PongResponse | None: - """Typed heartbeat when this observation reached the control endpoint.""" - - @property - @abstractmethod - def startup_observation(self) -> StartingEndpointObservation | None: - """Startup-row projection when the control endpoint is not ready.""" - - @abstractmethod - def after_startup_status( - self, - status: EndpointStartupStatus, - ) -> EndpointObservation | None: - """Return the observation after a newer lifecycle event.""" - - @abstractmethod - def retained_after_scan_miss( - self, - is_proven_live: Callable[[PongResponse], bool], - ) -> EndpointObservation | None: - """Return this observation only when its declaration proves retention.""" - - -@dataclass(frozen=True, slots=True) -class ResponsiveEndpointObservation(EndpointObservation): - """Endpoint observation backed by its canonical PONG response.""" - - pong: PongResponse - - @property - def port(self) -> int: - return self.pong.port - - @property - def status(self) -> EndpointStartupStatus: - if self.pong.ready: - return EndpointStartupStatus( - phase=EndpointStartupPhase.CONNECTED, - message="Connected", - ) - return EndpointStartupStatus( - phase=EndpointStartupPhase.CHECKING_ENDPOINT, - message="Starting", - ) - - @property - def response(self) -> PongResponse: - return self.pong - - @property - def startup_observation(self) -> None: - return None - - def after_startup_status( - self, - status: EndpointStartupStatus, - ) -> EndpointObservation | None: - return self if status.phase.expects_endpoint_presence else None - - def retained_after_scan_miss( - self, - is_proven_live: Callable[[PongResponse], bool], - ) -> EndpointObservation | None: - return self if is_proven_live(self.pong) else None - - -@dataclass(frozen=True, slots=True) -class StartingEndpointObservation(EndpointObservation): - """Endpoint attempt observed before its control endpoint answers PING.""" - - endpoint_port: int - startup_status: EndpointStartupStatus - - def __post_init__(self) -> None: - if not self.startup_status.phase.expects_endpoint_presence: - raise ValueError("Starting endpoint observation requires an active phase") - - @property - def port(self) -> int: - return self.endpoint_port - - @property - def status(self) -> EndpointStartupStatus: - return self.startup_status - - @property - def response(self) -> None: - return None - - @property - def startup_observation(self) -> StartingEndpointObservation: - return self - - def present(self, target: EndpointStartupPresentationTarget) -> None: - """Project this observation through its lifecycle declaration leaf.""" - - self.startup_status.phase.present(target, self.startup_status.message) - - def after_startup_status( - self, - status: EndpointStartupStatus, - ) -> EndpointObservation | None: - if not status.phase.expects_endpoint_presence: - return None - return type(self)(endpoint_port=self.port, startup_status=status) - - def retained_after_scan_miss( - self, - is_proven_live: Callable[[PongResponse], bool], - ) -> EndpointObservation | None: - del is_proven_live - return self - - -@dataclass(frozen=True, slots=True) -class EndpointObservationSnapshot: - """One immutable authority for every currently observable endpoint.""" - - observations: tuple[EndpointObservation, ...] = () - - @classmethod - def from_observations( - cls, - observations: Sequence[EndpointObservation], - ) -> EndpointObservationSnapshot: - """Canonicalize one observation per port into stable port order.""" - - by_port = {observation.port: observation for observation in observations} - return cls(tuple(by_port[port] for port in sorted(by_port))) - - @classmethod - def from_responses( - cls, - responses: Sequence[PongResponse], - ) -> EndpointObservationSnapshot: - """Canonicalize scan results into stable port order.""" - - return cls.from_observations( - tuple(ResponsiveEndpointObservation(response) for response in responses) - ) - - @property - def responses(self) -> tuple[PongResponse, ...]: - """Project responsive heartbeat payloads from the endpoint authority.""" - - return tuple( - response - for observation in self.observations - if (response := observation.response) is not None - ) - - @property - def startup_observations(self) -> tuple[StartingEndpointObservation, ...]: - """Project startup rows from the endpoint authority.""" - - return tuple( - startup_observation - for observation in self.observations - if (startup_observation := observation.startup_observation) is not None - ) - - def with_startup_status( - self, - port: int, - status: EndpointStartupStatus, - ) -> EndpointObservationSnapshot: - """Apply one newer startup event to this endpoint authority.""" - - by_port = {observation.port: observation for observation in self.observations} - current = by_port.get(port) - if current is None: - replacement = ( - StartingEndpointObservation(port, status) - if status.phase.expects_endpoint_presence - else None - ) - else: - replacement = current.after_startup_status(status) - if replacement is None: - by_port.pop(port, None) - else: - by_port[port] = replacement - return self.from_observations(tuple(by_port.values())) - - def retain_proven_live_from( - self, - previous: EndpointObservationSnapshot, - is_proven_live: Callable[[PongResponse], bool], - ) -> EndpointObservationSnapshot: - """Reconcile scan misses through each nominal observation's proof rule.""" - - observed_ports = {observation.port for observation in self.observations} - retained = tuple( - retained_observation - for observation in previous.observations - if observation.port not in observed_ports - if (retained_observation := observation.retained_after_scan_miss(is_proven_live)) - is not None - ) - return self.from_observations((*self.observations, *retained)) - - def status_for_port(self, port: int) -> EndpointStartupStatus: - """Derive endpoint presentation from this snapshot without stored flags.""" - - observation = next( - (observation for observation in self.observations if observation.port == port), - None, - ) - if observation is None: - return EndpointStartupStatus( - phase=EndpointStartupPhase.DISCONNECTED, - message="Not connected", - ) - return observation.status - - def removed_ports_since( - self, - previous: EndpointObservationSnapshot, - ) -> tuple[int, ...]: - """Return endpoints present in ``previous`` but absent from this snapshot.""" - - current_ports = {observation.port for observation in self.observations} - return tuple( - observation.port - for observation in previous.observations - if observation.port not in current_ports - ) - - -@dataclass(frozen=True, slots=True) -class EndpointObservationAuthority: - """One complete scan declaration and the exact snapshot it produced.""" - - scan_service: ZMQServerScanService - ports: tuple[int, ...] - snapshot: EndpointObservationSnapshot = EndpointObservationSnapshot() - - def with_snapshot( - self, - snapshot: EndpointObservationSnapshot, - ) -> EndpointObservationTransition: - """Commit a derived snapshot and project removals through its producer.""" - - return self._transition( - scan_service=self.scan_service, - ports=self.ports, - snapshot=snapshot, - ) - - def with_scan_declaration( - self, - scan_service: ZMQServerScanService, - ports: Sequence[int], - ) -> EndpointObservationTransition: - """Replace the complete declaration and retire its observations.""" - - return self._transition( - scan_service=scan_service, - ports=tuple(ports), - snapshot=EndpointObservationSnapshot(), - ) - - def _transition( - self, - *, - scan_service: ZMQServerScanService, - ports: tuple[int, ...], - snapshot: EndpointObservationSnapshot, - ) -> EndpointObservationTransition: - replacement = type(self)(scan_service, ports, snapshot) - if replacement == self: - return EndpointObservationTransition(self) - removed = tuple( - self.scan_service.endpoint(port) for port in snapshot.removed_ports_since(self.snapshot) - ) - return EndpointObservationTransition(replacement, removed) - - -@dataclass(frozen=True, slots=True) -class EndpointObservationTransition: - """One indivisible authority replacement and its retired endpoints.""" - - authority: EndpointObservationAuthority - terminated_endpoints: tuple[TransportEndpoint, ...] = () - - -@dataclass(frozen=True, slots=True) -class EndpointScanResult: - """One scan result tied to the immutable authority it began from.""" - - snapshot: EndpointObservationSnapshot - base_authority: EndpointObservationAuthority - - -@dataclass(frozen=True, slots=True, init=False) -class ZMQServerScanService: - """Scan server endpoints while preserving the canonical PONG type.""" - - config: ZMQConfig - host: str - transport_mode: TransportMode - timeout_ms: int - max_workers: int - - def __init__( - self, - *, - config: ZMQConfig, - host: str = "localhost", - transport_mode: TransportMode | None = None, - timeout_ms: int = 300, - max_workers: int = 10, - ) -> None: - object.__setattr__(self, "config", config) - object.__setattr__(self, "host", host) - object.__setattr__(self, "transport_mode", resolve_transport_mode(transport_mode)) - object.__setattr__(self, "timeout_ms", timeout_ms) - object.__setattr__(self, "max_workers", max_workers) - - def endpoint(self, port: int) -> TransportEndpoint: - """Resolve one endpoint through this scan declaration.""" - - return TransportEndpoint( - host=self.host, - port=port, - transport_mode=self.transport_mode, - ) - - def scan_ports( - self, - ports: Sequence[int], - *, - previous_snapshot: EndpointObservationSnapshot | None = None, - ) -> EndpointObservationSnapshot: - """Ping all provided ports in parallel.""" - - responses: list[PongResponse] = [] - with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: - futures = tuple(executor.submit(self.ping_server, port) for port in ports) - for future in concurrent.futures.as_completed(futures): - response = future.result() - if response is not None: - responses.append(response) - observed = EndpointObservationSnapshot.from_responses(responses) - if previous_snapshot is None: - return observed - return observed.retain_proven_live_from( - previous_snapshot, - self._is_proven_live, - ) - - def _is_proven_live(self, response: PongResponse) -> bool: - identity = response.process_identity - return ( - identity is not None - and self.transport_mode.declaration.endpoint_is_local( - self.host, - response.port, - ) - and identity.is_alive() is True - ) - - def ping_server(self, port: int) -> PongResponse | None: - """Return the authoritative typed heartbeat for one server.""" - - return request_control_ping( - port, - self.transport_mode, - host=self.host, - config=self.config, - timeout_ms=self.timeout_ms, - ) diff --git a/src/pyqt_reactive/strategies/__init__.py b/src/pyqt_reactive/strategies/__init__.py deleted file mode 100644 index 562d8c4b..00000000 --- a/src/pyqt_reactive/strategies/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Reusable presentation declarations and services.""" - -from .preview_formatting import ( - FormattingConfig, - ObjectStatePreviewFormattingService, - PreviewSegment, -) -from .status_presentation import ( - DefaultStatusPresentationStrategy, - StatusPresentationInput, - StatusPresentationResult, - StatusPresentationStrategyABC, -) -from .tree_aggregation import ( - ExplicitPercentTreeAggregationPolicy, - MeanTreeAggregationPolicy, - TreeAggregationPolicyABC, - TreeAggregationPolicyRegistry, -) - -__all__ = [ - "FormattingConfig", - "ObjectStatePreviewFormattingService", - "PreviewSegment", - "StatusPresentationInput", - "StatusPresentationResult", - "StatusPresentationStrategyABC", - "DefaultStatusPresentationStrategy", - "TreeAggregationPolicyABC", - "MeanTreeAggregationPolicy", - "ExplicitPercentTreeAggregationPolicy", - "TreeAggregationPolicyRegistry", -] diff --git a/src/pyqt_reactive/strategies/preview_formatting.py b/src/pyqt_reactive/strategies/preview_formatting.py deleted file mode 100644 index 08d1a262..00000000 --- a/src/pyqt_reactive/strategies/preview_formatting.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Declaration-driven formatting for manager list-item previews.""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, TypeAlias - -from objectstate import DottedFieldPath, ParameterOwner - -from pyqt_reactive.utils.preview_formatters import ( - PreviewFieldFormatRequest, - PreviewValueDetail, - canonical_declaration_mro, - format_preview_value, -) - -if TYPE_CHECKING: - from objectstate import ObjectState - -PreviewSegment: TypeAlias = tuple[str, str | None, str | None] -PreviewValueFormatter: TypeAlias = Callable[[PreviewFieldFormatRequest], str | None] -ItemValueFormatter: TypeAlias = Callable[[object], str | None] - - -def get_group_abbreviation(config_type: type) -> str: - """Resolve a group abbreviation from the canonical declaration MRO.""" - from objectstate.lazy_factory import GROUP_ABBREVIATIONS_REGISTRY - - for declaration_type in canonical_declaration_mro(config_type): - abbreviation = GROUP_ABBREVIATIONS_REGISTRY.get(declaration_type) - if abbreviation is not None: - return abbreviation - return config_type.__name__.split("_", 1)[0] - - -@dataclass(frozen=True, slots=True) -class FormattingConfig: - """List-preview presentation derived from declared fields and ObjectState. - - These rules affect only displayed text. Full values, editing, field-level - provenance and saved configuration remain unchanged. - """ - - wrap_lines: bool = False - """Wrap complete list items to panel width and grow rows; disable for horizontal scrolling.""" - - show_detail_line: bool = True - """Show the declared detail line, such as a plate's full input path.""" - - show_active_configs: bool = True - """Include enabled configurations and their declaration-owned always-viewable fields.""" - - show_modified_fields: bool = True - """Add fields differing from their signature defaults to the declared preview fields.""" - - show_group_labels: bool = True - """Show configuration group headings using each owner's declared abbreviation.""" - - collection_detail: PreviewValueDetail = PreviewValueDetail.COMPACT - """Summarize collections by count or expand contents; enum sequences keep readable names.""" - - max_value_length: int = 96 - """Maximum characters per value, with an ellipsis for longer values; zero shows all text.""" - - group_separator: str = field(default=" | ", metadata={"ui_hidden": True}) - """Text before each labelled configuration group after the first group.""" - - field_separator: str = field(default=", ", metadata={"ui_hidden": True}) - """Text between successive field values within one configuration group.""" - - closing_brace_separator: str = field(default="", metadata={"ui_hidden": True}) - """Text before the closing brace of each labelled configuration group.""" - - container_abbr_func: Callable[[type], str] = field( - default=get_group_abbreviation, metadata={"ui_hidden": True} - ) - """Function mapping a configuration declaration type to its preview heading. - - The default resolves the registered abbreviation through the declaration's - method resolution order, or uses its class name before the first underscore. - """ - - def __post_init__(self) -> None: - if self.max_value_length < 0: - raise ValueError("max_value_length must be non-negative") - - def format_value(self, value: object) -> str | None: - text = format_preview_value(value, self.collection_detail) - if text is not None and self.max_value_length and len(text) > self.max_value_length: - return text[: self.max_value_length - 1] + "…" - return text - - -@dataclass(frozen=True, slots=True) -class _PreviewField: - """One rendered field in a preview group.""" - - field_path: str - label: str - - -@dataclass(slots=True) -class _PreviewGroup: - """Fields sharing one authoritative ObjectState container path.""" - - container_path: DottedFieldPath - owner: ParameterOwner - fields: list[_PreviewField] = field(default_factory=list) - - def add(self, field_path: str, label: str) -> None: - """Append one field to this render-cycle projection.""" - self.fields.append(_PreviewField(field_path=field_path, label=label)) - - def heading(self, config: FormattingConfig) -> tuple[str, str | None]: - """Derive the heading from the ObjectState container path and owner.""" - if not self.container_path.parts: - return "root", None - if not isinstance(self.owner, type): - raise TypeError( - f"Preview container {self.container_path.value!r} requires a type declaration" - ) - return ( - config.container_abbr_func(self.owner), - self.container_path.value, - ) - - -class _PreviewSegmentBuilder: - """Group formatted fields without copying their declaration metadata.""" - - def __init__(self, formatting_config: FormattingConfig) -> None: - self.config = formatting_config - self._groups: dict[DottedFieldPath, _PreviewGroup] = {} - - def add_field( - self, - field_path: str, - label: str, - container_path: DottedFieldPath, - container_owner: ParameterOwner, - ) -> None: - """Add a formatted field to its declaration-owned presentation group.""" - group = self._groups.get(container_path) - if group is None: - group = _PreviewGroup( - container_path=container_path, - owner=container_owner, - ) - self._groups[container_path] = group - elif group.owner is not container_owner: - raise TypeError( - f"Preview container {container_path.value!r} has inconsistent declarations" - ) - group.add(field_path, label) - - def build(self) -> list[PreviewSegment]: - """Render groups in the order their first field was added.""" - segments: list[PreviewSegment] = [] - for index, group in enumerate(self._groups.values()): - segments.extend(self._render_group(group, is_first_group=index == 0)) - return segments - - def _render_group( - self, - group: _PreviewGroup, - *, - is_first_group: bool, - ) -> list[PreviewSegment]: - segments: list[PreviewSegment] = [] - if self.config.show_group_labels: - abbreviation, abbreviation_path = group.heading(self.config) - if is_first_group: - group_separator = "" - else: - group_separator = self.config.group_separator - segments.extend( - ( - (abbreviation, abbreviation_path, group_separator), - ("{", None, ""), - ) - ) - - for index, preview_field in enumerate(group.fields): - if index == 0: - if self.config.show_group_labels: - separator = "" - else: - separator = None - else: - separator = self.config.field_separator - segments.append((preview_field.label, preview_field.field_path, separator)) - - if self.config.show_group_labels: - segments.append(("}", None, self.config.closing_brace_separator)) - return segments - - -class ObjectStatePreviewFormattingService: - """Format resolved ObjectState values using their recorded declarations.""" - - def __init__(self, config: FormattingConfig) -> None: - self.config = config - - def collect_and_render( - self, - state: ObjectState | None, - field_paths: Sequence[str], - formatters: Mapping[str, ItemValueFormatter], - field_value_formatter: PreviewValueFormatter, - ) -> list[PreviewSegment]: - """Collect declared fields and return their rendered segments.""" - if state is None: - return [] - - builder = _PreviewSegmentBuilder(self.config) - for field_path in field_paths: - value = state.get_resolved_value(field_path) - if value is None: - continue - - field_owner = self._field_owner_for_path(state, field_path) - formatter = formatters.get(field_path) - if formatter is None: - label = field_value_formatter( - PreviewFieldFormatRequest( - field_path=field_path, - value=value, - field_owner=field_owner, - value_formatter=self.config.format_value, - ) - ) - else: - label = formatter(value) - if not label: - continue - - container_path = self._container_path(field_path) - builder.add_field( - field_path, - label, - container_path, - self._field_owner_for_path(state, container_path.value), - ) - return builder.build() - - @staticmethod - def _field_owner_for_path( - state: ObjectState, - field_path: str, - ) -> ParameterOwner: - declaration = state.type_for_path(field_path) - if not isinstance(declaration, type) and not callable(declaration): - raise TypeError( - f"Preview field {field_path!r} requires a nominal type or callable declaration" - ) - return declaration - - @staticmethod - def _container_path(field_path: str) -> DottedFieldPath: - if "." not in field_path: - return DottedFieldPath("") - return DottedFieldPath(field_path.rsplit(".", 1)[0]) diff --git a/src/pyqt_reactive/strategies/status_presentation.py b/src/pyqt_reactive/strategies/status_presentation.py deleted file mode 100644 index 20f1e83e..00000000 --- a/src/pyqt_reactive/strategies/status_presentation.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Status presentation strategy abstractions.""" -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, Optional - - -@dataclass(frozen=True) -class StatusPresentationInput: - """Typed input for status text presentation.""" - - message: str - context: Any = None - - -@dataclass(frozen=True) -class StatusPresentationResult: - """Rendered output from a status presentation strategy.""" - - text: str - color_hex: Optional[str] = None - - -class StatusPresentationStrategyABC(ABC): - """Abstract strategy for manager status text presentation.""" - - @abstractmethod - def present(self, status_input: StatusPresentationInput) -> StatusPresentationResult: - """Render status presentation output.""" - - -class DefaultStatusPresentationStrategy(StatusPresentationStrategyABC): - """Default passthrough status presentation implementation.""" - - def present(self, status_input: StatusPresentationInput) -> StatusPresentationResult: - return StatusPresentationResult(text=status_input.message, color_hex=None) diff --git a/src/pyqt_reactive/strategies/tree_aggregation.py b/src/pyqt_reactive/strategies/tree_aggregation.py deleted file mode 100644 index 11bee003..00000000 --- a/src/pyqt_reactive/strategies/tree_aggregation.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Recursive tree aggregation policies.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Mapping, Sequence - - -class TreeAggregationPolicyABC(ABC): - """Aggregation policy for a tree node.""" - - @abstractmethod - def aggregate(self, node_percent: float, children: Sequence[float]) -> float: - """Return aggregated percent for this node.""" - - -class MeanTreeAggregationPolicy(TreeAggregationPolicyABC): - """Arithmetic mean over children.""" - - def aggregate(self, node_percent: float, children: Sequence[float]) -> float: - if not children: - return 0.0 - return sum(children) / len(children) - - -class ExplicitPercentTreeAggregationPolicy(TreeAggregationPolicyABC): - """Use node-local percent directly.""" - - def aggregate(self, node_percent: float, children: Sequence[float]) -> float: - return node_percent - - -@dataclass(frozen=True) -class TreeAggregationPolicyRegistry: - """Typed policy registry with fail-loud lookups.""" - - policies: Mapping[str, TreeAggregationPolicyABC] - - def aggregate( - self, - policy_id: str, - node_percent: float, - children: Sequence[float], - ) -> float: - if policy_id not in self.policies: - raise ValueError(f"Unknown tree aggregation policy '{policy_id}'") - return self.policies[policy_id].aggregate(node_percent, children) diff --git a/src/pyqt_reactive/theming/style_generator.py b/src/pyqt_reactive/theming/style_generator.py index 23352d28..986735b1 100644 --- a/src/pyqt_reactive/theming/style_generator.py +++ b/src/pyqt_reactive/theming/style_generator.py @@ -12,7 +12,7 @@ from PyQt6.QtGui import QColor -from pyqt_reactive.forms import layout_constants +from formbridge.forms import layout_constants from .accent_chrome import AccentChromeColorPolicy from .color_scheme import ColorScheme diff --git a/src/pyqt_reactive/utils/__init__.py b/src/pyqt_reactive/utils/__init__.py index 2d8fe0d7..2586b237 100644 --- a/src/pyqt_reactive/utils/__init__.py +++ b/src/pyqt_reactive/utils/__init__.py @@ -1,25 +1 @@ -""" -Utility helpers for PyQt FormGen. -""" - -from .preview_formatters import ( - PreviewFieldAbbreviationResolution, - PreviewFieldFormatRequest, - PreviewLabelResolution, - canonical_declaration_mro, - check_enabled_field, - format_preview_value, - resolve_field_abbreviation, - resolve_preview_label, -) - -__all__ = [ - "canonical_declaration_mro", - "check_enabled_field", - "format_preview_value", - "PreviewFieldAbbreviationResolution", - "PreviewFieldFormatRequest", - "PreviewLabelResolution", - "resolve_field_abbreviation", - "resolve_preview_label", -] +"""Qt utility helpers.""" diff --git a/src/pyqt_reactive/utils/log_highlight_client.py b/src/pyqt_reactive/utils/log_highlight_client.py deleted file mode 100644 index c1f8b0bd..00000000 --- a/src/pyqt_reactive/utils/log_highlight_client.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Subprocess-backed log highlighting client.""" - -from __future__ import annotations - -import json -import subprocess -import sys -import threading -from dataclasses import dataclass - -from zmqruntime.process_launch import BackgroundProcessLaunchPolicy - - -@dataclass(frozen=True, slots=True) -class HighlightedSegmentDTO: - start: int - length: int - color: tuple[int, int, int] - bold: bool = False - - -class LogHighlightClient: - """Own one serial highlighting subprocess for one log delegate.""" - - def __init__(self) -> None: - self._proc: subprocess.Popen[str] | None = None - self._state_lock = threading.Lock() - self._io_lock = threading.Lock() - self._closed = False - - def _ensure_process(self) -> subprocess.Popen: - with self._state_lock: - if self._closed: - raise RuntimeError("Log highlight client is closed") - if self._proc and self._proc.poll() is None: - return self._proc - - launch_policy = BackgroundProcessLaunchPolicy.current() - self._proc = subprocess.Popen( - [ - launch_policy.python_executable(sys.executable), - "-m", - "pyqt_reactive.utils.log_highlighter", - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - **launch_policy.popen_arguments(), - ) - return self._proc - - def parse_line(self, text: str) -> list[HighlightedSegmentDTO] | None: - try: - with self._io_lock: - proc = self._ensure_process() - if not proc.stdin or not proc.stdout: - return None - - payload = json.dumps({"text": text}, ensure_ascii=True) - proc.stdin.write(payload + "\n") - proc.stdin.flush() - - line = proc.stdout.readline() - if not line: - return None - data = json.loads(line) - segments = [] - for seg in data.get("segments", []): - segments.append( - HighlightedSegmentDTO( - start=seg["start"], - length=seg["length"], - color=tuple(seg["color"]), - bold=seg.get("bold", False), - ) - ) - return segments - except Exception: - return None - - def shutdown(self) -> None: - """Close this client and interrupt an in-flight subprocess read.""" - - with self._state_lock: - if self._closed: - return - self._closed = True - proc = self._proc - self._proc = None - - if not proc: - return - try: - proc.terminate() - proc.wait(timeout=1) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - except ProcessLookupError: - pass diff --git a/src/pyqt_reactive/utils/log_highlighter.py b/src/pyqt_reactive/utils/log_highlighter.py deleted file mode 100644 index 21174670..00000000 --- a/src/pyqt_reactive/utils/log_highlighter.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Subprocess log line highlighter (JSONL).""" - -import json -import re -import sys - - -_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3}") -_LOG_LEVEL_RE = re.compile(r"\b(ERROR|WARNING|INFO|DEBUG|CRITICAL)\b") -_LOGGER_NAME_RE = re.compile(r" - ([\w\.]+) - ") -_FILE_PATH_RE = re.compile(r"(?:/[\w\-\.]+)+\.py") -_PYTHON_STRING_RE = re.compile(r"[\"'](?:[^\"'\\]|\\.)*[\"']") -_NUMBER_RE = re.compile(r"\b\d+(?:\.\d+)?\b") - - -def parse_line(text: str) -> list[dict]: - segments = [] - - match = _TIMESTAMP_RE.match(text) - if match: - segments.append({"start": match.start(), "length": match.end() - match.start(), "color": (105, 105, 105)}) - - for match in _LOG_LEVEL_RE.finditer(text): - level = match.group(1) - if level in ("ERROR", "CRITICAL"): - color = (255, 85, 85) - elif level == "WARNING": - color = (255, 140, 0) - else: - color = (100, 160, 210) - segments.append({"start": match.start(), "length": match.end() - match.start(), "color": color, "bold": True}) - - for match in _LOGGER_NAME_RE.finditer(text): - segments.append({"start": match.start(1), "length": match.end(1) - match.start(1), "color": (147, 112, 219)}) - - for match in _FILE_PATH_RE.finditer(text): - segments.append({"start": match.start(), "length": match.end() - match.start(), "color": (34, 139, 34)}) - - for match in _PYTHON_STRING_RE.finditer(text): - segments.append({"start": match.start(), "length": match.end() - match.start(), "color": (206, 145, 120)}) - - for match in _NUMBER_RE.finditer(text): - segments.append({"start": match.start(), "length": match.end() - match.start(), "color": (181, 206, 168)}) - - return segments - - -def _escape_html(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">") - - -def build_log_line_html(text: str) -> str: - """Render one line from the authoritative parsed highlight segments.""" - segments = sorted(parse_line(text), key=lambda segment: segment["start"]) - cursor = 0 - parts: list[str] = [''] - text_len = len(text) - - for segment in segments: - start = max(0, segment["start"]) - end = min(text_len, start + segment["length"]) - if end <= start or start < cursor: - continue - if start > cursor: - parts.append(_escape_html(text[cursor:start])) - - red, green, blue = segment["color"] - style = f"color: rgb({red},{green},{blue});" - if segment.get("bold"): - style += " font-weight: 700;" - parts.append( - f'{_escape_html(text[start:end])}' - ) - cursor = end - - if cursor < text_len: - parts.append(_escape_html(text[cursor:])) - - parts.append("") - return "".join(parts) - - -def main() -> int: - for line in sys.stdin: - try: - payload = json.loads(line) - text = payload.get("text", "") - segments = parse_line(text) - sys.stdout.write(json.dumps({"segments": segments}, ensure_ascii=True) + "\n") - sys.stdout.flush() - except Exception as exc: - sys.stderr.write(str(exc)) - sys.stderr.flush() - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/pyqt_reactive/utils/log_loader.py b/src/pyqt_reactive/utils/log_loader.py deleted file mode 100644 index 4ff85ddb..00000000 --- a/src/pyqt_reactive/utils/log_loader.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Log file loader for subprocess use.""" - -import sys -from pathlib import Path - - -def main() -> int: - if len(sys.argv) < 2: - sys.stderr.write("Usage: python -m pyqt_reactive.utils.log_loader \n") - return 2 - - log_path = Path(sys.argv[1]) - try: - with open(log_path, "r", encoding="utf-8", errors="replace") as handle: - sys.stdout.write(handle.read()) - return 0 - except Exception as exc: - sys.stderr.write(str(exc)) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/pyqt_reactive/utils/log_streamer.py b/src/pyqt_reactive/utils/log_streamer.py deleted file mode 100644 index 91896c50..00000000 --- a/src/pyqt_reactive/utils/log_streamer.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Stream log file lines as JSONL chunks for UI consumption.""" - -import argparse -import json -import sys -from collections import deque -from pathlib import Path - -from pyqt_reactive.utils.log_highlighter import build_log_line_html - -def emit(obj: dict) -> None: - sys.stdout.write(json.dumps(obj, ensure_ascii=True) + "\n") - sys.stdout.flush() - - -def tail_lines(path: Path, max_lines: int) -> list[str]: - if max_lines <= 0: - return [] - - buf: deque[str] = deque(maxlen=max_lines) - with open(path, "r", encoding="utf-8", errors="replace") as handle: - for line in handle: - buf.append(line.rstrip("\n")) - return list(buf) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("path") - parser.add_argument("--tail-lines", type=int, default=100_000) - parser.add_argument("--chunk-lines", type=int, default=1000) - parser.add_argument("--html", action="store_true") - args = parser.parse_args() - - log_path = Path(args.path) - try: - lines = tail_lines(log_path, args.tail_lines) - chunk: list = [] - for line in lines: - if args.html: - chunk.append({"text": line, "html": build_log_line_html(line)}) - else: - chunk.append(line) - if len(chunk) >= args.chunk_lines: - emit({"type": "chunk", "lines": chunk}) - chunk = [] - - if chunk: - emit({"type": "chunk", "lines": chunk}) - - emit({"type": "done"}) - return 0 - except Exception as exc: - sys.stderr.write(str(exc)) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/pyqt_reactive/utils/preview_formatters.py b/src/pyqt_reactive/utils/preview_formatters.py deleted file mode 100644 index b0a49b41..00000000 --- a/src/pyqt_reactive/utils/preview_formatters.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Generic preview formatting helpers.""" - -from collections.abc import Callable, Collection, Mapping, MutableSequence, Sequence, Set -from dataclasses import dataclass -from enum import Enum, StrEnum -from functools import singledispatch -from types import FunctionType, MethodType -from typing import cast - -from objectstate import ParameterOwner - - -@dataclass(frozen=True, slots=True) -class PreviewLabelResolution: - """Preview label together with the config declaration that owns it.""" - - owner: type - label: str - - -@dataclass(frozen=True, slots=True) -class PreviewFieldAbbreviationResolution: - """Field abbreviation together with the declaration that owns it.""" - - owner: type - abbreviation: str - - -def canonical_declaration_mro(declaration_type: type) -> tuple[type, ...]: - """Return the semantic declaration MRO, excluding generated lazy wrappers.""" - from objectstate.lazy_factory import get_base_type_for_lazy - - canonical_type = get_base_type_for_lazy(declaration_type) or declaration_type - return canonical_type.__mro__ - - -def resolve_preview_label(config: object) -> PreviewLabelResolution | None: - """Resolve preview metadata from the canonical config declaration MRO.""" - from objectstate.lazy_factory import PREVIEW_LABEL_REGISTRY - - for candidate in canonical_declaration_mro(type(config)): - label = PREVIEW_LABEL_REGISTRY.get(candidate) - if label is not None: - return PreviewLabelResolution(owner=candidate, label=label) - return None - - -def resolve_field_abbreviation( - declaration: ParameterOwner, - field_name: str, -) -> PreviewFieldAbbreviationResolution | None: - """Resolve a field abbreviation only from its canonical declaration MRO.""" - from objectstate.lazy_factory import FIELD_ABBREVIATIONS_REGISTRY - - if not isinstance(declaration, type): - return None - - for candidate in canonical_declaration_mro(declaration): - abbreviation = FIELD_ABBREVIATIONS_REGISTRY.get(candidate, {}).get(field_name) - if abbreviation is not None: - return PreviewFieldAbbreviationResolution( - owner=candidate, - abbreviation=abbreviation, - ) - return None - - -def check_enabled_field(config: object) -> bool: - """Return the nominal ``Enableable`` state of a config declaration.""" - from python_introspect import Enableable - - if not issubclass(type(config), Enableable): - return True - return cast(Enableable, config).enabled - - -class PreviewValueDetail(StrEnum): - """Collection display modes carry their own leaf formatting behavior.""" - - COMPACT = ("compact", lambda values: f"[{len(values)}]") - EXPANDED = ("expanded", repr) - - def __new__(cls, value: str, formatter: Callable[[Collection], str]): - member = str.__new__(cls, value) - member._value_ = value - member._formatter = formatter - return member - - def format_collection(self, value: Collection) -> str: - return self._formatter(value) - - -@singledispatch -def format_preview_value( - value: object, detail: PreviewValueDetail = PreviewValueDetail.COMPACT -) -> str | None: - """Project display values by nominal type, without knowing config field names.""" - if callable(value) and not isinstance(value, type): - return type(value).__name__ - return str(value) - - -@format_preview_value.register(type(None)) -def _format_absent(value, detail=PreviewValueDetail.COMPACT) -> None: - return None - - -@format_preview_value.register(Enum) -def _format_enum(value: Enum, detail=PreviewValueDetail.COMPACT) -> str | None: - return value.name if value.value is not None else None - - -@format_preview_value.register(FunctionType) -@format_preview_value.register(MethodType) -def _format_function(value, detail=PreviewValueDetail.COMPACT) -> str: - return value.__name__ - - -@format_preview_value.register(Mapping) -@format_preview_value.register(Set) -def _format_collection(value: Collection, detail=PreviewValueDetail.COMPACT) -> str | None: - return detail.format_collection(value) if value else None - - -@format_preview_value.register(MutableSequence) -@format_preview_value.register(tuple) -def _format_sequence(value: Sequence, detail=PreviewValueDetail.COMPACT) -> str | None: - if value and all(isinstance(element, Enum) for element in value): - return ",".join(str(element.value) for element in value) - return _format_collection(value, detail) - - -@dataclass(frozen=True, slots=True) -class PreviewFieldFormatRequest: - """One resolved value together with its ObjectState-recorded declaration. - - Standalone requests use the generic value formatter; a configured preview - service supplies its declaration-owned display policy. - """ - - field_path: str - value: object - field_owner: ParameterOwner - value_formatter: Callable[[object], str | None] = format_preview_value - - @property - def field_name(self) -> str: - """Return the leaf field name declared by ``field_owner``.""" - return self.field_path.rsplit(".", 1)[-1] diff --git a/src/pyqt_reactive/utils/styling_utils.py b/src/pyqt_reactive/utils/styling_utils.py deleted file mode 100644 index cb0438fc..00000000 --- a/src/pyqt_reactive/utils/styling_utils.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Shared utilities for field styling indicators (* and _).""" - - -def get_field_indicators(state, field_id, param_name): - """Returns (has_star, has_underline) for a field.""" - path = f'{field_id}.{param_name}' if field_id else param_name - return (path in state.dirty_fields, path in state.signature_diff_fields) - - -def update_reset_button_styling(button, state, field_id, field_name): - """Apply * and _ styling to a reset button. - - Args: - button: QPushButton to style - state: ObjectState instance - field_id: Manager's field_id - field_name: Name of the field (e.g., 'enabled') - """ - has_star, has_underline = get_field_indicators(state, field_id, field_name) - - # Update text with * prefix - text = "Reset" - if has_star: - text = "*" + text - button.setText(text) - - # Apply underline via font (doesn't affect stylesheet) - font = button.font() - font.setUnderline(has_underline) - button.setFont(font) \ No newline at end of file diff --git a/src/pyqt_reactive/weak_events.py b/src/pyqt_reactive/weak_events.py deleted file mode 100644 index 0bb3b6fa..00000000 --- a/src/pyqt_reactive/weak_events.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Weak callback notifications with exact, non-owning cancellation handles. - -This module does not import Qt. A QObject can connect ``destroyed`` to a -subscription's ``cancel`` without its cleanup callback retaining that QObject. -""" - -from __future__ import annotations - -import threading -from collections.abc import Callable -from dataclasses import dataclass -from types import MethodType -from typing import Generic, ParamSpec -from weakref import ReferenceType, WeakMethod, ref - -P = ParamSpec("P") - - -@dataclass(frozen=True, eq=False) -class WeakSubscription(Generic[P]): - """Exact registration identity, owning neither callback nor event lifetime.""" - - event_reference: ReferenceType[WeakCallbackEvent[P]] - callback_reference: ReferenceType[Callable[P, None]] - - def cancel(self) -> None: - """Cancel only this registration, including after its callback dies.""" - event = self.event_reference() - if event is not None: - event._cancel_subscription(self) - - -class WeakCallbackEvent(Generic[P]): - """Thread-safe weak event with an immutable emission-start snapshot. - - Subscribers must own their callbacks elsewhere. Repeated registration of - the same callback returns the same token. Cancellation is idempotent and - cannot cancel a later registration of that callback. Changes during an - emission take effect on the next emission, not its existing snapshot. - """ - - def __init__(self) -> None: - self._subscribers: list[WeakSubscription[P]] = [] - self._lock = threading.RLock() - - @staticmethod - def _is_same_callback(left: Callable[P, None], right: Callable[P, None]) -> bool: - if isinstance(left, MethodType) and isinstance(right, MethodType): - return left.__self__ is right.__self__ and left.__func__ is right.__func__ - return left is right - - def subscribe(self, callback: Callable[P, None]) -> WeakSubscription[P]: - """Observe a callback and return its non-owning cancellation token.""" - with self._lock: - live_subscriptions: list[WeakSubscription[P]] = [] - existing: WeakSubscription[P] | None = None - for subscription in self._subscribers: - subscriber = subscription.callback_reference() - if subscriber is None: - continue - live_subscriptions.append(subscription) - if self._is_same_callback(subscriber, callback): - existing = subscription - if existing is None: - existing = WeakSubscription( - event_reference=ref(self), - callback_reference=( - WeakMethod(callback) if isinstance(callback, MethodType) else ref(callback) - ), - ) - live_subscriptions.append(existing) - self._subscribers = live_subscriptions - return existing - - def _cancel_subscription(self, cancelled: WeakSubscription[P]) -> None: - with self._lock: - self._subscribers = [ - subscription - for subscription in self._subscribers - if subscription is not cancelled and subscription.callback_reference() is not None - ] - - def unsubscribe(self, callback: Callable[P, None]) -> None: - """Cancel the current registration of one exact callback identity.""" - with self._lock: - self._subscribers = [ - subscription - for subscription in self._subscribers - if (subscriber := subscription.callback_reference()) is not None - and not self._is_same_callback(subscriber, callback) - ] - - def emit(self, *args: P.args, **kwargs: P.kwargs) -> None: - """Notify live callbacks captured at emission start.""" - with self._lock: - live_subscriptions: list[WeakSubscription[P]] = [] - subscribers: list[Callable[P, None]] = [] - for subscription in self._subscribers: - subscriber = subscription.callback_reference() - if subscriber is None: - continue - live_subscriptions.append(subscription) - subscribers.append(subscriber) - self._subscribers = live_subscriptions - for subscriber in subscribers: - subscriber(*args, **kwargs) diff --git a/src/pyqt_reactive/widgets/editors/simple_code_editor.py b/src/pyqt_reactive/widgets/editors/simple_code_editor.py index cbe61127..30451685 100644 --- a/src/pyqt_reactive/widgets/editors/simple_code_editor.py +++ b/src/pyqt_reactive/widgets/editors/simple_code_editor.py @@ -27,7 +27,7 @@ QVBoxLayout, ) -from pyqt_reactive.services.window_code_document import ( +from formbridge.services.window_code_document import ( PYTHON_MIME_TYPE, WindowCodeDocument, WindowCodeDocumentDriver, @@ -640,7 +640,7 @@ def _show_jedi_completions(self): # Create Jedi script with current code # Use project parameter to tell Jedi where to find project modules # Use configured project roots for Jedi (defaults to cwd) - from pyqt_reactive.protocols import get_form_config + from formbridge.protocols.form_config import get_form_config config = get_form_config() project_paths = [Path(p) for p in getattr(config, "jedi_project_paths", []) if p] if not project_paths: @@ -822,7 +822,7 @@ def _new_file(self): def _open_file(self): """Open file dialog and load content.""" - from pyqt_reactive.core.path_cache import CodeEditor, cache_path, get_initial_path + from formbridge.core.path_cache import CodeEditor, cache_path, get_initial_path # Get cached initial directory initial_dir = str(get_initial_path(CodeEditor, fallback=Path.home())) @@ -844,7 +844,7 @@ def _open_file(self): def _save_as(self): """Save content to file.""" - from pyqt_reactive.core.path_cache import CodeEditor, cache_path, get_initial_path + from formbridge.core.path_cache import CodeEditor, cache_path, get_initial_path # Get cached initial directory initial_dir = str(get_initial_path(CodeEditor, fallback=Path.home())) @@ -893,7 +893,7 @@ def _toggle_clean_mode(self, checked): """Toggle between clean mode (minimal) and explicit mode (full).""" try: current_code = self.editor.text() - from pyqt_reactive.protocols import get_codegen_provider + from formbridge.protocols.codegen_provider import get_codegen_provider provider = get_codegen_provider() if provider is None: from PyQt6.QtWidgets import QMessageBox diff --git a/src/pyqt_reactive/widgets/enhanced_path_widget.py b/src/pyqt_reactive/widgets/enhanced_path_widget.py index 815c4c5c..ec507541 100644 --- a/src/pyqt_reactive/widgets/enhanced_path_widget.py +++ b/src/pyqt_reactive/widgets/enhanced_path_widget.py @@ -17,7 +17,7 @@ from PyQt6.QtWidgets import QFileDialog, QHBoxLayout, QLineEdit, QPushButton, QWidget from python_introspect import ParameterInfo -from pyqt_reactive.core.path_cache import ( +from formbridge.core.path_cache import ( DirectorySelection, FileSelection, General, diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index e6445ae7..32cf3d7d 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -16,21 +16,21 @@ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QScrollArea from PyQt6.QtCore import Qt, pyqtSignal -from pyqt_reactive.protocols import ( +from formbridge.protocols.component_selection import ( get_component_selection_provider, get_function_selection_provider, ) -from pyqt_reactive.services.pattern_data_manager import PatternDataManager -from pyqt_reactive.services.pattern_data_manager import FUNC_EDITOR_SELECTED_PATTERN_KEY_META_KEY -from pyqt_reactive.services.pattern_data_manager import FUNC_EDITOR_PATTERN_TOKENS_META_KEY -from pyqt_reactive.services.function_navigation import ( +from formbridge.services.pattern_data_manager import PatternDataManager +from formbridge.services.pattern_data_manager import FUNC_EDITOR_SELECTED_PATTERN_KEY_META_KEY +from formbridge.services.pattern_data_manager import FUNC_EDITOR_PATTERN_TOKENS_META_KEY +from formbridge.services.function_navigation import ( FunctionPatternField, parse_function_field_target, ) from pyqt_reactive.services.function_list_editor_actions import ( FunctionListEditorAction, ) -from pyqt_reactive.services.function_pattern_code_document import ( +from formbridge.services.function_pattern_code_document import ( FunctionAuthority, FunctionKwargs, FunctionPatternByKey, @@ -44,7 +44,7 @@ from pyqt_reactive.widgets.function_pane import FunctionPaneWidget from objectstate import ObjectStateRegistry, semantic_values_equal from pyqt_reactive.theming import ColorScheme, WidgetTheme -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.widgets.shared.detachable_action_bar import ( DetachableActionBar, DetachableActionBarHost, @@ -1078,7 +1078,7 @@ def edit_function_code(self): editor_service = SimpleCodeEditorService(self) - from pyqt_reactive.services.window_code_document import use_external_code_editor + from formbridge.services.window_code_document import use_external_code_editor use_external = use_external_code_editor() diff --git a/src/pyqt_reactive/widgets/function_pane.py b/src/pyqt_reactive/widgets/function_pane.py index 5a4bd7a5..cecd9813 100644 --- a/src/pyqt_reactive/widgets/function_pane.py +++ b/src/pyqt_reactive/widgets/function_pane.py @@ -30,13 +30,11 @@ ReflowingVerticalScrollArea, ) from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT -from pyqt_reactive.forms.parameter_value_contracts import ParameterValue +from formbridge.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.parameter_value_contracts import ParameterValue from pyqt_reactive.forms.widget_strategies import PyQt6WidgetEnhancer -from pyqt_reactive.services.function_pattern_code_document import ( - FunctionPatternCodeDocumentService, -) -from pyqt_reactive.services.function_navigation import FunctionPatternField +from formbridge.services.function_pattern_code_document import FunctionPatternCodeDocumentService +from formbridge.services.function_navigation import FunctionPatternField from metaclass_registry import AutoRegisterMeta from objectstate import ScopeKey @@ -704,7 +702,7 @@ def _reconstruct_kwargs_from_state(self, state: "ObjectState") -> dict: Returns: Dict of kwargs suitable for passing to function at runtime """ - from pyqt_reactive.services.function_pattern_code_document import ( + from formbridge.services.function_pattern_code_document import ( FunctionPatternCodeDocumentService, ) diff --git a/src/pyqt_reactive/widgets/help_document_browser.py b/src/pyqt_reactive/widgets/help_document_browser.py index 8e53fdb3..1704cc23 100644 --- a/src/pyqt_reactive/widgets/help_document_browser.py +++ b/src/pyqt_reactive/widgets/help_document_browser.py @@ -9,7 +9,7 @@ from PyQt6.QtGui import QBrush, QTextCharFormat, QTextCursor, QTextDocument, QTextOption from PyQt6.QtWidgets import QTextBrowser -from pyqt_reactive.services.help_document import HelpDocument, HelpDocumentFormat +from formbridge.services.help_document import HelpDocument, HelpDocumentFormat from pyqt_reactive.theming import ColorScheme HELP_DOCUMENT_PADDING = 8 diff --git a/src/pyqt_reactive/widgets/log_viewer.py b/src/pyqt_reactive/widgets/log_viewer.py index b340eb10..bbe64096 100644 --- a/src/pyqt_reactive/widgets/log_viewer.py +++ b/src/pyqt_reactive/widgets/log_viewer.py @@ -61,14 +61,14 @@ QWidget, ) -from pyqt_reactive.core.log_utils import LogFileInfo, LogType -from pyqt_reactive.protocols import ( +from formbridge.core.log_utils import LogFileInfo, LogType +from formbridge.protocols.log_providers import ( LogDiscoveryProviderABC, get_log_discovery_provider, get_server_scan_provider, ) -from pyqt_reactive.services.coalesced_scan_requests import CoalescedScanRequests -from pyqt_reactive.services.process_tracker import ( +from formbridge.services.coalesced_scan_requests import CoalescedScanRequests +from formbridge.services.process_tracker import ( ProcessLiveness, ProcessTracker, extract_pid_from_log_filename, @@ -76,8 +76,8 @@ get_log_tooltip, ) from pyqt_reactive.theming.themed_checkbox import ThemedCheckBox -from pyqt_reactive.utils.log_highlight_client import LogHighlightClient -from pyqt_reactive.utils.log_highlighter import build_log_line_html +from formbridge.utils.log_highlight_client import LogHighlightClient +from formbridge.utils.log_highlighter import build_log_line_html logger = logging.getLogger(__name__) diff --git a/src/pyqt_reactive/widgets/shared/__init__.py b/src/pyqt_reactive/widgets/shared/__init__.py index ffda1409..77340f70 100644 --- a/src/pyqt_reactive/widgets/shared/__init__.py +++ b/src/pyqt_reactive/widgets/shared/__init__.py @@ -35,10 +35,6 @@ "pyqt_reactive.widgets.shared.action_tabbed_window_body", "ActionTabbedWindowBody", ), - "TabLabelDeclarationMixin": ( - "pyqt_reactive.services.tab_identity", - "TabLabelDeclarationMixin", - ), "BaseManagedWindow": ( "pyqt_reactive.widgets.shared.base_form_dialog", "BaseManagedWindow", @@ -151,10 +147,6 @@ "pyqt_reactive.widgets.shared.tree_state_adapter", "TypedPayloadTreeItemKeyBuilder", ), - "TreeItemKeyProviderABC": ( - "pyqt_reactive.services.tree_item_key", - "TreeItemKeyProviderABC", - ), "TreeStateAdapter": ( "pyqt_reactive.widgets.shared.tree_state_adapter", "TreeStateAdapter", @@ -163,10 +155,6 @@ "pyqt_reactive.widgets.shared.tree_rebuild_coordinator", "TreeRebuildCoordinator", ), - "ScopeColorSchemeReceiver": ( - "pyqt_reactive.widgets.shared.scope_color_receiver", - "ScopeColorSchemeReceiver", - ), "ScopedTableWidget": ( "pyqt_reactive.widgets.shared.scoped_table_widget", "ScopedTableWidget", diff --git a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py index 72c4565a..ffb7f524 100644 --- a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py +++ b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py @@ -18,7 +18,7 @@ import logging -from pyqt_reactive.widgets.shared.manager_workflows import ( +from formbridge.managers.workflows import ( ManagerCodeExecutionWorkflow, ManagerDeletionWorkflow, NullManagerCodeExecutionWorkflow, @@ -38,24 +38,22 @@ from pyqt_reactive.widgets.mixins import ( CrossWindowPreviewMixin, ) -from pyqt_reactive.strategies.preview_formatting import ( +from formbridge.strategies.preview_formatting import ( FormattingConfig, ObjectStatePreviewFormattingService, ) from objectstate import LiveContextResolver from pyqt_reactive.animation import FlashMixin from pyqt_reactive.widgets.shared.manager_ui_scaffold import setup_manager_widget_ui -from pyqt_reactive.widgets.shared.manager_item_hooks import ( - ManagerItemHooks, -) +from formbridge.managers.item_hooks import ManagerItemHooks from pyqt_reactive.widgets.shared.manager_item_access import ManagerItemAccess -from pyqt_reactive.widgets.shared.manager_preview_formatting import ManagerPreviewFieldFormatter -from pyqt_reactive.widgets.shared.manager_state_binding import ManagerStateBinding +from formbridge.managers.preview_formatting import ManagerPreviewFieldFormatter +from formbridge.managers.state_binding import ManagerStateBinding from pyqt_reactive.widgets.shared.manager_list_updater import ( ManagerListUpdater, ManagerListUpdateOperations, ) -from pyqt_reactive.widgets.shared.manager_time_travel_binding import ManagerTimeTravelBinding +from formbridge.managers.time_travel_binding import ManagerTimeTravelBinding from pyqt_reactive.widgets.shared.manager_action_controller import ( CodeEditorPayload, ManagerActionHandler, @@ -74,17 +72,14 @@ ManagerSelectionOperations, SelectionPayloadProjection, ) -from pyqt_reactive.widgets.shared.manager_reorder_controller import ( +from formbridge.managers.reorder_controller import ( ManagerReorderController, ManagerReorderOperations, ) from pyqt_reactive.widgets.shared.manager_status_controller import ManagerStatusController -from pyqt_reactive.services.service_registry import AutoRegisterServiceMixin +from formbridge.services.service_registry import AutoRegisterServiceMixin from pyqt_reactive.services.window_navigation import ListItemWindowNavigationDriver -from pyqt_reactive.services.window_code_document import ( - WindowCodeDocument, - WindowCodeDocumentDriver, -) +from formbridge.services.window_code_document import WindowCodeDocument, WindowCodeDocumentDriver from pyqt_reactive.widgets.shared.scope_visual_config import ListItemType # Backwards compat alias @@ -674,7 +669,7 @@ def __init__(self, service_adapter, color_scheme=None, parent=None): # CRITICAL: Manually trigger AutoRegisterServiceMixin registration # Qt's C++ classes don't call super().__init__(), so we need to do it explicitly - from pyqt_reactive.services.service_registry import AutoRegisterServiceMixin + from formbridge.services.service_registry import AutoRegisterServiceMixin if isinstance(self, AutoRegisterServiceMixin): self._register_with_service_registry() diff --git a/src/pyqt_reactive/widgets/shared/abstract_table_browser.py b/src/pyqt_reactive/widgets/shared/abstract_table_browser.py index 756d54ef..7a46e635 100644 --- a/src/pyqt_reactive/widgets/shared/abstract_table_browser.py +++ b/src/pyqt_reactive/widgets/shared/abstract_table_browser.py @@ -25,7 +25,7 @@ QWidget, ) -from pyqt_reactive.services.search_service import SearchService +from formbridge.services.search_service import SearchService from pyqt_reactive.theming import ColorScheme T = TypeVar('T') diff --git a/src/pyqt_reactive/widgets/shared/action_tabbed_window_body.py b/src/pyqt_reactive/widgets/shared/action_tabbed_window_body.py index 2d2c3829..16b18455 100644 --- a/src/pyqt_reactive/widgets/shared/action_tabbed_window_body.py +++ b/src/pyqt_reactive/widgets/shared/action_tabbed_window_body.py @@ -15,10 +15,7 @@ QWidget, ) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT -from pyqt_reactive.services.tab_identity import ( - TabLabelDeclarationMixin as TabLabelDeclarationMixin, -) +from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.widgets.shared.responsive_layout_widgets import ResponsiveTwoRowWidget diff --git a/src/pyqt_reactive/widgets/shared/base_form_dialog.py b/src/pyqt_reactive/widgets/shared/base_form_dialog.py index 34a3e625..a5ca748b 100644 --- a/src/pyqt_reactive/widgets/shared/base_form_dialog.py +++ b/src/pyqt_reactive/widgets/shared/base_form_dialog.py @@ -19,7 +19,7 @@ NullWindowNavigationDriver, WindowNavigationDriver, ) -from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver +from formbridge.services.window_code_document import WindowCodeDocumentDriver from pyqt_reactive.widgets.shared.dirty_window_presenter import ( DirtyWindowPresentation, DirtyWindowPresenter, diff --git a/src/pyqt_reactive/widgets/shared/base_managed_window.py b/src/pyqt_reactive/widgets/shared/base_managed_window.py index c777af1c..f2cdaf9e 100644 --- a/src/pyqt_reactive/widgets/shared/base_managed_window.py +++ b/src/pyqt_reactive/widgets/shared/base_managed_window.py @@ -14,7 +14,7 @@ NullWindowNavigationDriver, WindowNavigationDriver, ) -from pyqt_reactive.services.window_code_document import WindowCodeDocumentDriver +from formbridge.services.window_code_document import WindowCodeDocumentDriver logger = logging.getLogger(__name__) diff --git a/src/pyqt_reactive/widgets/shared/clickable_help_components.py b/src/pyqt_reactive/widgets/shared/clickable_help_components.py index 69e18c27..0384304f 100644 --- a/src/pyqt_reactive/widgets/shared/clickable_help_components.py +++ b/src/pyqt_reactive/widgets/shared/clickable_help_components.py @@ -22,7 +22,7 @@ from pyqt_reactive.animation import FlashMixin from pyqt_reactive.animation.flash_mixin import LEAF_WIDGET_TYPES, resolve_mask_widgets -from pyqt_reactive.forms import layout_constants +from formbridge.forms import layout_constants from pyqt_reactive.protocols import ( ChangeSignalEmitter, ChildFieldChromeRefreshable, @@ -43,7 +43,7 @@ from pyqt_reactive.theming import AccentChromeColorPolicy, ColorScheme from pyqt_reactive.protocols.widget_protocols import FlashMaskRectProvider from pyqt_reactive.widgets.shared.scope_border_renderer import ScopeBorderRenderer -from pyqt_reactive.widgets.shared.scope_color_receiver import ScopeColorSchemeReceiver +from formbridge.theming.scope_color_receiver import ScopeColorSchemeReceiver from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme logger = logging.getLogger(__name__) @@ -921,7 +921,7 @@ def __init__(self, title: str, help_target: Union[Callable, type] = None, # Create main layout and add title widget at top # Use CURRENT_LAYOUT as single source of truth for margins/spacing - from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT + from formbridge.forms.layout_constants import CURRENT_LAYOUT main_layout = QVBoxLayout(self) main_layout.setContentsMargins(*CURRENT_LAYOUT.groupbox_margins) main_layout.setSpacing(CURRENT_LAYOUT.groupbox_spacing) @@ -1068,7 +1068,7 @@ def paintEvent(self, event) -> None: def _paint_scope_background(self, layers) -> None: """Paint subtle scope-colored background (matching list item style).""" from pyqt_reactive.animation import get_widget_corner_radius - from pyqt_reactive.forms.layout_constants import default_container_corner_radius_px + from formbridge.forms.layout_constants import default_container_corner_radius_px from pyqt_reactive.widgets.shared.scope_visual_config import ScopeVisualConfig # Get border rect (accounts for margin-top offset) @@ -1139,7 +1139,7 @@ def _paint_border_layers(self, layers) -> None: """ rect = self._get_border_rect() from pyqt_reactive.animation import get_widget_corner_radius - from pyqt_reactive.forms.layout_constants import default_container_corner_radius_px + from formbridge.forms.layout_constants import default_container_corner_radius_px radius = get_widget_corner_radius(self) if radius == 0: radius = default_container_corner_radius_px() diff --git a/src/pyqt_reactive/widgets/shared/column_filter_widget.py b/src/pyqt_reactive/widgets/shared/column_filter_widget.py index f3e1fad6..d2fe6bb5 100644 --- a/src/pyqt_reactive/widgets/shared/column_filter_widget.py +++ b/src/pyqt_reactive/widgets/shared/column_filter_widget.py @@ -27,7 +27,7 @@ QWidget, ) -from pyqt_reactive.forms.layout_constants import COMPACT_LAYOUT +from formbridge.forms.layout_constants import COMPACT_LAYOUT from pyqt_reactive.theming import ColorScheme from pyqt_reactive.theming.themed_checkbox import ThemedCheckBox from pyqt_reactive.widgets.shared.abstract_table_browser import ( diff --git a/src/pyqt_reactive/widgets/shared/manager_action_controller.py b/src/pyqt_reactive/widgets/shared/manager_action_controller.py index adae853c..821e97b9 100644 --- a/src/pyqt_reactive/widgets/shared/manager_action_controller.py +++ b/src/pyqt_reactive/widgets/shared/manager_action_controller.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field from typing import Any, Generic, Optional, TypeAlias, TypeVar -from pyqt_reactive.services.window_code_document import use_external_code_editor +from formbridge.services.window_code_document import use_external_code_editor logger = logging.getLogger(__name__) diff --git a/src/pyqt_reactive/widgets/shared/manager_item_access.py b/src/pyqt_reactive/widgets/shared/manager_item_access.py index f075cd2a..a1347589 100644 --- a/src/pyqt_reactive/widgets/shared/manager_item_access.py +++ b/src/pyqt_reactive/widgets/shared/manager_item_access.py @@ -8,8 +8,8 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QListWidgetItem -from pyqt_reactive.widgets.shared.manager_item_hooks import ManagerItemHooks -from pyqt_reactive.widgets.shared.manager_state_binding import ManagerStateBinding +from formbridge.managers.item_hooks import ManagerItemHooks +from formbridge.managers.state_binding import ManagerStateBinding @dataclass(frozen=True, slots=True) diff --git a/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py b/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py index 55746754..c7f7a7b9 100644 --- a/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py +++ b/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py @@ -10,11 +10,11 @@ from objectstate.lazy_factory import ALWAYS_VIEWABLE_FIELDS_REGISTRY from python_introspect import Enableable, is_enableable -from pyqt_reactive.strategies.preview_formatting import ( +from formbridge.strategies.preview_formatting import ( ObjectStatePreviewFormattingService, PreviewSegment, ) -from pyqt_reactive.utils.preview_formatters import ( +from formbridge.utils.preview_formatters import ( PreviewFieldFormatRequest, canonical_declaration_mro, ) diff --git a/src/pyqt_reactive/widgets/shared/manager_item_hooks.py b/src/pyqt_reactive/widgets/shared/manager_item_hooks.py deleted file mode 100644 index 118d725a..00000000 --- a/src/pyqt_reactive/widgets/shared/manager_item_hooks.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Typed list-item hook declarations for manager widgets.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping -from dataclasses import dataclass, field -from operator import attrgetter -from typing import Any - - -class ItemIdProjection(ABC): - """Projects a stable selection id from a manager backing item.""" - - @abstractmethod - def __call__(self, item: Any) -> str: - ... - - -@dataclass(frozen=True, slots=True) -class DictItemIdProjection(ItemIdProjection): - """Project an item id from a mapping key.""" - - key: str - - def __call__(self, item: Any) -> str: - if not isinstance(item, Mapping): - raise TypeError( - f"Dict item-id projection expects Mapping, got {type(item).__name__}." - ) - return str(item[self.key]) - - -@dataclass(frozen=True, slots=True) -class AttributeItemIdProjection(ItemIdProjection): - """Project an item id from an object attribute path.""" - - path: str - _getter: Callable[[Any], Any] = field(init=False, repr=False) - - def __post_init__(self) -> None: - object.__setattr__(self, "_getter", attrgetter(self.path)) - - def __call__(self, item: Any) -> str: - return str(self._getter(item)) - - -@dataclass(frozen=True, slots=True) -class ManagerItemHooks: - """Typed source for list-item behavior consumed by AbstractManagerWidget.""" - - id_projection: ItemIdProjection = field( - default_factory=lambda: AttributeItemIdProjection("id") - ) - preserve_selection_pred: Callable[[Any], bool] = lambda _manager: False - - def item_id(self, item: Any) -> str: - return self.id_projection(item) - - def should_preserve_selection(self, manager: Any) -> bool: - return bool(self.preserve_selection_pred(manager)) - - def list_item_data_for(self, item: Any, index: int) -> Any: - """Return the stable, transport-safe identity stored in ``UserRole``.""" - - del index - return self.item_id(item) - - def item_from_list_data(self, data: Any, items: list[Any]) -> Any: - """Resolve one stable row identity against the authoritative backing list.""" - - if data is None: - return None - item_id = str(data) - return next( - (item for item in items if self.item_id(item) == item_id), - None, - ) diff --git a/src/pyqt_reactive/widgets/shared/manager_preview_formatting.py b/src/pyqt_reactive/widgets/shared/manager_preview_formatting.py deleted file mode 100644 index acf453ce..00000000 --- a/src/pyqt_reactive/widgets/shared/manager_preview_formatting.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Preview field formatting for manager list items.""" - -from dataclasses import is_dataclass - -from pyqt_reactive.utils.preview_formatters import ( - PreviewFieldFormatRequest, - check_enabled_field, - resolve_field_abbreviation, - resolve_preview_label, -) - - -class ManagerPreviewFieldFormatter: - """Formats field values for AbstractManagerWidget preview segments.""" - - def format_field(self, request: PreviewFieldFormatRequest) -> str | None: - """Format a value using metadata from its actual declaring type.""" - if request.value is None: - return None - - if is_dataclass(request.value) and not isinstance(request.value, type): - return self._format_dataclass_value(request) - - formatted = request.value_formatter(request.value) - if formatted is None: - return None - abbreviation = resolve_field_abbreviation( - request.field_owner, - request.field_name, - ) - abbrev = abbreviation.abbreviation if abbreviation is not None else request.field_name - return f"{abbrev}:{formatted}" - - def _format_dataclass_value(self, request: PreviewFieldFormatRequest) -> str | None: - from pyqt_reactive.protocols import PreviewFormatterRegistry - - formatted = PreviewFormatterRegistry.format_field(request.value, request.field_name) - if formatted is None: - formatted = self._preview_label_for_config(request.value) - return formatted - - def _preview_label_for_config(self, config_obj: object) -> str | None: - if not check_enabled_field(config_obj): - return None - resolution = resolve_preview_label(config_obj) - return resolution.label if resolution is not None else None diff --git a/src/pyqt_reactive/widgets/shared/manager_reorder_controller.py b/src/pyqt_reactive/widgets/shared/manager_reorder_controller.py deleted file mode 100644 index d9f79486..00000000 --- a/src/pyqt_reactive/widgets/shared/manager_reorder_controller.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Drag-reorder workflow for manager widgets.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from objectstate import ObjectStateRegistry - - -@dataclass(frozen=True) -class ManagerReorderOperations: - """Nominal operation port consumed by ManagerReorderController.""" - - list_widget: Any - item_from_list_item: Callable[[Any], Any] - item_id: Callable[[Any], str] - item_name_singular: str - item_name_plural: str - reorder_items: Callable[[int, int], None] - emit_items_changed: Callable[[], None] - update_item_list: Callable[[], None] - emit_status: Callable[[str], None] - - -class ManagerReorderController: - """Owns reorder mutation sequencing and user-facing status text.""" - - def handle_reordered( - self, - operations: ManagerReorderOperations, - from_index: int, - to_index: int, - ) -> None: - # Qt has already moved the source row when this signal is emitted. - list_item = operations.list_widget.item(to_index) - item = operations.item_from_list_item(list_item) - item_id = operations.item_id(item) if item else "Unknown" - - with ObjectStateRegistry.atomic(f"reorder {operations.item_name_plural}"): - operations.reorder_items(from_index, to_index) - operations.emit_items_changed() - operations.update_item_list() - - direction = "up" if to_index < from_index else "down" - operations.emit_status( - f"Moved {operations.item_name_singular} '{item_id}' {direction}" - ) diff --git a/src/pyqt_reactive/widgets/shared/manager_state_binding.py b/src/pyqt_reactive/widgets/shared/manager_state_binding.py deleted file mode 100644 index e3a43913..00000000 --- a/src/pyqt_reactive/widgets/shared/manager_state_binding.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Typed state bindings for manager widgets.""" - -from dataclasses import dataclass -from typing import Any - - -@dataclass(frozen=True, slots=True) -class ManagerStateBinding: - """Maps manager-owned list, selection, and signal attributes into base logic.""" - - items_attr: str - selection_attr: str - selection_signal_attr: str - - def items(self, manager: Any) -> list[Any]: - return getattr(manager, self.items_attr) - - def set_selection_id(self, manager: Any, item_id: str) -> None: - setattr(manager, self.selection_attr, item_id) - - def current_selection_id(self, manager: Any) -> str: - return getattr(manager, self.selection_attr) - - def selection_signal(self, manager: Any) -> Any: - return getattr(manager, self.selection_signal_attr) diff --git a/src/pyqt_reactive/widgets/shared/manager_status_controller.py b/src/pyqt_reactive/widgets/shared/manager_status_controller.py index 2d212f4e..b3205b4a 100644 --- a/src/pyqt_reactive/widgets/shared/manager_status_controller.py +++ b/src/pyqt_reactive/widgets/shared/manager_status_controller.py @@ -8,7 +8,7 @@ from PyQt6.QtCore import Qt, QTimer from PyQt6.QtWidgets import QLabel -from pyqt_reactive.strategies import ( +from formbridge.strategies.status_presentation import ( DefaultStatusPresentationStrategy, StatusPresentationInput, ) diff --git a/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py b/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py deleted file mode 100644 index b6b516c8..00000000 --- a/src/pyqt_reactive/widgets/shared/manager_time_travel_binding.py +++ /dev/null @@ -1,84 +0,0 @@ -"""ObjectState time-travel binding for manager list widgets.""" - -from __future__ import annotations - -import logging -from typing import Any, Optional, TYPE_CHECKING - -from objectstate import ObjectStateRegistry - -if TYPE_CHECKING: - from objectstate import ObjectState - -logger = logging.getLogger(__name__) - - -class ManagerTimeTravelBinding: - """Owns ObjectState registry callbacks and list limbo for a manager widget.""" - - def __init__(self, manager: Any, item_access: Any) -> None: - self._manager = manager - self._item_access = item_access - self._limbo_items: dict[str, Any] = {} - - @property - def in_time_travel(self) -> bool: - return ObjectStateRegistry.is_restoring_snapshot() - - def connect(self) -> None: - ObjectStateRegistry.add_unregister_callback(self.on_registry_unregister) - ObjectStateRegistry.add_register_callback(self.on_registry_register) - ObjectStateRegistry.add_time_travel_complete_callback(self._manager.on_time_travel_complete) - - def disconnect(self) -> None: - ObjectStateRegistry.remove_unregister_callback(self.on_registry_unregister) - ObjectStateRegistry.remove_register_callback(self.on_registry_register) - ObjectStateRegistry.remove_time_travel_complete_callback(self._manager.on_time_travel_complete) - - def on_registry_unregister(self, scope_key: str, state: "ObjectState") -> None: - del state - manager = self._manager - item = self._find_backing_item_by_scope(manager, scope_key) - if item is None: - return - - backing_items = self._item_access.state_binding.items(manager) - if item in backing_items: - backing_items.remove(item) - manager.update_item_list() - logger.debug("TIME_TRAVEL: Removed item from UI: %s", scope_key) - - def on_registry_register(self, scope_key: str, state: "ObjectState") -> None: - del state - if not self.in_time_travel: - return - - item = self._limbo_items.pop(scope_key, None) - if item is None: - return - - manager = self._manager - backing_items = self._item_access.state_binding.items(manager) - if item not in backing_items: - insert_idx = manager.get_item_insert_index(item, scope_key) - if insert_idx is not None: - backing_items.insert(insert_idx, item) - else: - backing_items.append(item) - manager.update_item_list() - logger.debug("TIME_TRAVEL: Added item back to UI: %s", scope_key) - - def refresh_after_time_travel(self, manager: Any) -> None: - manager.update_item_list() - manager.update_button_states() - - def _find_backing_item_by_scope(self, manager: Any, scope_key: str) -> Optional[Any]: - if manager.SCOPE_ITEM_TYPE is None: - return None - - for index, item in enumerate(self._item_access.state_binding.items(manager)): - item_scope = self._item_access.list_item_scope_id(item, index) - if item_scope == scope_key: - self._limbo_items[scope_key] = item - return item - return None diff --git a/src/pyqt_reactive/widgets/shared/manager_workflows.py b/src/pyqt_reactive/widgets/shared/manager_workflows.py deleted file mode 100644 index 6d80f823..00000000 --- a/src/pyqt_reactive/widgets/shared/manager_workflows.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Nominal manager workflow capabilities for AbstractManagerWidget.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, Optional - -from metaclass_registry import AutoRegisterMeta - - -class ManagerCodeExecutionWorkflow(ABC, metaclass=AutoRegisterMeta): - """Workflow that applies code-editor namespaces to manager state.""" - - __registry_key__ = "workflow_key" - __skip_if_no_key__ = True - - @abstractmethod - def migration_namespace( - self, - code: str, - error: Exception, - ) -> Optional[dict]: - raise NotImplementedError - - @abstractmethod - def apply_namespace(self, namespace: dict) -> bool: - raise NotImplementedError - - @abstractmethod - def validate_namespace(self, namespace: dict) -> bool: - raise NotImplementedError - - -class NullManagerCodeExecutionWorkflow(ManagerCodeExecutionWorkflow): - """Default code execution workflow for managers without code-edit support.""" - - workflow_key = "null" - - def migration_namespace( - self, - code: str, - error: Exception, - ) -> Optional[dict]: - del code, error - return None - - def apply_namespace(self, namespace: dict) -> bool: - del namespace - return False - - def validate_namespace(self, namespace: dict) -> bool: - del namespace - return False - - -class ManagerDeletionWorkflow(ABC, metaclass=AutoRegisterMeta): - """Workflow that validates and deletes selected manager items.""" - - __registry_key__ = "workflow_key" - __skip_if_no_key__ = True - - @abstractmethod - def validate(self, items: list[Any]) -> bool: - raise NotImplementedError - - @abstractmethod - def delete(self, items: list[Any]) -> None: - raise NotImplementedError - - -class NullManagerDeletionWorkflow(ManagerDeletionWorkflow): - """Default deletion workflow for managers that must provide deletion logic.""" - - workflow_key = "null" - - def validate(self, items: list[Any]) -> bool: - del items - return True - - def delete(self, items: list[Any]) -> None: - del items - raise NotImplementedError("Manager deletion workflow is not configured") diff --git a/src/pyqt_reactive/widgets/shared/responsive_layout_widgets.py b/src/pyqt_reactive/widgets/shared/responsive_layout_widgets.py index 9f7a6e69..80b7e205 100644 --- a/src/pyqt_reactive/widgets/shared/responsive_layout_widgets.py +++ b/src/pyqt_reactive/widgets/shared/responsive_layout_widgets.py @@ -13,10 +13,7 @@ QWidget, ) -from pyqt_reactive.forms.layout_constants import ( - CURRENT_LAYOUT, - ParameterFormLayoutConfig, -) +from formbridge.forms.layout_constants import CURRENT_LAYOUT, ParameterFormLayoutConfig def _widget_required_width(widget: QWidget) -> int: diff --git a/src/pyqt_reactive/widgets/shared/scope_color_receiver.py b/src/pyqt_reactive/widgets/shared/scope_color_receiver.py deleted file mode 100644 index 2f6623b4..00000000 --- a/src/pyqt_reactive/widgets/shared/scope_color_receiver.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Nominal contract for widgets that receive scope color schemes.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class ScopeColorSchemeReceiver(ABC): - """Nominal receiver for scope color propagation.""" - - @abstractmethod - def set_scope_color_scheme(self, scheme) -> None: - """Apply or clear a scope color scheme.""" diff --git a/src/pyqt_reactive/widgets/shared/scope_color_strategy.py b/src/pyqt_reactive/widgets/shared/scope_color_strategy.py deleted file mode 100644 index aa4cc0bd..00000000 --- a/src/pyqt_reactive/widgets/shared/scope_color_strategy.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Pluggable color generation strategies for scope-based styling. - -HIERARCHY: -- Orchestrator (plate) → gets BASE color from palette (by hash) -- Steps under that orchestrator → inherit BASE color, vary by tint + pattern -""" - -from abc import ABC, abstractmethod -from enum import Enum, auto -from typing import Tuple, Dict, Optional -import colorsys -import hashlib -import logging - -from metaclass_registry import AutoRegisterMeta -from objectstate import ScopeKey - -logger = logging.getLogger(__name__) - -# Predetermined palette of maximally distinct colors (12 primary colors) -# Hand-picked for maximum perceptual distinction -_PRIMARY_PALETTE_RGB: Tuple[Tuple[int, int, int], ...] = ( - (46, 204, 113), # 0: Emerald green - (52, 152, 219), # 1: Sky blue - (231, 76, 60), # 2: Red - (155, 89, 182), # 3: Purple - (241, 196, 15), # 4: Yellow - (26, 188, 156), # 5: Turquoise - (230, 126, 34), # 6: Orange - (52, 73, 94), # 7: Dark blue-grey - (46, 204, 170), # 8: Mint - (192, 57, 43), # 9: Dark red - (142, 68, 173), # 10: Dark purple - (39, 174, 96), # 11: Dark green -) - - -class ColorStrategyType(Enum): - """Available color generation strategies.""" - - INDEX_BASED = auto() # Color by orchestrator hash (primary) - MD5_HASH = auto() # Fallback (distinctipy palette) - MANUAL = auto() - - -class ScopeColorStrategy(ABC, metaclass=AutoRegisterMeta): - """Abstract base for color generation strategies.""" - - __registry_key__ = "strategy_id" - __skip_if_no_key__ = True - - strategy_id: str - strategy_type: ColorStrategyType - - @abstractmethod - def generate_color(self, scope_id: str, step_index: Optional[int] = None) -> Tuple[int, int, int]: - """Generate RGB color for scope. - - Args: - scope_id: The scope identifier (orchestrator or step scope) - step_index: Optional step index (not used for base color, only for tint/pattern) - """ - raise NotImplementedError - - @classmethod - def create_registry(cls) -> Dict[ColorStrategyType, "ScopeColorStrategy"]: - """Instantiate all registered scope-color strategies keyed by strategy type.""" - return { - strategy_cls.strategy_type: strategy_cls() - for strategy_cls in cls.__registry__.values() - } - - -class IndexBasedStrategy(ScopeColorStrategy): - """Color by orchestrator from predetermined palette in discovery order. - - Orchestrators (plates) get distinct BASE colors from the primary palette - assigned in the order they are first seen. Steps inherit their orchestrator's - base color. Tint/pattern variation is handled in build_color_scheme_from_rgb. - """ - - strategy_id = "index_based" - strategy_type = ColorStrategyType.INDEX_BASED - - def __init__(self) -> None: - # Map orchestrator -> palette index assigned in discovery order - self._color_map: Dict[str, int] = {} - self._next_index: int = 0 - - def generate_color(self, scope_id: str, step_index: Optional[int] = None) -> Tuple[int, int, int]: - orchestrator = self._extract_orchestrator(scope_id) - - if orchestrator not in self._color_map: - palette_index = self._next_index % len(_PRIMARY_PALETTE_RGB) - self._color_map[orchestrator] = palette_index - self._next_index += 1 - else: - palette_index = self._color_map[orchestrator] - - return _PRIMARY_PALETTE_RGB[palette_index] - - @staticmethod - def _extract_orchestrator(scope_id: str) -> str: - """Return the root segment of ``scope_id``.""" - return ScopeKey(scope_id).root - - -class MD5HashStrategy(ScopeColorStrategy): - """Deterministic color from MD5 hash of scope_id (fallback).""" - - strategy_id = "md5_hash" - strategy_type = ColorStrategyType.MD5_HASH - PALETTE_SIZE = 50 - - def __init__(self) -> None: - self._palette: Optional[list] = None - - def _get_palette(self) -> list: - if self._palette is None: - self._palette = self._generate_palette() - return self._palette - - def _generate_palette(self) -> list: - try: - from distinctipy import distinctipy - - colors = distinctipy.get_colors( - self.PALETTE_SIZE, - exclude_colors=[(0, 0, 0), (1, 1, 1)], - pastel_factor=0.5, - ) - return [tuple(int(c * 255) for c in color) for color in colors] - except ImportError: - logger.debug("distinctipy not installed, using HSV fallback") - return self._generate_hsv_palette() - - def _generate_hsv_palette(self) -> list: - palette = [] - for i in range(self.PALETTE_SIZE): - hue = 360 * i / self.PALETTE_SIZE - r, g, b = colorsys.hsv_to_rgb(hue / 360, 0.5, 0.8) - palette.append((int(r * 255), int(g * 255), int(b * 255))) - return palette - - def _hash_to_index(self, scope_id: str) -> int: - hash_bytes = hashlib.md5(scope_id.encode("utf-8")).digest() - hash_int = int.from_bytes(hash_bytes[:4], byteorder="big") - return hash_int % self.PALETTE_SIZE - - def generate_color(self, scope_id: str, step_index: Optional[int] = None) -> Tuple[int, int, int]: - palette = self._get_palette() - index = self._hash_to_index(scope_id) - return palette[index] - - -class ManualColorStrategy(ScopeColorStrategy): - """User-selected colors with persistence.""" - - strategy_id = "manual" - strategy_type = ColorStrategyType.MANUAL - - def __init__(self) -> None: - self._colors: Dict[str, Tuple[int, int, int]] = {} - self._fallback = MD5HashStrategy() - - def set_color(self, scope_id: str, rgb: Tuple[int, int, int]) -> None: - self._colors[scope_id] = rgb - - def clear_color(self, scope_id: str) -> None: - self._colors.pop(scope_id, None) - - def has_manual_color(self, scope_id: str) -> bool: - return scope_id in self._colors - - def get_all_manual_colors(self) -> Dict[str, Tuple[int, int, int]]: - return dict(self._colors) - - def load_manual_colors(self, colors: Dict[str, Tuple[int, int, int]]) -> None: - self._colors.update(colors) - - def generate_color(self, scope_id: str, step_index: Optional[int] = None) -> Tuple[int, int, int]: - if scope_id in self._colors: - return self._colors[scope_id] - return self._fallback.generate_color(scope_id, step_index) diff --git a/src/pyqt_reactive/widgets/shared/scoped_table_widget.py b/src/pyqt_reactive/widgets/shared/scoped_table_widget.py index 6313c486..699175a4 100644 --- a/src/pyqt_reactive/widgets/shared/scoped_table_widget.py +++ b/src/pyqt_reactive/widgets/shared/scoped_table_widget.py @@ -6,7 +6,7 @@ from PyQt6.QtWidgets import QTableWidget, QWidget from pyqt_reactive.widgets.shared.scope_border_renderer import ScopeBorderRenderer -from pyqt_reactive.widgets.shared.scope_color_receiver import ScopeColorSchemeReceiver +from formbridge.theming.scope_color_receiver import ScopeColorSchemeReceiver from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme diff --git a/src/pyqt_reactive/widgets/shared/tree_state_adapter.py b/src/pyqt_reactive/widgets/shared/tree_state_adapter.py index e5d7f15e..e903f83e 100644 --- a/src/pyqt_reactive/widgets/shared/tree_state_adapter.py +++ b/src/pyqt_reactive/widgets/shared/tree_state_adapter.py @@ -8,7 +8,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem -from pyqt_reactive.services.tree_item_key import TreeItemKeyProviderABC +from formbridge.services.tree_item_key import TreeItemKeyProviderABC class TreeItemKeyBuilderABC(ABC): diff --git a/src/pyqt_reactive/widgets/shared/tree_sync_adapter.py b/src/pyqt_reactive/widgets/shared/tree_sync_adapter.py index ae5258e4..93548176 100644 --- a/src/pyqt_reactive/widgets/shared/tree_sync_adapter.py +++ b/src/pyqt_reactive/widgets/shared/tree_sync_adapter.py @@ -7,7 +7,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QTreeWidgetItem -from pyqt_reactive.services.tree_item_key import TreeItemKeyProviderABC +from formbridge.services.tree_item_key import TreeItemKeyProviderABC @dataclass diff --git a/src/pyqt_reactive/widgets/shared/zmq_server_browser_widget.py b/src/pyqt_reactive/widgets/shared/zmq_server_browser_widget.py index e27a9e16..cd53f355 100644 --- a/src/pyqt_reactive/widgets/shared/zmq_server_browser_widget.py +++ b/src/pyqt_reactive/widgets/shared/zmq_server_browser_widget.py @@ -24,11 +24,9 @@ EndpointStartupStatus, ) -from pyqt_reactive.services.coalesced_scan_requests import CoalescedScanRequests -from pyqt_reactive.services.zmq_server_info import ( - BaseServerInfo, -) -from pyqt_reactive.services.zmq_server_scan_service import ( +from formbridge.services.coalesced_scan_requests import CoalescedScanRequests +from formbridge.services.zmq_server_info import BaseServerInfo +from formbridge.services.zmq_server_scan_service import ( EndpointObservationAuthority, EndpointObservationSnapshot, EndpointObservationTransition, diff --git a/src/pyqt_reactive/widgets/system_monitor.py b/src/pyqt_reactive/widgets/system_monitor.py index 0dc8addf..2c3b36d2 100644 --- a/src/pyqt_reactive/widgets/system_monitor.py +++ b/src/pyqt_reactive/widgets/system_monitor.py @@ -29,16 +29,13 @@ from pyqt_reactive.animation import queue_visual_frame_callback from pyqt_reactive.theming import ColorScheme -from pyqt_reactive.services.system_monitor_core import SystemMonitorCore +from formbridge.services.system_monitor_core import SystemMonitorCore from pyqt_reactive.services.persistent_system_monitor import ( PersistentSystemMonitor, PersistentSystemMonitorThread, ) -from pyqt_reactive.services.system_monitor_config import PerformanceMonitorConfig -from pyqt_reactive.services.system_metrics_sampler import ( - SystemMetrics, - SystemMetricsSampler, -) +from formbridge.services.system_monitor_config import PerformanceMonitorConfig +from formbridge.services.system_metrics_sampler import SystemMetrics, SystemMetricsSampler from pyqt_reactive.widgets.shared.manager_ui_scaffold import create_manager_header logger = logging.getLogger(__name__) diff --git a/src/pyqt_reactive/windows/config_window.py b/src/pyqt_reactive/windows/config_window.py index 34367613..efe3c354 100644 --- a/src/pyqt_reactive/windows/config_window.py +++ b/src/pyqt_reactive/windows/config_window.py @@ -35,14 +35,14 @@ QWidget, ) -from pyqt_reactive.forms.layout_constants import CURRENT_LAYOUT +from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.forms.parameter_form_manager import ( FormManagerConfig, ParameterFormManager, ) -from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater +from formbridge.services.code_editor_form_updater import CodeEditorFormUpdater from pyqt_reactive.services.parameter_ops_service import ParameterOpsService -from pyqt_reactive.services.window_code_document import ( +from formbridge.services.window_code_document import ( PYTHON_MIME_TYPE, WindowCodeDocument, WindowCodeDocumentDriver, diff --git a/src/pyqt_reactive/windows/help_window_manager.py b/src/pyqt_reactive/windows/help_window_manager.py index c934dae8..c2a5032f 100644 --- a/src/pyqt_reactive/windows/help_window_manager.py +++ b/src/pyqt_reactive/windows/help_window_manager.py @@ -13,8 +13,8 @@ QVBoxLayout, ) -from pyqt_reactive.services.help_document import HelpDocument -from pyqt_reactive.services.parameter_help_service import ( +from formbridge.services.help_document import HelpDocument +from formbridge.services.parameter_help_service import ( ParameterHelpContent, docstring_info_for_target, parameter_help_content, diff --git a/tests/test_action_tabbed_window_body_lazy.py b/tests/test_action_tabbed_window_body_lazy.py index 5439e922..d389c3b4 100644 --- a/tests/test_action_tabbed_window_body_lazy.py +++ b/tests/test_action_tabbed_window_body_lazy.py @@ -6,9 +6,7 @@ import pytest -from pyqt_reactive.widgets.shared.action_tabbed_window_body import ( - TabLabelDeclarationMixin, -) +from formbridge.services.tab_identity import TabLabelDeclarationMixin class _ExampleTab(TabLabelDeclarationMixin, str, Enum): diff --git a/tests/test_annotation_choice_fields.py b/tests/test_annotation_choice_fields.py index 6b393c52..7e56b47d 100644 --- a/tests/test_annotation_choice_fields.py +++ b/tests/test_annotation_choice_fields.py @@ -58,7 +58,7 @@ def test_multiple_choice_field_renders_checkbox_per_choice(qapp): def test_choice_values_convert_from_choices_and_labels() -> None: - from pyqt_reactive.forms.parameter_form_service import ParameterFormService + from formbridge.forms.parameter_form_service import ParameterFormService service = ParameterFormService() diff --git a/tests/test_async_operation_executor.py b/tests/test_async_operation_executor.py deleted file mode 100644 index fced7fe5..00000000 --- a/tests/test_async_operation_executor.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Ownership tests for coroutine worker execution.""" - -from __future__ import annotations - -import asyncio -import threading - -import pytest - -from pyqt_reactive.services.async_operation_executor import ( - AsyncOperationExecutor, - AsyncOperationExecutorClosedError, -) - - -def test_executor_retains_operation_until_completion() -> None: - executor = AsyncOperationExecutor(max_workers=1) - release = threading.Event() - - async def operation() -> str: - await asyncio.get_running_loop().run_in_executor(None, release.wait) - return "done" - - future = executor.submit(operation) - - assert executor.active_futures() == (future,) - release.set() - assert future.result(timeout=1.0) == "done" - assert executor.active_futures() == () - executor.close() - - -def test_executor_closes_event_loop_after_operation_failure( - monkeypatch: pytest.MonkeyPatch, -) -> None: - executor = AsyncOperationExecutor(max_workers=1) - loops = [] - real_new_event_loop = asyncio.new_event_loop - - def recorded_loop(): - loop = real_new_event_loop() - loops.append(loop) - return loop - - monkeypatch.setattr(asyncio, "new_event_loop", recorded_loop) - - async def fail() -> None: - raise RuntimeError("failed") - - with pytest.raises(RuntimeError, match="failed"): - executor.submit(fail).result(timeout=1.0) - - assert len(loops) == 1 - assert loops[0].is_closed() - executor.close() - - -def test_close_cancels_queued_work_and_rejects_new_submissions() -> None: - executor = AsyncOperationExecutor(max_workers=1) - started = threading.Event() - release = threading.Event() - queued_ran: list[bool] = [] - - async def blocking() -> None: - started.set() - await asyncio.get_running_loop().run_in_executor(None, release.wait) - - async def queued() -> None: - queued_ran.append(True) - - running = executor.submit(blocking) - assert started.wait(timeout=1.0) - pending = executor.submit(queued) - - executor.close() - assert executor.active_futures() == (running,) - assert pending.cancelled() - with pytest.raises(AsyncOperationExecutorClosedError): - executor.submit(queued) - - release.set() - running.result(timeout=1.0) - assert queued_ran == [] diff --git a/tests/test_background_process_consumers.py b/tests/test_background_process_consumers.py deleted file mode 100644 index 85a10d2c..00000000 --- a/tests/test_background_process_consumers.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Background helpers launch through zmqruntime's background process policy.""" - -from __future__ import annotations - -from types import SimpleNamespace - -from pyqt_reactive.services import system_metrics_sampler -from pyqt_reactive.utils import log_highlight_client - - -class _ConsumerLaunchPolicy: - @classmethod - def current(cls, *, detached=False): - assert detached is False - return SimpleNamespace( - popen_arguments=lambda: {"creationflags": 73}, - python_executable=lambda _executable: "windowed-python", - ) - - -def test_system_metric_helpers_use_background_process_policy(monkeypatch) -> None: - check_output_calls: list[dict[str, object]] = [] - popen_calls: list[dict[str, object]] = [] - process = SimpleNamespace(stdout=()) - - monkeypatch.setattr(system_metrics_sampler, "is_wsl", lambda: True) - monkeypatch.setattr( - system_metrics_sampler, - "BackgroundProcessLaunchPolicy", - _ConsumerLaunchPolicy, - ) - monkeypatch.setattr( - system_metrics_sampler.subprocess, - "check_output", - lambda _command, **kwargs: check_output_calls.append(kwargs) or b"2400", - ) - monkeypatch.setattr( - system_metrics_sampler.shutil, - "which", - lambda _name: "nvidia-smi", - ) - monkeypatch.setattr( - system_metrics_sampler.subprocess, - "Popen", - lambda _command, **kwargs: popen_calls.append(kwargs) or process, - ) - monkeypatch.setattr( - system_metrics_sampler.threading, - "Thread", - lambda **_kwargs: SimpleNamespace(start=lambda: None), - ) - - assert system_metrics_sampler.get_cpu_freq_mhz() == 2400 - system_metrics_sampler.PersistentNvidiaSmiPoller( - cadence=system_metrics_sampler.PollingCadence(1.0), - temperature_sampling=( - system_metrics_sampler.GpuTemperatureSampling.ENABLED - ), - ).start() - - assert check_output_calls[0]["creationflags"] == 73 - assert popen_calls[0]["creationflags"] == 73 - - -def test_log_highlighter_uses_background_process_policy(monkeypatch) -> None: - captured: dict[str, object] = {} - process = SimpleNamespace( - poll=lambda: None, - terminate=lambda: None, - wait=lambda timeout=None: None, - ) - monkeypatch.setattr( - log_highlight_client, - "BackgroundProcessLaunchPolicy", - _ConsumerLaunchPolicy, - ) - monkeypatch.setattr( - log_highlight_client.subprocess, - "Popen", - lambda command, **kwargs: ( - captured.update(command=command, **kwargs) or process - ), - ) - client = log_highlight_client.LogHighlightClient() - try: - assert client._ensure_process() is process - assert captured["command"][0] == "windowed-python" - assert captured["creationflags"] == 73 - finally: - client.shutdown() diff --git a/tests/test_code_editor_form_updater.py b/tests/test_code_editor_form_updater.py deleted file mode 100644 index 14b93c09..00000000 --- a/tests/test_code_editor_form_updater.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Change-filtering tests for code-document form synchronization.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from pyqt_reactive.services.code_editor_form_updater import CodeEditorFormUpdater - - -@dataclass(frozen=True) -class _NestedConfig: - enabled: bool | None = None - label: str | None = None - - -@dataclass(frozen=True) -class _RootConfig: - name: str | None = None - nested: _NestedConfig | None = None - - -class _Step: - """A plain (non-dataclass) object edited through a form.""" - - def __init__(self, *, name: str, description: str | None = None, enabled: bool = True): - self.name = name - self.description = description - self.enabled = enabled - - -class _RecordingFormManager: - def __init__( - self, - parameters: dict[str, object], - *, - nested_managers: dict[str, _RecordingFormManager] | None = None, - ) -> None: - self.parameters = parameters - self.nested_managers = nested_managers or {} - self.updated: list[tuple[str, object]] = [] - - def update_parameter(self, field_name: str, value: object) -> None: - self.updated.append((field_name, value)) - self.parameters[field_name] = value - - -def test_update_form_from_instance_dispatches_only_changed_nested_leaf() -> None: - nested_manager = _RecordingFormManager( - { - "enabled": None, - "label": "same", - } - ) - root_manager = _RecordingFormManager( - { - "name": "same", - "nested": _NestedConfig(enabled=None, label="same"), - }, - nested_managers={"nested": nested_manager}, - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig( - name="same", - nested=_NestedConfig(enabled=False, label="same"), - ), - ) - - assert root_manager.updated == [] - assert nested_manager.updated == [("enabled", False)] - - -def test_update_form_from_instance_skips_fully_unchanged_document() -> None: - nested_manager = _RecordingFormManager( - { - "enabled": None, - "label": "same", - } - ) - root_manager = _RecordingFormManager( - { - "name": "same", - "nested": _NestedConfig(enabled=None, label="same"), - }, - nested_managers={"nested": nested_manager}, - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig( - name="same", - nested=_NestedConfig(enabled=None, label="same"), - ), - ) - - assert root_manager.updated == [] - assert nested_manager.updated == [] - - -def test_update_form_from_instance_preserves_explicit_reset_to_none() -> None: - root_manager = _RecordingFormManager( - { - "name": "override", - "nested": None, - } - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig(name=None, nested=None), - ) - - assert root_manager.updated == [("name", None)] - - -def test_update_form_from_instance_updates_unexpanded_dataclass_once() -> None: - original_nested = _NestedConfig(enabled=None, label="same") - replacement_nested = _NestedConfig(enabled=True, label="same") - root_manager = _RecordingFormManager( - { - "name": "same", - "nested": original_nested, - } - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _RootConfig(name="same", nested=replacement_nested), - ) - - assert root_manager.updated == [("nested", replacement_nested)] - - -def test_update_form_from_non_dataclass_step_dispatches_only_changed_field() -> None: - root_manager = _RecordingFormManager( - { - "name": "Original", - "description": None, - "enabled": True, - } - ) - - CodeEditorFormUpdater.update_form_from_instance( - root_manager, - _Step(name="Renamed"), - ) - - assert root_manager.updated == [("name", "Renamed")] diff --git a/tests/test_compact_dataclass_projection.py b/tests/test_compact_dataclass_projection.py deleted file mode 100644 index 54eaa25d..00000000 --- a/tests/test_compact_dataclass_projection.py +++ /dev/null @@ -1,77 +0,0 @@ -from dataclasses import dataclass, field - -from pyqt_reactive.services.widget_tree_projection_config import ( - COMPACT_FIELD_PROJECTION_METADATA_KEY, - CompactFieldProjection, - compact_dataclass_projection, -) - - -@dataclass(frozen=True) -class _ProjectionExample: - required_empty: str = field( - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda _owner, _value: True - ) - } - ) - ordinary_empty: str = "" - enabled: bool = False - selected: bool = field( - default=False, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, value: value or owner.enabled - ) - }, - ) - - -def test_compact_dataclass_projection_uses_field_owned_predicates(): - value = _ProjectionExample( - required_empty="", - enabled=True, - selected=False, - ) - - assert compact_dataclass_projection(value) == { - "required_empty": "", - "enabled": True, - "selected": False, - } - - -def test_compact_dataclass_projection_omits_default_empty_values(): - value = _ProjectionExample(required_empty="") - - assert compact_dataclass_projection(value) == {"required_empty": ""} - - -def test_required_empty_fields_survive_even_an_excluding_policy(): - @dataclass - class RequiredValues: - empty: str - absent: None - disabled: bool = field(metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda _owner, _value: False - ) - }) - - value = RequiredValues('', None, False) - assert compact_dataclass_projection(value) == { - 'empty': '', 'absent': None, 'disabled': False - } - - -def test_empty_values_differing_from_defaults_remain_distinct(): - @dataclass - class OptionalValues: - enabled: bool | None = None - name: str = 'default' - entries: tuple = field(default_factory=tuple) - - assert compact_dataclass_projection(OptionalValues(False, '')) == { - 'enabled': False, 'name': '', 'entries': () - } diff --git a/tests/test_explicit_function_pattern_overrides.py b/tests/test_explicit_function_pattern_overrides.py deleted file mode 100644 index 4ed09fad..00000000 --- a/tests/test_explicit_function_pattern_overrides.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Explicit function overrides survive projection of normally hidden parameters.""" - -from dataclasses import dataclass - -from objectstate import ObjectState, ObjectStateRegistry, config -from python_introspect import parameter_exclusions, set_parameter_exclusions - -from pyqt_reactive.services.function_pattern_code_document import ( - EditableFunctionPatternCallable, - FunctionPatternCodeDocumentService, - FunctionPatternValue, -) - - -def test_explicit_override_is_editable_without_exposing_other_runtime_parameters(monkeypatch): - @dataclass - class RootConfig: - pass - - monkeypatch.setattr(config, "_base_config_type", RootConfig) - def process(image, *, policy: float = 1.0, runtime_context=None): - return image - - set_parameter_exclusions(process, ("policy", "runtime_context")) - kwargs = {"policy": 2.0} - editable = EditableFunctionPatternCallable.for_entry(process, kwargs) - assert parameter_exclusions(editable) == frozenset({"runtime_context"}) - state = ObjectState( - object_instance=editable, - scope_id="explicit-function-override", - exclude_params=FunctionPatternCodeDocumentService.reserved_parameter_names(editable), - initial_values=kwargs, - ) - assert FunctionPatternCodeDocumentService.reconstruct_kwargs_from_state(state) == kwargs - assert EditableFunctionPatternCallable.for_entry(process, {}) is process - assert parameter_exclusions(process) == frozenset({"policy", "runtime_context"}) - - -def test_existing_function_state_rebuilds_when_explicit_kwargs_change_surface(monkeypatch): - @dataclass - class RootConfig: - pass - - monkeypatch.setattr(config, "_base_config_type", RootConfig) - - def process(image): - return image - - parent = ObjectState(object_instance=RootConfig(), scope_id="pattern-parent") - state = FunctionPatternCodeDocumentService.create_function_state( - scope_id="pattern-parent::func_0", - parent_state=parent, - entry=FunctionPatternValue(process, {}), - ) - ObjectStateRegistry.register(parent, _skip_snapshot=True) - ObjectStateRegistry.register(state, _skip_snapshot=True) - - added = FunctionPatternCodeDocumentService.synchronize_existing_function_state( - state=state, - parent_state=parent, - entry=FunctionPatternValue(process, {"artifact_name": "Nuclei"}), - ) - assert added is not state - assert FunctionPatternCodeDocumentService.reconstruct_kwargs_from_state(added) == { - "artifact_name": "Nuclei" - } - - updated = FunctionPatternCodeDocumentService.synchronize_existing_function_state( - state=added, - parent_state=parent, - entry=FunctionPatternValue(process, {"artifact_name": "Cytoplasm"}), - ) - assert updated is added - assert FunctionPatternCodeDocumentService.reconstruct_kwargs_from_state(updated) == { - "artifact_name": "Cytoplasm" - } - - removed = FunctionPatternCodeDocumentService.synchronize_existing_function_state( - state=updated, - parent_state=parent, - entry=FunctionPatternValue(process, {}), - ) - assert removed is not updated - assert FunctionPatternCodeDocumentService.reconstruct_kwargs_from_state(removed) == {} - - -def test_code_mode_rebuilds_restored_state_with_stale_nested_dataclass_schema( - monkeypatch, -): - """A declaration-added field must survive ObjectState/code-mode roundtrip.""" - - @dataclass - class RootConfig: - pass - - @dataclass(frozen=True) - class Settings: - threshold: float = 1.0 - correction_factor: float = 0.85 - - monkeypatch.setattr(config, "_base_config_type", RootConfig) - - def process(image, settings: Settings = Settings()): - return image - - parent = ObjectState(object_instance=RootConfig(), scope_id="schema-parent") - entry = FunctionPatternValue( - process, - {"settings": Settings(threshold=2.0, correction_factor=0.85)}, - ) - state = FunctionPatternCodeDocumentService.create_function_state( - scope_id="schema-parent::func_0", - parent_state=parent, - entry=entry, - ) - ObjectStateRegistry.register(parent, _skip_snapshot=True) - ObjectStateRegistry.register(state, _skip_snapshot=True) - - # Reproduce a history document written before ``correction_factor`` was - # declared: the container resolves to the current class, but the flattened - # state has no editable leaf for the new field. - stale_path = "settings.correction_factor" - state.parameters.pop(stale_path) - state._saved_parameters.pop(stale_path) - - requested = FunctionPatternValue( - process, - {"settings": Settings(threshold=2.0, correction_factor=0.5)}, - ) - updated = FunctionPatternCodeDocumentService.synchronize_existing_function_state( - state=state, - parent_state=parent, - entry=requested, - ) - - assert updated is not state - assert updated.parameters[stale_path] == 0.5 - assert FunctionPatternCodeDocumentService.reconstruct_kwargs_from_state( - updated - ) == {"settings": Settings(threshold=2.0, correction_factor=0.5)} diff --git a/tests/test_function_list_editor_tokens.py b/tests/test_function_list_editor_tokens.py index 183e056f..e0845cfc 100644 --- a/tests/test_function_list_editor_tokens.py +++ b/tests/test_function_list_editor_tokens.py @@ -7,10 +7,8 @@ import pytest from objectstate.object_state_registry import ObjectStateRegistry -from pyqt_reactive.services.function_pattern_code_document import ( - FunctionPatternCodeDocumentService, -) -from pyqt_reactive.services.scope_token_service import ( +from formbridge.services.function_pattern_code_document import FunctionPatternCodeDocumentService +from formbridge.services.scope_token_service import ( ScopeTokenService, ScopeTokenTarget, reconcile_occurrence_tokens, diff --git a/tests/test_help_document.py b/tests/test_help_document.py deleted file mode 100644 index 1b32d0e6..00000000 --- a/tests/test_help_document.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Tests for the generic typed help-document model.""" - -import pytest -from python_introspect import DocstringInfo - -from pyqt_reactive.services.help_document import ( - DEFAULT_HELP_DOCUMENT_MAX_CHARS, - HelpDocument, - HelpDocumentFormat, -) - - -@pytest.mark.parametrize( - ("source_path", "expected"), - [ - ("guide.md", HelpDocumentFormat.MARKDOWN), - ("guide.MARKDOWN", HelpDocumentFormat.MARKDOWN), - ("guide.RST", HelpDocumentFormat.RESTRUCTURED_TEXT), - ("guide.txt", HelpDocumentFormat.PLAIN_TEXT), - ("guide", HelpDocumentFormat.PLAIN_TEXT), - ], -) -def test_help_document_format_is_derived_from_declared_source_suffix( - source_path: str, - expected: HelpDocumentFormat, -) -> None: - assert HelpDocumentFormat.from_source_path(source_path) is expected - - -def test_docstring_projection_preserves_structured_sections_and_code() -> None: - document = HelpDocument.from_docstring_info( - DocstringInfo( - summary="Normalize *one* stack.", - description="Uses a [documented](https://example.com) algorithm.", - parameters={ - "image": "Input image.", - "tick`name": "A parameter with a delimiter in its name.", - }, - returns="The normalized image.", - examples='result = normalize(image)\nprint("```")', - ), - title="Normalize # Stack", - ) - - assert document.markup is HelpDocumentFormat.MARKDOWN - assert document.title == "Normalize # Stack" - assert document.content.startswith("# Normalize \\# Stack") - assert "**Normalize \\*one\\* stack.**" in document.content - assert "## Parameters" in document.content - assert "### `image`" in document.content - assert "## Returns" in document.content - assert "## Examples" in document.content - assert "````python" in document.content - assert document.content.endswith("````") - - -def test_parameter_projection_uses_same_typed_document_model() -> None: - document = HelpDocument.from_parameter_content( - title="Batch size", - summary="Images per request.", - description="Choose a value supported by the backend.", - ) - - assert document == HelpDocument( - content=( - "# Batch size\n\n" - "**Images per request.**\n\n" - "Choose a value supported by the backend." - ), - markup=HelpDocumentFormat.MARKDOWN, - title="Batch size", - ) - - -def test_restructured_text_rendering_preserves_hierarchy_code_and_links() -> None: - document = HelpDocument( - content=( - "Guide\n" - "=====\n\n" - "Overview text.\n\n" - "Section\n" - "-------\n\n" - "See `the reference `_.\n\n" - ".. code-block:: python\n\n" - " result = process(image)\n" - ), - markup=HelpDocumentFormat.RESTRUCTURED_TEXT, - ) - - rendered = document.rendered_html() - - assert '

Guide

' in rendered - assert "

Section

" in rendered - assert 'href="https://example.com/reference"' in rendered - assert "result" in rendered - assert " None: - secret = tmp_path / "secret.txt" - secret.write_text("DO_NOT_INSERT_THIS_VALUE", encoding="utf-8") - document = HelpDocument( - content=( - ".. raw:: html\n\n" - " \n\n" - f".. include:: {secret}\n" - ), - markup=HelpDocumentFormat.RESTRUCTURED_TEXT, - ) - - rendered = document.rendered_html() - - assert "DO_NOT_RENDER_THIS_SCRIPT" not in rendered - assert "DO_NOT_INSERT_THIS_VALUE" not in rendered - assert " None: - with pytest.raises(ValueError, match="only valid for reStructuredText"): - HelpDocument("# Markdown", HelpDocumentFormat.MARKDOWN).rendered_html() - - -def test_bounded_uses_declaration_owned_default_and_preserves_metadata() -> None: - document = HelpDocument( - content="x" * (DEFAULT_HELP_DOCUMENT_MAX_CHARS + 100), - markup=HelpDocumentFormat.MARKDOWN, - title="Large document", - base_url="https://example.com/docs/", - ) - - bounded = document.bounded() - - assert len(bounded.content) <= DEFAULT_HELP_DOCUMENT_MAX_CHARS - assert bounded.content.endswith("…") - assert bounded.markup is document.markup - assert bounded.title == document.title - assert bounded.base_url == document.base_url - - -@pytest.mark.parametrize("max_chars", [0, -1]) -def test_bounded_rejects_non_positive_limits(max_chars: int) -> None: - with pytest.raises(ValueError, match="at least 1"): - HelpDocument("content").bounded(max_chars) - - -def test_bounded_returns_same_immutable_document_when_already_within_limit() -> None: - document = HelpDocument("short") - - assert document.bounded(5) is document - - -def test_bounded_respects_very_small_limits() -> None: - assert HelpDocument("content").bounded(1).content == "…" - assert HelpDocument("content").bounded(2).content == "c…" - assert HelpDocument("content").bounded(4).content == "c\n\n…" - - -@pytest.mark.parametrize( - ("document", "title", "expected"), - [ - ( - HelpDocument( - "\n# Processing Guide #\n\nFirst section.", - HelpDocumentFormat.MARKDOWN, - ), - "processing guide", - "First section.", - ), - ( - HelpDocument( - "\nProcessing Guide\n================\n\nFirst section.", - HelpDocumentFormat.RESTRUCTURED_TEXT, - ), - "processing guide", - "First section.", - ), - ], -) -def test_matching_leading_heading_is_removed( - document: HelpDocument, - title: str, - expected: str, -) -> None: - assert document.without_leading_heading(title).content == expected - - -@pytest.mark.parametrize( - "document", - [ - HelpDocument("# Different Guide\n\nContent", HelpDocumentFormat.MARKDOWN), - HelpDocument( - "Different Guide\n===============\n\nContent", - HelpDocumentFormat.RESTRUCTURED_TEXT, - ), - HelpDocument("Processing Guide\nContent", HelpDocumentFormat.PLAIN_TEXT), - ], -) -def test_nonmatching_or_plain_heading_is_unchanged(document: HelpDocument) -> None: - assert document.without_leading_heading("Processing Guide") is document diff --git a/tests/test_help_document_browser.py b/tests/test_help_document_browser.py index 0c442a76..f6d43631 100644 --- a/tests/test_help_document_browser.py +++ b/tests/test_help_document_browser.py @@ -8,7 +8,7 @@ from PyQt6.QtCore import QUrl from PyQt6.QtGui import QTextCursor -from pyqt_reactive.services.help_document import HelpDocument, HelpDocumentFormat +from formbridge.services.help_document import HelpDocument, HelpDocumentFormat from pyqt_reactive.widgets.help_document_browser import HelpDocumentBrowser from pyqt_reactive.windows.help_window_manager import ( BaseHelpWindow, diff --git a/tests/test_host_provider_contracts.py b/tests/test_host_provider_contracts.py deleted file mode 100644 index 39b94484..00000000 --- a/tests/test_host_provider_contracts.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Nominal registration gates for host-provided UI behavior.""" - -import pytest - -from pyqt_reactive.protocols import ( - ComponentSelectionProviderABC, - FunctionSelectionProviderABC, - LogDiscoveryProviderABC, - ServerScanProviderABC, - register_component_selection_provider, - register_function_selection_provider, - register_log_discovery_provider, - register_server_scan_provider, -) - - -@pytest.mark.parametrize( - ("contract", "register", "message"), - ( - ( - LogDiscoveryProviderABC, - register_log_discovery_provider, - "inherit LogDiscoveryProviderABC", - ), - ( - ServerScanProviderABC, - register_server_scan_provider, - "inherit ServerScanProviderABC", - ), - ( - ComponentSelectionProviderABC, - register_component_selection_provider, - "inherit ComponentSelectionProviderABC", - ), - ( - FunctionSelectionProviderABC, - register_function_selection_provider, - "inherit FunctionSelectionProviderABC", - ), - ), -) -def test_host_provider_registration_requires_nominal_contract( - contract, - register, - message: str, -) -> None: - class StructuralProvider: - pass - - with pytest.raises(TypeError): - contract() - - with pytest.raises(TypeError, match=message): - register(StructuralProvider()) diff --git a/tests/test_inline_dataclass_chrome.py b/tests/test_inline_dataclass_chrome.py index 60e70047..e76f6a53 100644 --- a/tests/test_inline_dataclass_chrome.py +++ b/tests/test_inline_dataclass_chrome.py @@ -80,7 +80,7 @@ def set_raw_value_with_resolved_preview( def _inline_param_info(): - from pyqt_reactive.forms.parameter_info_types import InlineDataclassWidgetInfo + from formbridge.forms.parameter_info_types import InlineDataclassWidgetInfo return InlineDataclassWidgetInfo( name="config", diff --git a/tests/test_log_viewer_lifecycle.py b/tests/test_log_viewer_lifecycle.py index f21c8b2e..1c544dce 100644 --- a/tests/test_log_viewer_lifecycle.py +++ b/tests/test_log_viewer_lifecycle.py @@ -10,15 +10,15 @@ from PyQt6.QtWidgets import QStyleOptionViewItem from zmqruntime.messages import ProcessIdentity -from pyqt_reactive.core.log_utils import LogType -from pyqt_reactive.protocols import ( +from formbridge.core.log_utils import LogType +from formbridge.protocols.log_providers import ( LogDiscoveryProviderABC, ServerScanProviderABC, register_log_discovery_provider, register_server_scan_provider, ) -from pyqt_reactive.services.process_tracker import ProcessLiveness -from pyqt_reactive.utils.log_highlighter import build_log_line_html +from formbridge.services.process_tracker import ProcessLiveness +from formbridge.utils.log_highlighter import build_log_line_html from pyqt_reactive.widgets.log_viewer import ( LogFileDetector, LogFileInfo, @@ -64,7 +64,7 @@ def wait_until_closed(client, _text): return [] monkeypatch.setattr( - "pyqt_reactive.utils.log_highlight_client.LogHighlightClient.parse_line", + "formbridge.utils.log_highlight_client.LogHighlightClient.parse_line", wait_until_closed, ) delegate = LogItemDelegate() diff --git a/tests/test_parameter_form_service.py b/tests/test_parameter_form_service.py deleted file mode 100644 index 13d4f574..00000000 --- a/tests/test_parameter_form_service.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from enum import Enum -from typing import Annotated - -import pytest - -from pyqt_reactive.forms.parameter_form_service import ParameterFormService -from pyqt_reactive.forms.parameter_info_types import ( - OptionalDataclassInfo, - create_parameter_info, -) -from pyqt_reactive.forms.parameter_type_utils import ParameterTypeUtils - - -class MatchSubject(Enum): - FILE = "file" - DIRECTORY = "directory" - - -class InheritableMode(Enum): - ENABLED = "enabled" - INHERIT = None - - -@dataclass(frozen=True) -class MatchClause: - subject: MatchSubject - value: str | None = None - - -@dataclass(frozen=True) -class NestedConfig: - value: int = 1 - - -@dataclass(frozen=True) -class NestedConfigOwner: - primary: NestedConfig | None = None - secondary: NestedConfig | None = None - - -def test_convert_value_to_type_rebuilds_optional_dataclass_tuple() -> None: - converted = ParameterFormService().convert_value_to_type( - [{"subject": "directory", "value": "TimePoint_1"}], - tuple[MatchClause, ...] | None, - "source_filters", - ) - - assert converted == ( - MatchClause(subject=MatchSubject.DIRECTORY, value="TimePoint_1"), - ) - - -def test_convert_value_to_type_validates_mapping_keys_and_values() -> None: - service = ParameterFormService() - - assert service.convert_value_to_type( - {1: "one"}, - dict[int, str], - "labels", - ) == {1: "one"} - - with pytest.raises(ValueError, match="expected int, got str"): - service.convert_value_to_type( - {"1": "one"}, - dict[int, str], - "labels", - ) - - with pytest.raises(ValueError, match="expected str, got int"): - service.convert_value_to_type( - {1: 1}, - dict[int, str], - "labels", - ) - - -def test_convert_value_to_type_validates_nested_list_and_tuple_items() -> None: - service = ParameterFormService() - annotation = dict[str, list[tuple[str, int]]] - - converted = service.convert_value_to_type( - {"cpu": [("worker", 2)]}, - annotation, - "routes", - ) - - assert converted == {"cpu": [("worker", 2)]} - - with pytest.raises(ValueError, match="expected int, got str"): - service.convert_value_to_type( - {"cpu": [("worker", "2")]}, - annotation, - "routes", - ) - - -def test_convert_value_to_type_allows_int_to_float_widening() -> None: - converted = ParameterFormService().convert_value_to_type( - [1, 2.5], - list[float], - "weights", - ) - - assert converted == [1.0, 2.5] - assert all(type(item) is float for item in converted) - - -def test_convert_value_to_type_rejects_bool_as_int_container_item() -> None: - with pytest.raises(ValueError, match="expected int, got bool"): - ParameterFormService().convert_value_to_type( - [True], - list[int], - "worker_counts", - ) - - -def test_convert_value_to_type_resolves_annotated_union_members() -> None: - annotation = Annotated[bool, "enabled-field"] | None - - assert ( - ParameterFormService().convert_value_to_type( - True, - annotation, - "enabled", - ) - is True - ) - - -def test_convert_value_to_type_accepts_enum_member_names() -> None: - service = ParameterFormService() - - assert ( - service.convert_value_to_type("INHERIT", InheritableMode, "mode") - is InheritableMode.INHERIT - ) - assert ( - service.convert_value_to_type( - "INHERIT", - InheritableMode | None, - "mode", - ) - is InheritableMode.INHERIT - ) - assert service.convert_value_to_type( - ["enabled", "INHERIT"], - list[InheritableMode], - "modes", - ) == [InheritableMode.ENABLED, InheritableMode.INHERIT] - - -def test_convert_value_to_type_preserves_callable_pattern_entries() -> None: - def process(image): - return image - - annotation = list[Callable | tuple[Callable, dict]] | None - pattern = [(process, {"gain": 2})] - - converted = ParameterFormService().convert_value_to_type( - pattern, - annotation, - "func", - ) - - assert converted == pattern - assert converted[0][0] is process - - -def test_convert_value_to_type_rejects_noncallable_pattern_entries() -> None: - annotation = list[Callable | tuple[Callable, dict]] | None - - with pytest.raises( - ValueError, - match=r"expected (?:typing\.)?Callable, got object", - ): - ParameterFormService().convert_value_to_type( - [(object(), {})], - annotation, - "func", - ) - - -def test_pep604_optional_dataclass_uses_shared_optional_authority() -> None: - annotation = NestedConfig | None - - assert ParameterTypeUtils.is_optional(annotation) - assert ParameterTypeUtils.is_optional_dataclass(annotation) - assert ParameterTypeUtils.get_optional_inner_type(annotation) is NestedConfig - assert isinstance( - create_parameter_info("config", annotation, None), - OptionalDataclassInfo, - ) - - -def test_declared_nested_field_path_uses_exact_field_name() -> None: - service = ParameterFormService() - - assert service.resolve_declared_field_path( - NestedConfigOwner, - "primary", - NestedConfig, - ) == "primary" - assert service.resolve_declared_field_path( - NestedConfigOwner, - "secondary", - NestedConfig, - ) == "secondary" - assert service.resolve_declared_field_path( - type(None), - "function_config", - NestedConfig, - ) == "function_config" - - -def test_declared_nested_field_path_fails_on_missing_or_mismatched_owner() -> None: - service = ParameterFormService() - - with pytest.raises(ValueError, match="no declared field 'missing'"): - service.resolve_declared_field_path( - NestedConfigOwner, - "missing", - NestedConfig, - ) - - with pytest.raises(ValueError, match="declares NestedConfig, not MatchClause"): - service.resolve_declared_field_path( - NestedConfigOwner, - "primary", - MatchClause, - ) diff --git a/tests/test_parameter_help_service.py b/tests/test_parameter_help_service.py deleted file mode 100644 index 37ce23bb..00000000 --- a/tests/test_parameter_help_service.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for shared parameter help introspection.""" - -from dataclasses import dataclass, fields -from typing import Annotated - -from pyqt_reactive.services.parameter_help_service import ( - dataclass_parameter_descriptions, - parameter_help_content, -) -from pyqt_reactive.strategies.preview_formatting import FormattingConfig - - -def test_preview_policy_fields_include_authored_help_for_hidden_controls() -> None: - descriptions = dataclass_parameter_descriptions(FormattingConfig) - assert set(descriptions) == {field.name for field in fields(FormattingConfig)} - assert { - name for name, description in descriptions.items() if not (description or "").strip() - } == set() - - -def test_dataclass_docstring_help_falls_back_when_source_is_unavailable(monkeypatch): - from pyqt_reactive.services import parameter_help_service - from pyqt_reactive.services.parameter_help_service import docstring_info_for_target - - @dataclass - class GeneratedLikeConfig: - """Generated-like config summary.""" - - value: int = 1 - - def raise_source_unavailable(_target): - raise OSError("could not find class definition") - - monkeypatch.setattr( - parameter_help_service.inspect, - "getsource", - raise_source_unavailable, - ) - - docstring_info = docstring_info_for_target(GeneratedLikeConfig) - - assert docstring_info.summary == "Generated-like config summary." - assert docstring_info.description is None - - -def test_parameter_help_projects_annotated_owner_type_without_metadata_repr() -> None: - content = parameter_help_content( - param_name="colormap", - param_type=Annotated[str, "non-empty"], - description="Napari colormap name.", - ) - - assert content.summary == "• colormap (str)" - assert content.description == "Napari colormap name." diff --git a/tests/test_path_name_roles.py b/tests/test_path_name_roles.py index fe175cdb..21886acf 100644 --- a/tests/test_path_name_roles.py +++ b/tests/test_path_name_roles.py @@ -2,7 +2,7 @@ import pytest -from pyqt_reactive.core.path_cache import ( +from formbridge.core.path_cache import ( DirectorySelection, FileSelection, PathCacheKey, diff --git a/tests/test_pattern_data_manager.py b/tests/test_pattern_data_manager.py deleted file mode 100644 index 65af2033..00000000 --- a/tests/test_pattern_data_manager.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest - -from pyqt_reactive.services.pattern_data_manager import PatternDataManager - - -def _func(image): - return image - - -def test_pattern_entries_are_callables_or_callable_kwargs_pairs(): - assert PatternDataManager.extract_func_and_kwargs(_func) == (_func, {}) - assert PatternDataManager.extract_func_and_kwargs((_func, {"a": 1})) == (_func, {"a": 1}) - - -@pytest.mark.parametrize("entry", ((_func, {}, "extra"), ("name", {}), None)) -def test_malformed_pattern_entry_is_refused_not_dropped(entry): - with pytest.raises(TypeError): - PatternDataManager.extract_func_and_kwargs(entry) - assert PatternDataManager.validate_pattern_structure([entry]) is False diff --git a/tests/test_preview_configuration_imports.py b/tests/test_preview_configuration_imports.py deleted file mode 100644 index ca21d63d..00000000 --- a/tests/test_preview_configuration_imports.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Preview declarations remain usable without loading the native UI runtime.""" - -import subprocess -import sys - - -def test_preview_configuration_import_does_not_load_qt(): - result = subprocess.run( - [ - sys.executable, - "-c", - """ -import builtins -import sys - -original_import = builtins.__import__ - -def reject_qt(name, globals=None, locals=None, fromlist=(), level=0): - if name == "PyQt6" or name.startswith("PyQt6."): - raise AssertionError(f"Preview declaration imported {name}") - return original_import(name, globals, locals, fromlist, level) - -builtins.__import__ = reject_qt -from pyqt_reactive.strategies.preview_formatting import FormattingConfig - -assert FormattingConfig() is not None -assert not any(name == "PyQt6" or name.startswith("PyQt6.") for name in sys.modules) -""", - ], - capture_output=True, - text=True, - timeout=30, - ) - assert result.returncode == 0, result.stderr diff --git a/tests/test_preview_formatter_registry.py b/tests/test_preview_formatter_registry.py deleted file mode 100644 index 597a0428..00000000 --- a/tests/test_preview_formatter_registry.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Preview formatter registration behavior.""" - -from dataclasses import dataclass - -import pytest -from objectstate.lazy_factory import LazyDataclassFactory - -from pyqt_reactive.protocols.preview_formatter import PreviewFormatterRegistry - - -def test_preview_formatter_resolves_nearest_nominal_base_registration() -> None: - class BaseConfig: - pass - - class SpecializedConfig(BaseConfig): - pass - - PreviewFormatterRegistry.register( - BaseConfig, - lambda _config, field_name: f"base:{field_name}", - ) - - assert PreviewFormatterRegistry.format_field(SpecializedConfig(), "value") == ("base:value") - - -def test_preview_formatter_does_not_hide_host_formatter_failures() -> None: - class BrokenConfig: - pass - - def broken_formatter(_config: object, _field_name: str) -> str: - raise RuntimeError("formatter failed") - - PreviewFormatterRegistry.register(BrokenConfig, broken_formatter) - - with pytest.raises(RuntimeError, match="formatter failed"): - PreviewFormatterRegistry.format_field(BrokenConfig(), "value") - - -def test_preview_formatter_ignores_lazy_wrapper_registration() -> None: - @dataclass - class BaseConfig: - value: int = 0 - - lazy_type = LazyDataclassFactory.make_lazy_simple( - BaseConfig, - "LazyPreviewFormatterRegistryBaseConfig", - ) - PreviewFormatterRegistry.register( - BaseConfig, - lambda _config, _field_name: "base", - ) - PreviewFormatterRegistry.register( - lazy_type, - lambda _config, _field_name: "copied", - ) - - assert PreviewFormatterRegistry.format_field(lazy_type(), "value") == "base" diff --git a/tests/test_preview_formatters.py b/tests/test_preview_formatters.py index a362e5e9..fab0d63c 100644 --- a/tests/test_preview_formatters.py +++ b/tests/test_preview_formatters.py @@ -13,11 +13,11 @@ ) from python_introspect import Enableable -from pyqt_reactive.strategies.preview_formatting import ( +from formbridge.strategies.preview_formatting import ( FormattingConfig, ObjectStatePreviewFormattingService, ) -from pyqt_reactive.utils.preview_formatters import ( +from formbridge.utils.preview_formatters import ( PreviewFieldFormatRequest, PreviewValueDetail, format_preview_value, @@ -28,9 +28,7 @@ ListItemFormat, _ManagerItemDisplayBuilder, ) -from pyqt_reactive.widgets.shared.manager_preview_formatting import ( - ManagerPreviewFieldFormatter, -) +from formbridge.managers.preview_formatting import ManagerPreviewFieldFormatter def _request( diff --git a/tests/test_reset_flash_changes.py b/tests/test_reset_flash_changes.py index dfdb79e6..e169af39 100644 --- a/tests/test_reset_flash_changes.py +++ b/tests/test_reset_flash_changes.py @@ -10,7 +10,7 @@ from PyQt6.QtWidgets import QDialog, QVBoxLayout from pyqt_reactive.animation.flash_mixin import WindowFlashOverlay -from pyqt_reactive.flash_trace import FlashTrace +from formbridge.flash_trace import FlashTrace from pyqt_reactive.forms.parameter_form_manager import ( FormManagerConfig, ParameterFormManager, diff --git a/tests/test_scoped_table_widget.py b/tests/test_scoped_table_widget.py index ec96e136..202a5be9 100644 --- a/tests/test_scoped_table_widget.py +++ b/tests/test_scoped_table_widget.py @@ -18,7 +18,7 @@ GroupBoxWithHelp, InlineDataclassGroupBox, ) -from pyqt_reactive.widgets.shared.scope_color_receiver import ScopeColorSchemeReceiver +from formbridge.theming.scope_color_receiver import ScopeColorSchemeReceiver from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme from pyqt_reactive.widgets.shared.scoped_table_widget import ScopedTableWidget diff --git a/tests/test_system_metrics_sampler.py b/tests/test_system_metrics_sampler.py deleted file mode 100644 index b0e7fc6f..00000000 --- a/tests/test_system_metrics_sampler.py +++ /dev/null @@ -1,264 +0,0 @@ -from __future__ import annotations - -import threading -from dataclasses import dataclass, fields, replace - -import pytest - -from pyqt_reactive.services import system_metrics_sampler as sampler_module -from pyqt_reactive.services.parameter_help_service import ( - dataclass_parameter_descriptions, -) -from pyqt_reactive.services.system_metrics_sampler import ( - BackgroundMetricPoller, - CpuMetrics, - GpuTemperatureSampling, - GpuMetrics, - MemoryMetrics, - PollingCadence, - PersistentNvidiaSmiPoller, - SystemMetrics, - SystemMetricsSampler, - SystemMetricsSamplerConfig, -) - - -def test_sampler_config_projects_complete_declaration_help() -> None: - descriptions = dataclass_parameter_descriptions(SystemMetricsSamplerConfig) - field_names = {field.name for field in fields(SystemMetricsSamplerConfig)} - - assert set(descriptions) == field_names - assert all(descriptions[name].strip() for name in field_names) - assert "GPU utilization and memory metrics" in descriptions[ - "enable_gpu_monitoring" - ] - assert "interval in seconds" in descriptions["cpu_frequency_refresh_seconds"] - - -@dataclass(frozen=True, slots=True) -class MemorySnapshot: - percent: float - used: int - total: int - available: int - - -class FakeCpuFrequencyPoller: - created: list["FakeCpuFrequencyPoller"] = [] - - def __init__(self, *, name, cadence, probe, default) -> None: - self.name = name - self.cadence = cadence - self.probe = probe - self.default = default - self.stopped = False - self.__class__.created.append(self) - - def latest(self) -> int: - return 2400 - - def stop(self) -> None: - self.stopped = True - - -class FakeGpuMetricsPoller: - created: list["FakeGpuMetricsPoller"] = [] - - def __init__(self, *, cadence, temperature_sampling) -> None: - self.cadence = cadence - self.temperature_sampling = temperature_sampling - self.stopped = False - self.__class__.created.append(self) - - def latest_metrics(self) -> GpuMetrics: - return GpuMetrics( - gpu_percent=12.5, - vram_percent=25.0, - gpu_name="Test GPU", - gpu_temp=61.0, - vram_used_mb=2048.0, - vram_total_mb=8192.0, - ) - - def stop(self) -> None: - self.stopped = True - - -def test_sampler_returns_typed_metrics_from_cached_background_providers(monkeypatch) -> None: - FakeCpuFrequencyPoller.created.clear() - FakeGpuMetricsPoller.created.clear() - monkeypatch.setattr(sampler_module.psutil, "cpu_count", lambda: 16) - monkeypatch.setattr(sampler_module.psutil, "cpu_percent", lambda interval=None: 7.5) - monkeypatch.setattr( - sampler_module.psutil, - "virtual_memory", - lambda: MemorySnapshot( - percent=40.0, - used=4 * 1024**3, - total=16 * 1024**3, - available=12 * 1024**3, - ), - ) - monkeypatch.setattr(sampler_module, "BackgroundMetricPoller", FakeCpuFrequencyPoller) - monkeypatch.setattr(sampler_module, "PersistentNvidiaSmiPoller", FakeGpuMetricsPoller) - - config = SystemMetricsSamplerConfig( - enable_gpu_monitoring=True, - gpu_temperature_monitoring=True, - cpu_frequency_monitoring=True, - gpu_refresh_seconds=0.25, - cpu_frequency_refresh_seconds=2.0, - ) - sampler = SystemMetricsSampler(config) - - metrics = sampler.collect_metrics() - - assert isinstance(metrics, SystemMetrics) - assert metrics.cpu == CpuMetrics( - cpu_percent=7.5, - cpu_cores=16, - cpu_freq_mhz=2400, - ) - assert metrics.memory == MemoryMetrics( - ram_percent=40.0, - ram_used_gb=4.0, - ram_total_gb=16.0, - ram_available_gb=12.0, - ) - assert metrics.gpu.gpu_name == "Test GPU" - assert metrics.gpu.vram_percent == 25.0 - assert metrics.gpu.gpu_percent == 12.5 - assert FakeCpuFrequencyPoller.created[0].cadence == PollingCadence(2.0) - assert FakeGpuMetricsPoller.created[0].cadence == PollingCadence(0.25) - - sampler.close() - - assert FakeCpuFrequencyPoller.created[0].stopped is True - assert FakeGpuMetricsPoller.created[0].stopped is True - - -def test_nvidia_smi_poller_parses_typed_metrics() -> None: - poller = PersistentNvidiaSmiPoller( - cadence=PollingCadence(0.5), - temperature_sampling=GpuTemperatureSampling.ENABLED, - ) - - metrics = poller._parse_gpu_line("12, 63, 2048, 8192, NVIDIA RTX") - - assert metrics == GpuMetrics( - gpu_percent=12.0, - vram_percent=25.0, - gpu_name="NVIDIA RTX", - gpu_temp=63.0, - vram_used_mb=2048.0, - vram_total_mb=8192.0, - ) - - -def test_nvidia_smi_poller_respects_temperature_policy() -> None: - poller = PersistentNvidiaSmiPoller( - cadence=PollingCadence(0.5), - temperature_sampling=GpuTemperatureSampling.DISABLED, - ) - - metrics = poller._parse_gpu_line("12, 63, 2048, 8192, NVIDIA RTX") - - assert metrics.gpu_temp == 0 - - -def test_enable_gpu_monitoring_leaf_disables_gpu_provider(monkeypatch) -> None: - monkeypatch.setattr(sampler_module, "PersistentNvidiaSmiPoller", FakeGpuMetricsPoller) - config = replace(SystemMetricsSamplerConfig(), enable_gpu_monitoring=False) - - sampler = SystemMetricsSampler(config) - - assert sampler._gpu_poller is None - assert sampler._cached_gpu_metrics().gpu_name == "GPU Monitoring Disabled" - sampler.close() - - -def test_gpu_temperature_monitoring_leaf_reaches_gpu_provider(monkeypatch) -> None: - FakeGpuMetricsPoller.created.clear() - monkeypatch.setattr(sampler_module, "PersistentNvidiaSmiPoller", FakeGpuMetricsPoller) - config = replace(SystemMetricsSamplerConfig(), gpu_temperature_monitoring=False) - - sampler = SystemMetricsSampler(config) - - assert ( - FakeGpuMetricsPoller.created[-1].temperature_sampling - is GpuTemperatureSampling.DISABLED - ) - sampler.close() - - -def test_cpu_frequency_monitoring_leaf_disables_frequency_provider(monkeypatch) -> None: - monkeypatch.setattr(sampler_module, "BackgroundMetricPoller", FakeCpuFrequencyPoller) - config = replace(SystemMetricsSamplerConfig(), cpu_frequency_monitoring=False) - - sampler = SystemMetricsSampler(config) - - assert sampler._cpu_frequency_poller is None - assert sampler._cached_cpu_frequency() == 0 - sampler.close() - - -def test_gpu_refresh_seconds_leaf_reaches_gpu_provider(monkeypatch) -> None: - FakeGpuMetricsPoller.created.clear() - monkeypatch.setattr(sampler_module, "PersistentNvidiaSmiPoller", FakeGpuMetricsPoller) - config = replace(SystemMetricsSamplerConfig(), gpu_refresh_seconds=0.25) - - sampler = SystemMetricsSampler(config) - - assert FakeGpuMetricsPoller.created[-1].cadence == PollingCadence(0.25) - sampler.close() - - -def test_cpu_frequency_refresh_seconds_leaf_reaches_frequency_provider( - monkeypatch, -) -> None: - FakeCpuFrequencyPoller.created.clear() - monkeypatch.setattr(sampler_module, "BackgroundMetricPoller", FakeCpuFrequencyPoller) - config = replace(SystemMetricsSamplerConfig(), cpu_frequency_refresh_seconds=2.0) - - sampler = SystemMetricsSampler(config) - - assert FakeCpuFrequencyPoller.created[-1].cadence == PollingCadence(2.0) - sampler.close() - - -def test_background_metric_poller_is_lazy_and_does_not_restart_after_stop() -> None: - probe_called = threading.Event() - - def probe() -> int: - probe_called.set() - return 3200 - - poller = BackgroundMetricPoller( - name="TestMetricPoller", - cadence=PollingCadence(60.0), - probe=probe, - default=0, - ) - - assert probe_called.is_set() is False - assert poller.latest() in {0, 3200} - assert probe_called.wait(timeout=1.0) is True - assert poller.latest() == 3200 - - poller.stop() - probe_called.clear() - - assert poller.latest() == 3200 - assert probe_called.is_set() is False - - -@pytest.mark.parametrize( - "config_kwargs", - ( - {"gpu_refresh_seconds": 0}, - {"cpu_frequency_refresh_seconds": 0}, - ), -) -def test_sampler_config_rejects_nonpositive_refresh_intervals(config_kwargs) -> None: - with pytest.raises(ValueError): - SystemMetricsSamplerConfig(**config_kwargs) diff --git a/tests/test_system_monitor_config.py b/tests/test_system_monitor_config.py index 8e8a718d..74590fa5 100644 --- a/tests/test_system_monitor_config.py +++ b/tests/test_system_monitor_config.py @@ -7,8 +7,8 @@ import pytest import pyqt_reactive.widgets.system_monitor as system_monitor_module -from pyqt_reactive.services.system_metrics_sampler import SystemMetricsSamplerConfig -from pyqt_reactive.services.system_monitor_config import ( +from formbridge.services.system_metrics_sampler import SystemMetricsSamplerConfig +from formbridge.services.system_monitor_config import ( PerformanceGraphColor, PerformanceMonitorColors, PerformanceMonitorConfig, diff --git a/tests/test_ui_thread_dispatch.py b/tests/test_ui_thread_dispatch.py index aa28c5ce..2d7c8bc8 100644 --- a/tests/test_ui_thread_dispatch.py +++ b/tests/test_ui_thread_dispatch.py @@ -7,20 +7,20 @@ import pytest -from pyqt_reactive.services.ui_thread_dispatch import ( - UiThreadDispatcher, - UiThreadDispatcherClosedError, - UiThreadDispatchTimeoutError, +from formbridge.services.main_thread_dispatch import ( + MainThreadDispatcherClosedError, + MainThreadDispatchTimeoutError, ) +from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher -class CancelledBeforeStartError(UiThreadDispatchTimeoutError): +class CancelledBeforeStartError(MainThreadDispatchTimeoutError): """A consumer's nominal projection of the owner's cancellation.""" class ProjectedCancellationDispatcher(UiThreadDispatcher): @staticmethod - def _dispatch_timeout_error() -> UiThreadDispatchTimeoutError: + def _dispatch_timeout_error() -> MainThreadDispatchTimeoutError: return CancelledBeforeStartError("Cancelled before callback invocation.") @@ -33,12 +33,12 @@ def test_close_cancels_a_posted_callback_before_qt_executes_it(qapp) -> None: qapp.processEvents() assert calls == [] - with pytest.raises(UiThreadDispatcherClosedError): + with pytest.raises(MainThreadDispatcherClosedError): dispatcher.post(lambda: None) @pytest.mark.parametrize("dispatcher_type,error_type", ( - (UiThreadDispatcher, UiThreadDispatchTimeoutError), + (UiThreadDispatcher, MainThreadDispatchTimeoutError), (ProjectedCancellationDispatcher, CancelledBeforeStartError), )) def test_call_timeout_cancels_callback_still_queued_for_qt(qapp, dispatcher_type, error_type) -> None: @@ -66,7 +66,7 @@ def call_from_worker() -> None: @pytest.mark.parametrize("queued", (False, True), ids=("ui-direct", "queued-started")) def test_callback_timeout_after_side_effect_keeps_its_own_outcome(qapp, qtbot, queued): dispatcher = ProjectedCancellationDispatcher() - original = UiThreadDispatchTimeoutError("Started callback failed after its side effect.") + original = MainThreadDispatchTimeoutError("Started callback failed after its side effect.") calls, errors = [], [] def callback(): @@ -120,7 +120,7 @@ def call_from_worker() -> None: assert not worker.is_alive() assert len(errors) == 1 - assert isinstance(errors[0], UiThreadDispatcherClosedError) + assert isinstance(errors[0], MainThreadDispatcherClosedError) def test_call_waits_for_atomic_callback_that_started_before_timeout(qapp, qtbot) -> None: diff --git a/tests/test_weak_events.py b/tests/test_weak_events.py deleted file mode 100644 index e2649ce8..00000000 --- a/tests/test_weak_events.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Exact weak subscription ownership and immutable notification snapshots.""" - -import gc -import weakref - -from pyqt_reactive.weak_events import WeakCallbackEvent, WeakSubscription - - -class Subscriber: - def __init__(self, observations): - self.observations = observations - - def observe(self, value): - self.observations.append(value) - - -def test_subscription_token_owns_neither_event_nor_callback(): - event = WeakCallbackEvent() - subscriber = Subscriber([]) - event_reference = weakref.ref(event) - subscriber_reference = weakref.ref(subscriber) - token = event.subscribe(subscriber.observe) - assert isinstance(token, WeakSubscription) - assert weakref.ref(token)() is token - del event, subscriber - gc.collect() - assert event_reference() is None - assert subscriber_reference() is None - token.cancel() - - -def test_duplicate_callback_returns_exact_same_cancellation_token(): - event = WeakCallbackEvent() - observations = [] - subscriber = Subscriber(observations) - token = event.subscribe(subscriber.observe) - assert event.subscribe(subscriber.observe) is token - event.emit("changed") - assert observations == ["changed"] - token.cancel() - token.cancel() - event.emit("cancelled") - assert observations == ["changed"] - - -def test_old_token_cannot_cancel_callback_registered_again(): - event = WeakCallbackEvent() - observations = [] - subscriber = Subscriber(observations) - old = event.subscribe(subscriber.observe) - old.cancel() - current = event.subscribe(subscriber.observe) - assert current is not old - old.cancel() - event.emit("changed") - assert observations == ["changed"] - - -def test_cancel_uses_identity_not_equal_callback_comparison(): - class EqualSubscriber(Subscriber): - def __eq__(self, other): - return isinstance(other, EqualSubscriber) - - event = WeakCallbackEvent() - observations = [] - first = EqualSubscriber(observations) - second = EqualSubscriber(observations) - token = event.subscribe(first.observe) - event.subscribe(second.observe) - token.cancel() - event.emit("changed") - assert observations == ["changed"] - - -def test_cancellation_during_emit_preserves_emission_start_snapshot(): - event = WeakCallbackEvent() - observations = [] - - def first(value): - observations.append(("first", value)) - second_token.cancel() - - def second(value): - observations.append(("second", value)) - - event.subscribe(first) - second_token = event.subscribe(second) - event.emit(1) - event.emit(2) - assert observations == [("first", 1), ("second", 1), ("first", 2)] - - -def test_token_can_cancel_dead_callback_without_resolving_it(): - event = WeakCallbackEvent() - subscriber = Subscriber([]) - token = event.subscribe(subscriber.observe) - del subscriber - gc.collect() - assert token.callback_reference() is None - token.cancel() - event.emit("changed") diff --git a/tests/test_widget_projection_safety.py b/tests/test_widget_projection_safety.py index 72174762..76615791 100644 --- a/tests/test_widget_projection_safety.py +++ b/tests/test_widget_projection_safety.py @@ -345,7 +345,7 @@ def test_recursive_mapping_items_reach_object_state_as_declared_types(qapp): """Literal parsing and the semantic converter preserve nested item identity.""" from objectstate import ObjectState, ObjectStateRegistry, set_base_config_type - from pyqt_reactive.forms.parameter_form_service import ParameterFormService + from formbridge.forms.parameter_form_service import ParameterFormService from pyqt_reactive.forms.widget_strategies import create_pyqt6_widget class Mode(Enum): @@ -515,19 +515,3 @@ def test_no_parallel_widget_factory_or_string_fallback_authority(): and node.attr == "create_string" ] assert fallback_calls == [] - - -def test_union_and_nested_field_semantics_have_no_parallel_fallbacks(): - """Static gate: union and field identity remain derived from declarations.""" - forms_root = Path(__file__).parents[1] / "src" / "pyqt_reactive" / "forms" - parameter_info_source = (forms_root / "parameter_info_types.py").read_text() - parameter_type_source = (forms_root / "parameter_type_utils.py").read_text() - service_source = (forms_root / "parameter_form_service.py").read_text() - - assert "get_origin(param_type) is Union" not in parameter_info_source - assert "get_origin(param_type) is Union" not in parameter_type_source - assert "get_obj_type_for_param" not in parameter_type_source - assert "resolve_union_type" not in parameter_type_source - assert "get_field_path_with_fail_loud" not in service_source - assert "__name__.lower().replace('config', '')" not in service_source - assert "Handle Union types" not in service_source diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 96f6efc1..20c393fe 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -316,7 +316,7 @@ def test_function_form_uses_signature_enums_and_concrete_nested_config(qapp): FormManagerConfig, ParameterFormManager, ) - from pyqt_reactive.services.function_pattern_code_document import ( + from formbridge.services.function_pattern_code_document import ( FunctionPatternCodeDocumentService, ) from pyqt_reactive.forms.widget_strategies import TypedLiteralUnionEdit @@ -386,7 +386,7 @@ def test_editable_function_pattern_resolves_quoted_enum_annotations(qapp): FormManagerConfig, ParameterFormManager, ) - from pyqt_reactive.services.function_pattern_code_document import ( + from formbridge.services.function_pattern_code_document import ( EditableFunctionPatternCallable, FunctionPatternCodeDocumentService, ) @@ -3213,7 +3213,7 @@ def test_parameter_reset_overwrites_invalid_typed_literal_editor(qapp): import pytest - from pyqt_reactive.forms.parameter_info_types import GenericInfo + from formbridge.forms.parameter_info_types import GenericInfo from pyqt_reactive.forms.widget_strategies import TypedLiteralContainerEdit from pyqt_reactive.services.parameter_ops_service import ParameterOpsService from pyqt_reactive.services.widget_service import WidgetService @@ -3521,11 +3521,8 @@ def test_manager_reorder_resolves_transport_safe_identity_after_qt_move(qapp): from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QListWidget, QListWidgetItem - from pyqt_reactive.widgets.shared.manager_item_hooks import ( - DictItemIdProjection, - ManagerItemHooks, - ) - from pyqt_reactive.widgets.shared.manager_reorder_controller import ( + from formbridge.managers.item_hooks import DictItemIdProjection, ManagerItemHooks + from formbridge.managers.reorder_controller import ( ManagerReorderController, ManagerReorderOperations, ) diff --git a/tests/test_window_code_document.py b/tests/test_window_code_document.py deleted file mode 100644 index e1ce1320..00000000 --- a/tests/test_window_code_document.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Document-local revision ownership for managed window code documents.""" - -from __future__ import annotations - -import pytest - -from pyqt_reactive.protocols import CodegenProviderABC, codegen_provider -from pyqt_reactive.services.window_code_document import ( - WindowCodeDocument, - WindowCodeDocumentDriver, -) - - -class MutableCodeDocumentDriver(WindowCodeDocumentDriver): - def __init__(self, source: str) -> None: - self.source = source - - def read_document(self, clean: bool = True) -> WindowCodeDocument: - del clean - return WindowCodeDocument(title="Mutable document", source=self.source) - - def validate_source(self, source: str) -> None: - if source == "invalid": - raise ValueError("invalid source") - - def apply_source(self, source: str) -> None: - self.validate_source(source) - self.source = source - - -def test_default_revision_tracks_document_content_not_rejected_validation() -> None: - driver = MutableCodeDocumentDriver("original") - original_revision = driver.current_revision_token() - - assert driver.writable() - with pytest.raises(ValueError, match="invalid source"): - driver.validate_source("invalid") - - assert driver.current_revision_token() == original_revision - driver.apply_source("replacement") - assert driver.current_revision_token() != original_revision - - -def test_declaration_backed_source_comparison_uses_codegen_normalization( - monkeypatch, -) -> None: - class NormalizingCodegenProvider(CodegenProviderABC): - def render_assignment( - self, - value: object, - *, - assignment_name: str, - header: str, - clean_mode: bool, - ) -> str: - raise AssertionError("source comparison must not render assignments") - - def normalize_source( - self, - source: str, - *, - declaration_type: type, - clean_mode: bool, - ) -> str: - assert declaration_type is int - assert clean_mode is True - namespace: dict[str, object] = {} - exec(source, namespace) - return f"value = {namespace['value']}\n" - - class DeclarationCodeDocumentDriver(MutableCodeDocumentDriver): - def read_document(self, clean: bool = True) -> WindowCodeDocument: - del clean - return WindowCodeDocument( - title="Declaration document", - source=self.source, - declaration_type=int, - ) - - monkeypatch.setattr( - codegen_provider, - "_codegen_provider", - NormalizingCodegenProvider(), - ) - driver = DeclarationCodeDocumentDriver("value = 1\n") - - assert driver.source_is_current("value = int(1)\n") - assert not driver.source_is_current("value = 2\n") - assert not driver.source_is_current("unsupported source") - - -def test_codegen_registration_requires_nominal_contract(monkeypatch) -> None: - class StructuralProvider: - def render_assignment(self, *args, **kwargs) -> str: - return "" - - def normalize_source(self, *args, **kwargs) -> str: - return "" - - monkeypatch.setattr(codegen_provider, "_codegen_provider", None) - - with pytest.raises(TypeError): - CodegenProviderABC() - - with pytest.raises(TypeError, match="inherit CodegenProviderABC"): - codegen_provider.register_codegen_provider(StructuralProvider()) - - assert codegen_provider.get_codegen_provider() is None diff --git a/tests/test_window_snapshot.py b/tests/test_window_snapshot.py index b7f436b1..ab128ecc 100644 --- a/tests/test_window_snapshot.py +++ b/tests/test_window_snapshot.py @@ -631,7 +631,7 @@ def test_nonmaximum_native_paint_is_preserved_only_as_failure_diagnostics( monkeypatch, ): from pyqt_reactive.animation.flash_mixin import WindowFlashOverlay, _GlobalFlashCoordinator - from pyqt_reactive.flash_trace import FlashTrace, FlashTraceRecord + from formbridge.flash_trace import FlashTrace, FlashTraceRecord window, form = observed_form overlay = WindowFlashOverlay.get_for_window(window) @@ -673,16 +673,6 @@ def stop_after_actual_paint(frame): overlay.frame_painted.disconnect(stop_after_actual_paint) -def test_legacy_trace_import_preserves_the_canonical_declaration_and_ring(): - from pyqt_reactive import flash_trace as canonical - from pyqt_reactive.animation import flash_trace as legacy - - assert legacy.FlashTrace is canonical.FlashTrace - assert legacy.FlashTraceRecord is canonical.FlashTraceRecord - assert legacy.flash_trace is canonical.flash_trace - assert legacy.logger is canonical.logger - - def test_native_stalled_render_presents_the_declared_maximum_without_duration_override( qtbot, observed_form, diff --git a/tests/test_zmq_server_browser_lifecycle.py b/tests/test_zmq_server_browser_lifecycle.py index 0538d5be..6153808b 100644 --- a/tests/test_zmq_server_browser_lifecycle.py +++ b/tests/test_zmq_server_browser_lifecycle.py @@ -16,7 +16,7 @@ from zmqruntime.startup import EndpointStartupPhase, EndpointStartupStatus from zmqruntime.transport import TransportEndpoint, get_default_transport_mode -from pyqt_reactive.services.zmq_server_scan_service import ( +from formbridge.services.zmq_server_scan_service import ( EndpointObservationAuthority, EndpointObservationSnapshot, EndpointScanResult, From 6ac7ea186d3800e87299e7d6547321b815d29281 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 14:15:59 -0400 Subject: [PATCH 2/5] 0.6.0: depend on formbridge; Qt half of the running-UI bridge The window, widget-tree and snapshot projections and the ObjectState code-document window providers of the UI bridge move here from OpenHCS (bridge.windows, bridge.object_state_documents) over formbridge's DTOs and contracts; an application's main window mixes in bridge.host.UiBridgeHostWindow. services.window_snapshot keeps the Qt capture service; the request, frame condition and receipt types are formbridge's, and each capture scope and frame-observation kind has a registered Qt implementation instead of a callable stored in an enum member. AbstractManagerWidget takes formbridge's UiHostServices. pyqt-reactive now holds only Qt code (test_only_qt_code); docutils and pyzmq leave its dependencies with the modules that used them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 37 + docs/api/modules.rst | 2 +- .../architecture/list_item_preview_system.rst | 2 +- docs/architecture/log_viewer_system.rst | 2 +- .../server_scanning_and_polling.rst | 6 +- .../service-layer-architecture.rst | 2 +- docs/architecture/service_registry.rst | 4 +- docs/architecture/system_monitor_core.rst | 4 +- .../tree_aggregation_strategy.rst | 2 +- .../zmq_server_browser_widget.rst | 2 +- docs/archive/IMPLEMENTATION_NOTES.md | 2 +- docs/examples/ui.rst | 2 +- pyproject.toml | 3 +- requirements-ci.txt | 3 +- src/pyqt_reactive/__init__.py | 2 +- src/pyqt_reactive/animation/flash_mixin.py | 2 +- src/pyqt_reactive/bridge/__init__.py | 0 src/pyqt_reactive/bridge/host.py | 30 + .../bridge/object_state_documents.py | 332 ++ src/pyqt_reactive/bridge/windows.py | 2791 +++++++++++++++++ src/pyqt_reactive/forms/form_init_service.py | 1 - .../forms/widget_creation_config.py | 4 +- .../services/parameter_ops_service.py | 24 +- src/pyqt_reactive/services/window_snapshot.py | 333 +- .../widgets/function_list_editor.py | 1 - .../widgets/shared/abstract_manager_widget.py | 3 +- src/pyqt_reactive/widgets/system_monitor.py | 21 +- tests/test_only_qt_code.py | 64 + tests/test_window_snapshot.py | 4 +- tests/test_window_snapshot_bindings.py | 2 + 30 files changed, 3371 insertions(+), 316 deletions(-) create mode 100644 src/pyqt_reactive/bridge/__init__.py create mode 100644 src/pyqt_reactive/bridge/host.py create mode 100644 src/pyqt_reactive/bridge/object_state_documents.py create mode 100644 src/pyqt_reactive/bridge/windows.py create mode 100644 tests/test_only_qt_code.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4d681d..ace64fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## 0.6.0 + +The Qt-free tier moved to [formbridge](https://github.com/OpenHCSDev/formbridge), +which pyqt-reactive now depends on (`formbridge>=0.1,<0.2`). Import each moved +module from `formbridge` with the same path; nothing is re-exported here. + +Removed (now in formbridge): `forms.parameter_form_service`, +`forms.parameter_info_types`, `forms.parameter_type_utils` and the other +Qt-free form modules; `services.help_document`, `services.parameter_help_service`, +`services.function_pattern_code_document`, `services.window_code_document`, +`services.scope_token_service`, `services.pattern_data_manager`, +`services.async_operation_executor` and the other Qt-free services; +`protocols.form_config`, `codegen_provider`, `component_selection`, +`log_providers`, `preview_formatter`; `strategies`; `utils.preview_formatters` +and the log utilities; `core.path_cache`, `core.log_utils`, +`core.performance_monitor`, `core.sort_utils`; `flash_trace`, +`pattern_metadata`, `weak_events`; the Qt-free manager hooks in +`widgets.shared` (now `formbridge.managers`) and the scope colour strategy +(now `formbridge.theming`). The unused `animation.flash_trace` shim is deleted. + +Changed: + +- `UiThreadDispatcher` is the Qt delivery of formbridge's `MainThreadDispatcher`; + its errors are formbridge's `MainThreadDispatch*` errors. +- `services.window_snapshot` keeps the Qt capture service. The request, + frame condition and receipt types are formbridge's; each capture scope and + frame-observation kind has a registered Qt implementation + (`QtCaptureScope`, `QtFrameObservation`) instead of a callable stored in an + enum member. + +Added: + +- `bridge.windows`, `bridge.object_state_documents` and `bridge.host`: the Qt + window, widget-tree and snapshot projections and the ObjectState code-document + providers of formbridge's running-UI bridge. An application's main window + mixes in `UiBridgeHostWindow`. + ## 0.5.0 Generic Qt machinery moved here from OpenHCS, with the application's choices diff --git a/docs/api/modules.rst b/docs/api/modules.rst index c3fbc98b..8f2a10ec 100644 --- a/docs/api/modules.rst +++ b/docs/api/modules.rst @@ -22,7 +22,7 @@ Forms ``pyqt_reactive.forms.widget_strategies`` Nominal widget creation strategies. -``pyqt_reactive.forms.parameter_value_contracts`` +``formbridge.forms.parameter_value_contracts`` Typed form contexts, parameter mappings, and widget values. Services and protocols diff --git a/docs/architecture/list_item_preview_system.rst b/docs/architecture/list_item_preview_system.rst index 69f952d7..276b22c5 100644 --- a/docs/architecture/list_item_preview_system.rst +++ b/docs/architecture/list_item_preview_system.rst @@ -54,7 +54,7 @@ ordinary segments supplied by the owning manager or formatting service. Preview policy and row controls ------------------------------- -``FormattingConfig`` in ``pyqt_reactive.strategies.preview_formatting`` owns +``FormattingConfig`` in ``formbridge.strategies.preview_formatting`` owns shared display preferences, including detail visibility, collection formatting and the default wrapping policy. Pass that declaration to the manager's ``set_preview_config()`` method to update formatting and layout together. diff --git a/docs/architecture/log_viewer_system.rst b/docs/architecture/log_viewer_system.rst index e91c4d6d..c90af61e 100644 --- a/docs/architecture/log_viewer_system.rst +++ b/docs/architecture/log_viewer_system.rst @@ -40,7 +40,7 @@ available. .. code-block:: python - from pyqt_reactive.protocols.log_providers import ( + from formbridge.protocols.log_providers import ( LogDiscoveryProviderABC, ServerScanProviderABC, register_log_discovery_provider, diff --git a/docs/architecture/server_scanning_and_polling.rst b/docs/architecture/server_scanning_and_polling.rst index 7976538d..48d3a051 100644 --- a/docs/architecture/server_scanning_and_polling.rst +++ b/docs/architecture/server_scanning_and_polling.rst @@ -4,9 +4,9 @@ Server Scanning and Polling Modules ------- -- ``pyqt_reactive.services.zmq_server_scan_service`` -- ``pyqt_reactive.services.interval_snapshot_poller`` -- ``pyqt_reactive.services.zmq_server_info`` +- ``formbridge.services.zmq_server_scan_service`` +- ``formbridge.services.interval_snapshot_poller`` +- ``formbridge.services.zmq_server_info`` ZMQServerScanService -------------------- diff --git a/docs/architecture/service-layer-architecture.rst b/docs/architecture/service-layer-architecture.rst index 9df46e23..712178aa 100644 --- a/docs/architecture/service-layer-architecture.rst +++ b/docs/architecture/service-layer-architecture.rst @@ -52,7 +52,7 @@ plugin families, or domain behavior. Declaration-to-help projection ------------------------------ -``pyqt_reactive.services.parameter_help_service`` is a Qt-free projection +``formbridge.services.parameter_help_service`` is a Qt-free projection boundary shared by forms, help windows, and agent-facing consumers. It accepts a callable, authored dataclass, or registered ObjectState lazy dataclass and returns typed records such as ``DocstringInfo`` and ``ParameterHelpContent``. diff --git a/docs/architecture/service_registry.rst b/docs/architecture/service_registry.rst index ae2b04bd..15a8202e 100644 --- a/docs/architecture/service_registry.rst +++ b/docs/architecture/service_registry.rst @@ -1,7 +1,7 @@ Service registry ================ -``pyqt_reactive.services.service_registry.ServiceRegistry`` is a type-keyed +``formbridge.services.service_registry.ServiceRegistry`` is a type-keyed application assembly tool. Register an instance with ``register(type, instance)`` and resolve that exact type with ``get(type)``. ``get`` returns ``None`` when a type is absent; callers that require a service should validate @@ -9,7 +9,7 @@ that result at their boundary. .. code-block:: python - from pyqt_reactive.services.service_registry import ServiceRegistry + from formbridge.services.service_registry import ServiceRegistry ServiceRegistry.register(SearchProvider, search_provider) provider = ServiceRegistry.get(SearchProvider) diff --git a/docs/architecture/system_monitor_core.rst b/docs/architecture/system_monitor_core.rst index 5156ceb0..7832a126 100644 --- a/docs/architecture/system_monitor_core.rst +++ b/docs/architecture/system_monitor_core.rst @@ -3,7 +3,7 @@ System Monitor Core **Framework-agnostic system monitoring core for CPU, RAM, GPU, and VRAM metrics.** -**Module**: ``pyqt_reactive.services.system_monitor_core`` +**Module**: ``formbridge.services.system_monitor_core`` Overview -------- @@ -52,7 +52,7 @@ Basic Usage .. code-block:: python - from pyqt_reactive.services.system_monitor_core import SystemMonitorCore + from formbridge.services.system_monitor_core import SystemMonitorCore # Create monitor with history length monitor = SystemMonitorCore(history_length=300) diff --git a/docs/architecture/tree_aggregation_strategy.rst b/docs/architecture/tree_aggregation_strategy.rst index 992a774c..d9c64e5b 100644 --- a/docs/architecture/tree_aggregation_strategy.rst +++ b/docs/architecture/tree_aggregation_strategy.rst @@ -4,7 +4,7 @@ Tree Aggregation Strategy Module ------ -``pyqt_reactive.strategies.tree_aggregation`` +``formbridge.strategies.tree_aggregation`` Purpose ------- diff --git a/docs/architecture/zmq_server_browser_widget.rst b/docs/architecture/zmq_server_browser_widget.rst index 983859da..c63193c2 100644 --- a/docs/architecture/zmq_server_browser_widget.rst +++ b/docs/architecture/zmq_server_browser_widget.rst @@ -47,7 +47,7 @@ Related Infrastructure for standard manager header/layout assembly - ``pyqt_reactive.widgets.shared.tree_rebuild_coordinator`` for state-preserving rebuilds -- ``pyqt_reactive.services.zmq_server_scan_service`` +- ``formbridge.services.zmq_server_scan_service`` for typed parallel ping scans Design Outcome diff --git a/docs/archive/IMPLEMENTATION_NOTES.md b/docs/archive/IMPLEMENTATION_NOTES.md index a013a1e2..e7df737a 100644 --- a/docs/archive/IMPLEMENTATION_NOTES.md +++ b/docs/archive/IMPLEMENTATION_NOTES.md @@ -69,7 +69,7 @@ if groupbox and hasattr(groupbox, 'title_layout'): break # Update reset button styling for ALL reset buttons -from pyqt_reactive.utils.styling_utils import update_reset_button_styling +from formbridge.utils.styling_utils import update_reset_button_styling for field_name, reset_button in source.reset_buttons.items(): update_reset_button_styling(reset_button, source.state, source.field_id, field_name) ``` diff --git a/docs/examples/ui.rst b/docs/examples/ui.rst index 5332c289..bc3f2844 100644 --- a/docs/examples/ui.rst +++ b/docs/examples/ui.rst @@ -138,7 +138,7 @@ The generic service registry is keyed by a service type: from abc import ABC, abstractmethod - from pyqt_reactive.services.service_registry import ServiceRegistry + from formbridge.services.service_registry import ServiceRegistry class StatusSink(ABC): @abstractmethod diff --git a/pyproject.toml b/pyproject.toml index 3d7300c0..fcf79676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,14 +25,13 @@ keywords = ["pyqt6", "forms", "reactive", "gui", "widgets", "dataclass"] dependencies = [ "annotated-types>=0.7.0", - "docutils>=0.20", + "formbridge>=0.1,<0.2", "magicgui>=0.7.0", "metaclass-registry>=0.1.5", "PyQt6>=6.4.0", "objectstate>=2.0,<3", "psutil>=5.9", "Pygments>=2.15", - "pyzmq>=22.0", "python-introspect>=0.2.6,<0.3", "zmqruntime>=0.6,<0.7", ] diff --git a/requirements-ci.txt b/requirements-ci.txt index 8d6bb699..37163b6b 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,2 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. -# Empty: every first-party dependency is released (objectstate 2.0.0, python-introspect 0.2.6). +# Remove once formbridge 0.1.0 is on PyPI. +formbridge @ git+https://github.com/OpenHCSDev/formbridge.git@efd973fde8ea4bcf3caeb32c722785b9608b0405 diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index 018b69cc..3cd734e6 100644 --- a/src/pyqt_reactive/__init__.py +++ b/src/pyqt_reactive/__init__.py @@ -19,7 +19,7 @@ - Cross-window reactive updates """ -__version__ = "0.5.0" +__version__ = "0.6.0" # Public API will be populated as modules are added __all__ = [ diff --git a/src/pyqt_reactive/animation/flash_mixin.py b/src/pyqt_reactive/animation/flash_mixin.py index 6fc6f59b..a9206a9a 100644 --- a/src/pyqt_reactive/animation/flash_mixin.py +++ b/src/pyqt_reactive/animation/flash_mixin.py @@ -81,7 +81,7 @@ from objectstate.time_travel_profile import TimeTravelProfiler from pyqt_reactive.animation.flash_config import FlashConfig, FlashPlayback, FlashPhase, get_flash_config from formbridge.forms.layout_constants import default_container_corner_radius_px -from pyqt_reactive.services.window_snapshot import FlashPaintElement, FlashPaintFrame +from formbridge.services.window_snapshot import FlashPaintElement, FlashPaintFrame from formbridge.flash_trace import flash_trace from objectstate import ScopeKey diff --git a/src/pyqt_reactive/bridge/__init__.py b/src/pyqt_reactive/bridge/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pyqt_reactive/bridge/host.py b/src/pyqt_reactive/bridge/host.py new file mode 100644 index 00000000..f5f68c5f --- /dev/null +++ b/src/pyqt_reactive/bridge/host.py @@ -0,0 +1,30 @@ +"""The main window whose windows, panes and widgets a running-UI bridge introspects.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, ClassVar + +from formbridge.bridge.identities import UiStableWidgetIdentityDeclaration + +if TYPE_CHECKING: + from PyQt6.QtWidgets import QWidget + + from pyqt_reactive.widgets.shared.dock_layout import DockPanes + + +class UiBridgeHostWindow: + """Mixin for an application's main window served by the UI bridge. + + ``window_specs`` maps each managed window id to a spec with ``window_id`` + and ``title``; ``embedded_widgets`` holds the docked panes. + """ + + UI_BRIDGE_WIDGET_IDENTITY: ClassVar[type[UiStableWidgetIdentityDeclaration]] + embedded_widgets: DockPanes + window_specs: Mapping[str, object] + + def show_window(self, window_id: str, hide_if_startup: bool = True) -> QWidget: + """Open or raise one managed window.""" + + raise NotImplementedError(f"{type(self).__name__} does not open managed windows.") diff --git a/src/pyqt_reactive/bridge/object_state_documents.py b/src/pyqt_reactive/bridge/object_state_documents.py new file mode 100644 index 00000000..642f1e4c --- /dev/null +++ b/src/pyqt_reactive/bridge/object_state_documents.py @@ -0,0 +1,332 @@ +"""Code documents of ObjectState scopes and managed windows, served from the running Qt UI.""" + +from __future__ import annotations + +from collections.abc import Callable + +from formbridge.bridge.contracts import ( + UiBridgeSnapshotProviderABC, + UiCodeDocumentProviderIdentity, +) +from formbridge.bridge.dto import ( + UiCodeDocumentSummary, +) +from formbridge.bridge.identities import UiStableWindowIdentityDeclaration +from formbridge.bridge.object_state import ( + OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID, + OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX, + OBJECT_STATE_SCOPE_PROVIDER_ID, + WINDOW_CODE_DOCUMENT_ID, + WINDOW_CODE_DOCUMENT_PREFIX, + ObjectStateScopeCodeDocumentAddress, + ObjectStateScopeProvider, + WindowCodeDocumentAddress, + WindowCodeDocumentDriverBackedProvider, + agent_object_state_scope_id, +) +from formbridge.bridge.registry import UiBridgeProviderSetABC, UiBridgeRegistrationContext +from formbridge.contracts import SCHEMA_VERSION +from formbridge.services.function_pattern_code_document import ( + FunctionPatternScopeCodeDocumentDriver, +) +from objectstate import ObjectStateRegistry + +from pyqt_reactive.services.scope_window_navigation import ScopeWindowNavigationService +from pyqt_reactive.services.window_manager import WindowManager +from pyqt_reactive.services.window_navigation import WindowNavigationRequest + + +class ObjectStateScopeCodeDocumentProvider(WindowCodeDocumentDriverBackedProvider): + """Dynamic code-document provider for WindowManager-backed ObjectState scopes.""" + + identity = UiCodeDocumentProviderIdentity( + document_id=OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID, + widget_id=OBJECT_STATE_SCOPE_CODE_DOCUMENT_ID, + title="ObjectState scope code documents", + ) + + def __init__( + self, + snapshot_provider: UiBridgeSnapshotProviderABC, + before_mutation: Callable[[str], None] | None = None, + ) -> None: + super().__init__(snapshot_provider) + self._before_mutation = before_mutation + + def _before_apply(self, address: ObjectStateScopeCodeDocumentAddress) -> None: + if self._before_mutation is not None: + self._before_mutation(self._scope_id(address)) + + def handles(self, document_id: str) -> bool: + return document_id == self.identity.document_id or document_id.startswith( + OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX + ) + + def summaries(self) -> tuple[UiCodeDocumentSummary, ...]: + summaries = tuple( + self._summary_for_scope_id(scope_id) for scope_id in self._readable_scope_ids() + ) + if not summaries: + return (self.summary(),) + return summaries + + def revision_key_for_document_id(self, document_id: str) -> str: + return self._revision_key_for_address(self._address(document_id)) + + def summary(self) -> UiCodeDocumentSummary: + return UiCodeDocumentSummary( + schema_version=SCHEMA_VERSION, + identity=self.identity.as_document_identity(), + widget_id=self.identity.widget_id, + title=( + "ObjectState scope code documents " + f"({OBJECT_STATE_SCOPE_CODE_DOCUMENT_PREFIX})" + ), + readable=False, + writable=False, + total_scope_count=len(ObjectStateRegistry.get_all()), + ) + + def _readable_scope_ids(self) -> tuple[str, ...]: + return tuple( + agent_object_state_scope_id(state.scope_id) or state.scope_id + for state in ObjectStateRegistry.get_all() + if self._has_code_document_driver(state.scope_id) + ) + + def _has_code_document_driver(self, scope_id: str) -> bool: + if FunctionPatternScopeCodeDocumentDriver.handles_scope(scope_id): + return True + code_document_scopes = WindowManager.get_code_document_scopes() + window_id = agent_object_state_scope_id(scope_id) or scope_id + return any( + candidate_scope_id in code_document_scopes + for candidate_scope_id in ( + UiStableWindowIdentityDeclaration.manager_scopes_for_window_id(window_id) + ) + ) + + def _summary_for_scope_id(self, scope_id: str) -> UiCodeDocumentSummary: + address = ObjectStateScopeCodeDocumentAddress(scope_id=scope_id) + title = self._title_for_scope_id(scope_id) + return self._summary_for_address(address, title) + + def _title_for_scope_id(self, scope_id: str) -> str: + for candidate_scope_id in UiStableWindowIdentityDeclaration.manager_scopes_for_window_id( + scope_id + ): + window = WindowManager.get_window(candidate_scope_id) + if window is not None: + return f"Code mode - {window.windowTitle()}" + return f"Code mode - {scope_id}" + + def _address(self, document_id: str) -> ObjectStateScopeCodeDocumentAddress: + return ObjectStateScopeCodeDocumentAddress.parse(document_id) + + def _driver(self, address: ObjectStateScopeCodeDocumentAddress): + scope_id = self._scope_id(address) + if FunctionPatternScopeCodeDocumentDriver.handles_scope(scope_id): + return FunctionPatternScopeCodeDocumentDriver(scope_id) + return WindowManager.require_code_document_driver(scope_id) + + def _scope_id(self, address: ObjectStateScopeCodeDocumentAddress) -> str: + for scope_id in UiStableWindowIdentityDeclaration.manager_scopes_for_window_id( + address.scope_id + ): + if FunctionPatternScopeCodeDocumentDriver.handles_scope(scope_id): + return scope_id + try: + WindowManager.require_code_document_driver(scope_id) + return scope_id + except KeyError: + continue + for scope_id in UiStableWindowIdentityDeclaration.manager_scopes_for_window_id( + address.scope_id + ): + result = ScopeWindowNavigationService.navigate( + WindowNavigationRequest( + scope_id=scope_id, + create_if_missing=True, + ) + ) + if result.window is not None: + return scope_id + raise KeyError(f"ObjectState scope window is not available: {address.scope_id!r}") + + def _summary_for_address( + self, + address: ObjectStateScopeCodeDocumentAddress, + title: str, + ) -> UiCodeDocumentSummary: + identity = address.provider_identity(title) + writable = self._driver(address).writable() + return UiCodeDocumentSummary( + schema_version=SCHEMA_VERSION, + identity=identity.as_document_identity(), + widget_id=identity.widget_id, + title=title, + readable=True, + writable=writable, + current_selection_count=1, + total_scope_count=len(ObjectStateRegistry.get_all()), + ) + + def _selected_scope_ids( + self, + address: ObjectStateScopeCodeDocumentAddress, + ) -> tuple[str, ...]: + return (address.scope_id,) + + def _revision_key_for_address( + self, + address: ObjectStateScopeCodeDocumentAddress, + ) -> str: + return address.revision_key + + def _provider_identity_for_label( + self, + address: ObjectStateScopeCodeDocumentAddress, + ) -> UiCodeDocumentProviderIdentity: + return address.provider_identity(self.identity.title) + + def _mutation_scope_id(self, address: ObjectStateScopeCodeDocumentAddress) -> str: + return self._scope_id(address) + + +class WindowManagerCodeDocumentProvider(WindowCodeDocumentDriverBackedProvider): + """Discover and route live WindowManager code-mode documents.""" + + identity = UiCodeDocumentProviderIdentity( + document_id=WINDOW_CODE_DOCUMENT_ID, + widget_id=WINDOW_CODE_DOCUMENT_ID, + title="Open window code-mode documents", + ) + + def handles(self, document_id: str) -> bool: + return document_id.startswith(WINDOW_CODE_DOCUMENT_PREFIX) + + def revision_key_for_document_id(self, document_id: str) -> str: + return self._revision_key_for_address(self._address(document_id)) + + def summary(self) -> UiCodeDocumentSummary: + return UiCodeDocumentSummary( + schema_version=SCHEMA_VERSION, + identity=self.identity.as_document_identity(), + widget_id=self.identity.widget_id, + title=(f"Open window code-mode documents ({WINDOW_CODE_DOCUMENT_PREFIX})"), + readable=False, + writable=False, + total_scope_count=len(WindowManager.get_code_document_scopes()), + ) + + def summaries(self) -> tuple[UiCodeDocumentSummary, ...]: + scopes = WindowManager.get_code_document_scopes() + if not scopes: + return (self.summary(),) + return tuple(self._summary_for_open_scope(scope_id) for scope_id in scopes) + + def _summary_for_open_scope(self, scope_id: str) -> UiCodeDocumentSummary: + address = WindowCodeDocumentAddress.from_scope_id(scope_id) + window = WindowManager.get_window(scope_id) + title = self._open_scope_title(scope_id, address, window) + return self._summary_for_address(address, title) + + def _open_scope_title( + self, + scope_id: str, + address: WindowCodeDocumentAddress, + window, + ) -> str: + if window is not None and window.windowTitle(): + return f"Code mode - {window.windowTitle()}" + try: + document_title = ( + WindowManager.require_code_document_driver(scope_id).read_document(clean=True).title + ) + except Exception: + document_title = address.window_id + return f"Code mode - {document_title}" + + def _address(self, document_id: str) -> WindowCodeDocumentAddress: + return WindowCodeDocumentAddress.parse(document_id) + + def _driver(self, address: WindowCodeDocumentAddress): + return WindowManager.require_code_document_driver(self._scope_id(address)) + + def _scope_id(self, address: WindowCodeDocumentAddress) -> str: + for scope_id in UiStableWindowIdentityDeclaration.manager_scopes_for_window_id( + address.window_id + ): + try: + WindowManager.require_code_document_driver(scope_id) + return scope_id + except KeyError: + continue + raise KeyError(f"Open window has no code-document driver registered: {address.window_id!r}") + + def _summary_for_address( + self, + address: WindowCodeDocumentAddress, + title: str, + ) -> UiCodeDocumentSummary: + identity = address.provider_identity(title) + writable = self._driver(address).writable() + return UiCodeDocumentSummary( + schema_version=SCHEMA_VERSION, + identity=identity.as_document_identity(), + widget_id=identity.widget_id, + title=title, + readable=True, + writable=writable, + current_selection_count=1, + total_scope_count=len(WindowManager.get_code_document_scopes()), + ) + + def _selected_scope_ids( + self, + address: WindowCodeDocumentAddress, + ) -> tuple[str, ...]: + return (self._scope_id(address),) + + def _revision_key_for_address(self, address: WindowCodeDocumentAddress) -> str: + return address.revision_key + + def _current_revision_token(self, address: WindowCodeDocumentAddress) -> str: + return self._driver(address).current_revision_token() + + def _provider_identity_for_label( + self, + address: WindowCodeDocumentAddress, + ) -> UiCodeDocumentProviderIdentity: + return address.provider_identity(self.identity.title) + + def _mutation_scope_id(self, address: WindowCodeDocumentAddress) -> str: + return self._scope_id(address) + + +class ObjectStateBridgeProviderSet(UiBridgeProviderSetABC): + """Provider set for ObjectState registry projections.""" + + registry_key = OBJECT_STATE_SCOPE_PROVIDER_ID + + def __init__( + self, + before_mutation: Callable[[str], None] | None = None, + ) -> None: + self._before_mutation = before_mutation + + @classmethod + def for_main_window(cls, main_window) -> ObjectStateBridgeProviderSet: + return cls(main_window.session.require_definition_mutation_allowed_for_object_scope) + + def register(self, context: UiBridgeRegistrationContext) -> None: + context.registry.register_object_state_scope_provider(ObjectStateScopeProvider()) + context.registry.register_code_document_provider( + ObjectStateScopeCodeDocumentProvider( + context.snapshot_provider, + before_mutation=self._before_mutation, + ) + ) + context.registry.register_code_document_provider( + WindowManagerCodeDocumentProvider(context.snapshot_provider) + ) diff --git a/src/pyqt_reactive/bridge/windows.py b/src/pyqt_reactive/bridge/windows.py new file mode 100644 index 00000000..09872b0c --- /dev/null +++ b/src/pyqt_reactive/bridge/windows.py @@ -0,0 +1,2791 @@ +"""Window, widget-tree and snapshot projections of a running Qt UI for the bridge.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, ClassVar, TypeAlias + +from formbridge.bridge.contracts import ( + UiActionDispatch, + UiActionProviderABC, + UiActionProviderIdentity, + UiLiveOverviewWidget, + UiWindowProviderABC, + UiWindowProviderIdentity, +) +from formbridge.bridge.dto import ( + DYNAMIC_SCOPE_WINDOW_KIND, + EMBEDDED_WINDOW_KIND, + MANAGED_WINDOW_KIND, + QT_TOP_LEVEL_WINDOW_KIND, + UiActionIdentity, + UiActionInvokeRequest, + UiActionSummary, + UiLiveOverviewItem, + UiLiveOverviewMetric, + UiLiveOverviewSection, + UiLiveOverviewSeverity, + UiMutationReceipt, + UiWidgetActionInvokeRequest, + UiWidgetActionInvokeResult, + UiWidgetActionIssueCode, + UiWidgetActionSemanticCarrier, + UiWidgetActionSummary, + UiWidgetRect, + UiWidgetTreeNode, + UiWidgetTreeRequest, + UiWidgetTreeResult, + UiWindowCatalog, + UiWindowCloseRequest, + UiWindowCloseResult, + UiWindowFocusRequest, + UiWindowFocusResult, + UiWindowIdentity, + UiWindowManagerScope, + UiWindowNavigateRequest, + UiWindowNavigateResult, + UiWindowOperationRequest, + UiWindowSemanticCarrier, + UiWindowSnapshotRequest, + UiWindowSnapshotResult, + UiWindowSummary, +) +from formbridge.bridge.identities import ( + ManagedWindowWidgetIdentity, + UiStableWindowIdentityDeclaration, +) +from formbridge.bridge.object_state import ObjectStateFieldSemanticProjection +from formbridge.bridge.service import ( + UiBridgeNavigateWindowOperation, + UiBridgeSnapshotWindowOperation, +) +from formbridge.contracts import SCHEMA_VERSION, AgentError, AgentResourceRef +from formbridge.forms.parameter_form_constants import CONSTANTS as PARAMETER_FORM_CONSTANTS +from formbridge.services.window_snapshot import WindowVisualObservation +from metaclass_registry import AutoRegisterMeta +from objectstate import ObjectState +from objectstate.inspection import field_markers, state_markers +from PyQt6.QtCore import QModelIndex, Qt +from PyQt6.QtWidgets import ( + QAbstractItemView, + QMessageBox, + QWidget, +) +from python_introspect import overlay_non_none_dataclass, project_dataclass + +from pyqt_reactive.bridge.host import UiBridgeHostWindow +from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager +from pyqt_reactive.services.scope_window_factory import ScopeWindowRegistry +from pyqt_reactive.services.scope_window_navigation import ScopeWindowNavigationService +from pyqt_reactive.services.widget_tree_projection import ( + DEFAULT_WIDGET_DESCRIPTOR_PROJECTOR_REGISTRY, + ROOT_WIDGET_PATH_ID, + WIDGET_PATH_SEPARATOR, + WidgetActionKind, + WidgetActionTargetInvalidError, + WidgetActionUnsupportedError, + WidgetDescriptor, + WidgetRect, + WidgetTreeProjection, + WidgetTreeProjectionService, +) +from pyqt_reactive.services.window_manager import WindowManager +from pyqt_reactive.services.window_navigation import ( + NullWindowNavigationDriver, + RegisteredWindowNavigationCompletion, + WindowNavigationDispatch, + WindowNavigationRequest, +) +from pyqt_reactive.services.window_snapshot import ( + QtWindowSnapshot, + QtWindowSnapshotRequest, + QtWindowSnapshotService, +) +from pyqt_reactive.widgets.shared import ( + BaseFormDialog, + BaseManagedWindow, + ManagedWindowAction, + ManagedWindowActionCapabilities, +) +from pyqt_reactive.widgets.shared.abstract_manager_widget import AbstractManagerWidget +from pyqt_reactive.widgets.shared.list_item_delegate import ( + DIRTY_FIELDS_ROLE, + OBJECT_STATE_PATH_ROLE, + SIG_DIFF_FIELDS_ROLE, +) + +if TYPE_CHECKING: + from pyqt_reactive.widgets.shared.dock_layout import DockPane + + +WindowRouteCollection: TypeAlias = ( + tuple["EmbeddedWindowRoute", ...] | tuple["ManagedWindowRoute", ...] +) +MAIN_WINDOW_PROVIDER_ID = "main_window.windows" +QT_TOP_LEVEL_PROVIDER_ID = "qt_top_level.windows" +QT_TOP_LEVEL_WINDOW_ID_PREFIX = "qt_top_level:" +MANAGED_WINDOW_ACTIONS_TITLE = "Managed window actions" +FIELD_INPUT_ACTION_ROLE = "field_input" +FIELD_RESET_ACTION_ROLE = "field_reset" +ITEM_SELECT_ACTION_ROLE = "item_select" + + +def _agent_object_state_scope_id(scope_id: str | None) -> str | None: + if scope_id is None: + return None + return UiStableWindowIdentityDeclaration.window_id_for_manager_scope(scope_id) + + +MANAGED_WINDOW_ACTION_PROVIDER_IDENTITY = UiActionProviderIdentity.from_widget_declaration( + ManagedWindowWidgetIdentity, + title=MANAGED_WINDOW_ACTIONS_TITLE, +) + + +class ManagedWindowSummaryProjection: + """Project ObjectState-backed managed-window status into window summaries.""" + + @classmethod + def project(cls, window: QWidget | None) -> UiWindowSemanticCarrier: + if not isinstance(window, BaseManagedWindow): + return UiWindowSemanticCarrier() + state = window.state + capabilities = window.managed_window_action_capabilities() + return UiWindowSemanticCarrier( + object_state_scope_id=cls._object_state_scope_id(state), + dirty=bool(state.dirty_fields) if state is not None else False, + signature_diff=(bool(state.signature_diff_fields) if state is not None else False), + dirty_field_count=(len(state.dirty_fields) if state is not None else 0), + signature_diff_field_count=( + len(state.signature_diff_fields) if state is not None else 0 + ), + semantic_markers=cls._semantic_markers(state), + managed_action_ids=cls._managed_action_ids(capabilities), + ) + + @staticmethod + def _object_state_scope_id(state: ObjectState | None) -> str | None: + if state is None: + return None + return _agent_object_state_scope_id(state.scope_id) + + @staticmethod + def _semantic_markers(state: ObjectState | None) -> tuple[str, ...]: + return () if state is None else state_markers(state) + + @staticmethod + def _managed_action_ids( + capabilities: ManagedWindowActionCapabilities, + ) -> tuple[str, ...]: + return tuple( + action.value for action in ManagedWindowAction if action.is_supported(capabilities) + ) + + +class EmbeddedManagerSummaryProjection: + """Project shared AbstractManagerWidget row semantics into window summaries.""" + + @classmethod + def project(cls, widget: QWidget | None) -> UiWindowSemanticCarrier: + if not isinstance(widget, AbstractManagerWidget): + return UiWindowSemanticCarrier() + item_list = widget.item_list + if item_list is None: + return UiWindowSemanticCarrier() + model = item_list.model() + if model is None: + return UiWindowSemanticCarrier() + + dirty_field_count = 0 + signature_diff_field_count = 0 + for row_index in range(model.rowCount()): + item_record = _WidgetItemSemanticRecord.from_index(model.index(row_index, 0)) + dirty_field_count += len(item_record.dirty_fields) + signature_diff_field_count += len(item_record.signature_diff_fields) + + return UiWindowSemanticCarrier( + dirty=bool(dirty_field_count), + signature_diff=bool(signature_diff_field_count), + dirty_field_count=dirty_field_count, + signature_diff_field_count=signature_diff_field_count, + semantic_markers=cls._semantic_markers( + dirty_field_count=dirty_field_count, + signature_diff_field_count=signature_diff_field_count, + ), + ) + + @staticmethod + def _semantic_markers( + *, + dirty_field_count: int, + signature_diff_field_count: int, + ) -> tuple[str, ...]: + return field_markers( + dirty=bool(dirty_field_count), + signature_diff=bool(signature_diff_field_count), + ) + + +@dataclass(frozen=True, slots=True) +class WindowProjectionTarget: + """Resolved Qt widget plus its agent-facing summary.""" + + widget: QWidget + summary: UiWindowSummary + + +class WindowProjectionResultAuthority: + """Shared agent-result fragments for UI window projection operations.""" + + @staticmethod + def unknown_window(identity: UiWindowIdentity) -> AgentError: + return AgentError( + code="unknown_ui_window", + message=f"Unknown or closed UI window: {identity.window_id!r}", + ) + + @staticmethod + def unsupported_navigation_target(request: UiWindowNavigateRequest) -> AgentError: + return AgentError( + code="ui_window_navigation_target_unsupported", + message=( + f"UI window {request.window_id!r} does not own the requested item or field target." + ), + path=request.field_path, + ) + + +class WindowCloseResultBoundaryPolicy: + """Single result boundary for UI window close operations.""" + + @staticmethod + def closed_summary(summary: UiWindowSummary) -> UiWindowSummary: + return replace(summary, visible=False, focusable=False) + + @classmethod + def closed( + cls, + request: UiWindowCloseRequest, + summary: UiWindowSummary, + ) -> UiWindowCloseResult: + return UiWindowCloseResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + closed=True, + summary=cls.closed_summary(summary), + ) + + @staticmethod + def error( + request: UiWindowCloseRequest, + code: str, + message: str, + *, + summary: UiWindowSummary | None = None, + ) -> UiWindowCloseResult: + return UiWindowCloseResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + closed=False, + summary=summary, + errors=(AgentError(code=code, message=message),), + ) + + +class MainWindowContextFactoryMixin: + """Construct registry leaves from an optional host main window.""" + + @classmethod + def create(cls, main_window: UiBridgeHostWindow | None): + return cls(main_window) + + +class WindowCatalogProjectionABC( + MainWindowContextFactoryMixin, + UiWindowProviderABC, + ABC, + metaclass=AutoRegisterMeta, +): + """Provider/projection authority for one catalog of UI windows.""" + + __registry_key__ = "projection_id" + __skip_if_no_key__ = True + + projection_id: ClassVar[str | None] = None + identity: ClassVar[UiWindowProviderIdentity] + + @classmethod + def registered_types(cls) -> tuple[type[WindowCatalogProjectionABC], ...]: + return tuple(cls.__registry__.values()) + + @classmethod + def for_projection_id( + cls, + projection_id: str, + main_window: UiBridgeHostWindow | None, + ) -> WindowCatalogProjectionABC: + return cls.__registry__[projection_id].create(main_window) + + def catalog(self) -> UiWindowCatalog: + return UiWindowCatalog( + schema_version=SCHEMA_VERSION, + windows=self.summaries(), + ) + + def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: + windows = self.summaries() + error_dialogs = tuple(window for window in windows if window.error_dialog) + return ( + UiLiveOverviewSection( + section_id=self.identity.provider_id, + title=self.identity.title, + summary=f"{len(windows)} windows", + metrics=( + UiLiveOverviewMetric( + key="windows", + label="windows", + value=str(len(windows)), + ), + UiLiveOverviewMetric( + key="visible", + label="visible", + value=str(sum(1 for window in windows if window.visible)), + ), + UiLiveOverviewMetric( + key="error_dialogs", + label="error dialogs", + value=str(len(error_dialogs)), + ), + ), + items=tuple(self._overview_window_item(window) for window in windows), + ), + ) + + @staticmethod + def _overview_window_item(window: UiWindowSummary) -> UiLiveOverviewItem: + is_error_dialog = window.error_dialog + return UiLiveOverviewItem( + label=window.title, + status=window.window_kind, + detail=f"visible={window.visible} focusable={window.focusable}", + severity=( + UiLiveOverviewSeverity.ERROR.value + if is_error_dialog + else UiLiveOverviewSeverity.INFO.value + ), + source_window_id=window.window_id, + ) + + @abstractmethod + def summaries(self) -> tuple[UiWindowSummary, ...]: + """Return the windows currently visible through this projection.""" + raise NotImplementedError + + @abstractmethod + def handles(self, window_id: str) -> bool: + """Return whether this projection owns a window id.""" + raise NotImplementedError + + @abstractmethod + def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: + raise NotImplementedError + + @abstractmethod + def navigate( + self, + request: UiWindowNavigateRequest, + completed: Callable[[UiWindowNavigateResult], None] | None = None, + ) -> UiWindowNavigateResult: + raise NotImplementedError + + @abstractmethod + def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: + raise NotImplementedError + + @abstractmethod + def snapshot( + self, + request: UiWindowSnapshotRequest, + completed: Callable[[UiWindowSnapshotResult], None] | None = None, + ) -> UiWindowSnapshotResult: + raise NotImplementedError + + @abstractmethod + def widget_tree(self, request: UiWidgetTreeRequest) -> UiWidgetTreeResult: + raise NotImplementedError + + @abstractmethod + def invoke_widget_action( + self, + request: UiWidgetActionInvokeRequest, + ) -> UiWidgetActionInvokeResult: + raise NotImplementedError + + +class WindowTargetOperationProjectionMixin(ABC): + """Shared target-backed window operations for projection providers.""" + + @abstractmethod + def target_for_operation( + self, + request: UiWindowOperationRequest, + ) -> WindowProjectionTarget | None: + raise NotImplementedError + + def snapshot( + self, + request: UiWindowSnapshotRequest, + completed: Callable[[UiWindowSnapshotResult], None] | None = None, + ) -> UiWindowSnapshotResult: + target = self.target_for_operation(request) + snapshot_results = UiWindowSnapshotResultFactory() + if target is None: + return snapshot_results.error( + request, + WindowProjectionResultAuthority.unknown_window(request), + ) + return snapshot_results.capture(request, target, completed) + + def widget_tree(self, request: UiWidgetTreeRequest) -> UiWidgetTreeResult: + target = self.target_for_operation(request) + widget_tree_results = UiWidgetTreeResultFactory() + if target is None: + return widget_tree_results.error( + request, + WindowProjectionResultAuthority.unknown_window(request), + ) + return widget_tree_results.project(request, target) + + def invoke_widget_action( + self, + request: UiWidgetActionInvokeRequest, + ) -> UiWidgetActionInvokeResult: + target = self.target_for_operation(request) + invoke_results = UiWidgetActionInvokeResultFactory() + if target is None: + return invoke_results.error( + request, + WindowProjectionResultAuthority.unknown_window(request), + ) + return invoke_results.invoke(request, target) + + +@dataclass(frozen=True, slots=True) +class FocusableWindowRouteMixin(ABC): + """Shared focus algorithm for static focusable window routes.""" + + focus_action: Callable[[], None] + + @abstractmethod + def summary(self) -> UiWindowSummary: + raise NotImplementedError + + @abstractmethod + def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: + raise NotImplementedError + + def focus(self) -> UiWindowSummary: + self.focus_action() + return self.summary() + + +@dataclass(frozen=True, slots=True) +class EmbeddedWindowRoute: + """Focusable embedded widget route owned by the main window layout.""" + + pane: DockPane + + @property + def identity(self) -> UiWindowIdentity: + return UiWindowIdentity(window_id=self.pane.window_id) + + @property + def title(self) -> str: + return self.pane.title + + def widget(self) -> QWidget: + return self.pane.widget + + def focus(self) -> UiWindowSummary: + self.pane.show() + return self.summary() + + def navigate( + self, + request: UiWindowNavigateRequest, + completed: Callable[[RegisteredWindowNavigationCompletion], None] | None = None, + ) -> WindowNavigationDispatch: + self.pane.show() + widget = self.widget() + driver = ( + widget.window_navigation_driver() + if isinstance(widget, AbstractManagerWidget) + else NullWindowNavigationDriver() + ) + return WindowManager.dispatch_widget_navigation( + widget, + driver, + requested_scope_id=request.window_id, + item_id=request.item_id, + field_path=request.field_path, + completed=completed, + ) + + def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: + widget = self.widget() + if isinstance(widget, UiLiveOverviewWidget): + return widget.overview_sections() + return () + + def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: + del create_if_missing + return WindowProjectionTarget( + widget=self.widget(), + summary=self.summary(), + ) + + def summary(self) -> UiWindowSummary: + widget = self.widget() + scope = UiWindowManagerScope.from_identity(self.identity) + return project_dataclass( + UiWindowSummary, + EmbeddedManagerSummaryProjection.project(widget), + schema_version=SCHEMA_VERSION, + identity=self.identity, + title=self.title, + window_kind=EMBEDDED_WINDOW_KIND, + visible=widget.isVisible(), + focusable=True, + manager_scope=scope, + ) + + +@dataclass(frozen=True, slots=True) +class ManagedWindowRoute(FocusableWindowRouteMixin): + """Focusable WindowManager-backed static window route.""" + + identity: UiWindowIdentity + title: str + + def navigate( + self, + request: UiWindowNavigateRequest, + completed: Callable[[RegisteredWindowNavigationCompletion], None] | None = None, + ) -> WindowNavigationDispatch: + self.focus() + return WindowManager.focus_and_navigate_result( + UiWindowManagerScope.from_identity(self.identity).value, + item_id=request.item_id, + field_path=request.field_path, + requested_scope_id=request.window_id, + completed=completed, + ) + + def widget(self, create_if_missing: bool) -> QWidget | None: + scope = UiWindowManagerScope.from_identity(self.identity) + window = WindowManager.get_window(scope.value) + if window is not None: + return window + if not create_if_missing: + return None + self.focus_action() + return WindowManager.get_window(scope.value) + + def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: + widget = self.widget(create_if_missing=create_if_missing) + if widget is None: + return None + return WindowProjectionTarget( + widget=widget, + summary=self.summary(), + ) + + def summary(self) -> UiWindowSummary: + scope = UiWindowManagerScope.from_identity(self.identity) + window = WindowManager.get_window(scope.value) + visible = False + if window is not None: + visible = window.isVisible() + return project_dataclass( + UiWindowSummary, + ManagedWindowSummaryProjection.project(window), + schema_version=SCHEMA_VERSION, + identity=self.identity, + title=self.title, + window_kind=MANAGED_WINDOW_KIND, + visible=visible, + focusable=True, + manager_scope=scope, + ) + + +@dataclass(frozen=True, slots=True) +class WindowIdentitySet: + """Closed set of window identities for static route filtering.""" + + identities: frozenset[UiWindowIdentity] + + @classmethod + def from_routes( + cls, + routes: WindowRouteCollection, + ) -> WindowIdentitySet: + return cls(frozenset(route.identity for route in routes)) + + def contains(self, identity: UiWindowIdentity) -> bool: + return identity in self.identities + + def union(self, other: WindowIdentitySet) -> WindowIdentitySet: + return WindowIdentitySet(self.identities | other.identities) + + +@dataclass(frozen=True, slots=True) +class WindowRouteIndex: + """Identity-indexed route lookup for static window routes.""" + + embedded_routes: tuple[EmbeddedWindowRoute, ...] + managed_routes: tuple[ManagedWindowRoute, ...] + + @property + def static_identities(self) -> WindowIdentitySet: + return WindowIdentitySet.from_routes(self.embedded_routes).union( + WindowIdentitySet.from_routes(self.managed_routes) + ) + + def embedded_route(self, identity: UiWindowIdentity) -> EmbeddedWindowRoute | None: + return self._route_by_identity(self.embedded_routes, identity) + + def managed_route(self, identity: UiWindowIdentity) -> ManagedWindowRoute | None: + return self._route_by_identity(self.managed_routes, identity) + + @staticmethod + def _route_by_identity( + routes: WindowRouteCollection, + identity: UiWindowIdentity, + ): + for route in routes: + if route.identity == identity: + return route + return None + + +@dataclass(frozen=True, slots=True) +class WindowRouteResolution: + """A static window route resolved for a requested agent-visible identity.""" + + request_identity: UiWindowIdentity + route_identity: UiWindowIdentity + route: EmbeddedWindowRoute | ManagedWindowRoute + + def summary(self, target_summary: UiWindowSummary | None = None) -> UiWindowSummary: + if target_summary is None: + target_summary = self.route.summary() + if self.route_identity == self.request_identity: + return target_summary + return replace(target_summary, identity=self.request_identity) + + def focus(self) -> UiWindowSummary: + return self.summary(self.route.focus()) + + def target(self, *, create_if_missing: bool) -> WindowProjectionTarget | None: + target = self.route.target(create_if_missing=create_if_missing) + if target is None: + return None + return WindowProjectionTarget( + widget=target.widget, + summary=self.summary(target.summary), + ) + + +class EmbeddedWindowProjection: + """Project embedded main-window widgets.""" + + def __init__(self, main_window: UiBridgeHostWindow) -> None: + self._main_window = main_window + + def routes(self) -> tuple[EmbeddedWindowRoute, ...]: + embedded = self._main_window.embedded_widgets + return tuple(EmbeddedWindowRoute(pane=pane) for pane in embedded.panes()) + + +class ManagedWindowProjection: + """Project static WindowManager-managed windows.""" + + def __init__(self, main_window: UiBridgeHostWindow) -> None: + self._main_window = main_window + + def routes( + self, + embedded_routes: tuple[EmbeddedWindowRoute, ...], + ) -> tuple[ManagedWindowRoute, ...]: + embedded_identities = WindowIdentitySet.from_routes(embedded_routes) + return tuple( + ManagedWindowRoute( + identity=UiWindowIdentity(window_id=spec.window_id), + title=spec.title, + focus_action=self._focus_static_window_action(spec.window_id), + ) + for spec in self._main_window.window_specs.values() + if not embedded_identities.contains(UiWindowIdentity(window_id=spec.window_id)) + ) + + def _focus_static_window_action(self, window_id: str) -> Callable[[], None]: + def focus() -> None: + self._main_window.show_window(window_id, hide_if_startup=False) + + return focus + + +class DynamicScopeWindowProjection: + """Project already-open dynamic ObjectState/scope windows.""" + + def summaries( + self, + route_index: WindowRouteIndex, + ) -> tuple[UiWindowSummary, ...]: + return tuple( + self.summary( + self._identity_for_manager_scope(scope_id), + scope_id, + ) + for scope_id in WindowManager.get_open_scopes() + if not route_index.static_identities.contains( + self._identity_for_manager_scope(scope_id) + ) + ) + + def summary( + self, + identity: UiWindowIdentity, + manager_scope_id: str | None = None, + ) -> UiWindowSummary: + manager_identity = identity + if manager_scope_id is not None: + manager_identity = UiWindowIdentity(window_id=manager_scope_id) + scope = UiWindowManagerScope.from_identity(manager_identity) + window = WindowManager.get_window(scope.value) + title = scope.value + visible = False + if window is not None: + title = window.windowTitle() + visible = window.isVisible() + return project_dataclass( + UiWindowSummary, + ManagedWindowSummaryProjection.project(window), + schema_version=SCHEMA_VERSION, + identity=identity, + title=title, + window_kind=DYNAMIC_SCOPE_WINDOW_KIND, + visible=visible, + focusable=window is not None, + manager_scope=scope, + ) + + @staticmethod + def _identity_for_manager_scope(scope_id: str) -> UiWindowIdentity: + return UiWindowIdentity( + window_id=UiStableWindowIdentityDeclaration.window_id_for_manager_scope(scope_id) + ) + + +@dataclass(frozen=True, slots=True) +class OpenWindowManagerScope: + """Witness for an agent identity resolved to an open WindowManager scope.""" + + identity: UiWindowIdentity + scope_id: str + widget: QWidget + + def target( + self, + dynamic_projection: DynamicScopeWindowProjection, + ) -> WindowProjectionTarget: + return WindowProjectionTarget( + widget=self.widget, + summary=dynamic_projection.summary(self.identity, self.scope_id), + ) + + +class ScopeWindowTargetOperationProjectionABC( + WindowTargetOperationProjectionMixin, + ABC, +): + """Shared target lookup for WindowManager and scope-registry projections.""" + + @abstractmethod + def _route_index(self) -> WindowRouteIndex: + raise NotImplementedError + + @abstractmethod + def _dynamic_scope_projection(self) -> DynamicScopeWindowProjection: + raise NotImplementedError + + def target_for_operation( + self, + request: UiWindowOperationRequest, + ) -> WindowProjectionTarget | None: + identity = request.as_identity() + return self._target( + identity, + create_if_missing=request.open_policy.create_if_missing, + ) + + def _target( + self, + identity: UiWindowIdentity, + *, + create_if_missing: bool, + ) -> WindowProjectionTarget | None: + route_index = self._route_index() + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if embedded_route is not None: + return embedded_route.target(create_if_missing=create_if_missing) + + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if managed_route is not None: + return managed_route.target(create_if_missing=create_if_missing) + + open_target = self._open_dynamic_target(identity) + if open_target is not None: + return open_target + + embedded_alias = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if embedded_alias is not None: + return embedded_alias.target(create_if_missing=create_if_missing) + + managed_alias = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if managed_alias is not None: + return managed_alias.target(create_if_missing=create_if_missing) + + scope = UiWindowManagerScope.from_identity(self._resolved_window_identity(identity)) + scope_widget = WindowManager.get_window(scope.value) + if scope_widget is not None: + return WindowProjectionTarget( + widget=scope_widget, + summary=self._dynamic_scope_projection().summary( + identity, + scope.value, + ), + ) + + if create_if_missing and ScopeWindowRegistry.find_handler(identity.window_id) is not None: + navigation = ScopeWindowNavigationService.navigate( + WindowNavigationRequest( + scope_id=identity.window_id, + create_if_missing=True, + ) + ) + if navigation.focused: + target_scope_id = navigation.window_scope_id or scope.value + scope_widget = WindowManager.get_window(target_scope_id) + if scope_widget is not None: + return WindowProjectionTarget( + widget=scope_widget, + summary=self._dynamic_scope_projection().summary( + identity, + target_scope_id, + ), + ) + + return None + + def _embedded_route_resolution( + self, + identity: UiWindowIdentity, + route_index: WindowRouteIndex, + *, + resolve_scope_alias: bool, + ) -> WindowRouteResolution | None: + return self._static_route_resolution( + identity, + route_index.embedded_route, + resolve_scope_alias=resolve_scope_alias, + ) + + def _managed_route_resolution( + self, + identity: UiWindowIdentity, + route_index: WindowRouteIndex, + *, + resolve_scope_alias: bool, + ) -> WindowRouteResolution | None: + return self._static_route_resolution( + identity, + route_index.managed_route, + resolve_scope_alias=resolve_scope_alias, + ) + + def _static_route_resolution( + self, + identity: UiWindowIdentity, + route_for_identity: Callable[ + [UiWindowIdentity], + EmbeddedWindowRoute | ManagedWindowRoute | None, + ], + *, + resolve_scope_alias: bool, + ) -> WindowRouteResolution | None: + route_identity = identity + if resolve_scope_alias: + route_identity = self._resolved_window_identity(identity) + if route_identity == identity: + return None + + route = route_for_identity(route_identity) + if route is None: + return None + return WindowRouteResolution( + request_identity=identity, + route_identity=route_identity, + route=route, + ) + + def _open_dynamic_target( + self, + identity: UiWindowIdentity, + ) -> WindowProjectionTarget | None: + open_scope = self._open_window_manager_scope(identity) + if open_scope is None: + return None + return open_scope.target(self._dynamic_scope_projection()) + + @classmethod + def _open_window_manager_scope_id(cls, identity: UiWindowIdentity) -> str | None: + open_scope = cls._open_window_manager_scope(identity) + if open_scope is None: + return None + return open_scope.scope_id + + @staticmethod + def _open_window_manager_scope( + identity: UiWindowIdentity, + ) -> OpenWindowManagerScope | None: + for scope_id in UiStableWindowIdentityDeclaration.manager_scopes_for_window_id( + identity.window_id + ): + widget = WindowManager.get_window(scope_id) + if widget is not None: + return OpenWindowManagerScope( + identity=identity, + scope_id=scope_id, + widget=widget, + ) + return None + + @staticmethod + def _resolved_window_identity(identity: UiWindowIdentity) -> UiWindowIdentity: + route = ScopeWindowRegistry.find_handler(identity.window_id) + if route is None: + return identity + target = route.navigation_target(identity.window_id) + return UiWindowIdentity(window_id=target.window_scope_id) + + +class QtTopLevelWindowProjection( + WindowTargetOperationProjectionMixin, + WindowCatalogProjectionABC, +): + """Project visible Qt top-level windows that are not WindowManager scopes.""" + + projection_id = QT_TOP_LEVEL_PROVIDER_ID + identity = UiWindowProviderIdentity( + provider_id=QT_TOP_LEVEL_PROVIDER_ID, + title="Qt top-level windows", + ) + + def __init__( + self, + main_window: UiBridgeHostWindow | None, + ) -> None: + self._main_window = main_window + + def summaries(self) -> tuple[UiWindowSummary, ...]: + summaries = [] + for widget in self._projected_top_level_widgets(): + summary = WindowManager.project_live_window(widget, self.summary) + if summary is not None: + summaries.append(summary) + return tuple(summaries) + + def handles(self, window_id: str) -> bool: + return self.target(UiWindowIdentity(window_id=window_id)) is not None + + def target(self, identity: UiWindowIdentity) -> WindowProjectionTarget | None: + identity = identity.as_identity() + for widget in self._projected_top_level_widgets(): + target = WindowManager.project_live_window( + widget, + lambda live_widget: self._matching_target(live_widget, identity), + ) + if target is not None: + return target + return None + + def _matching_target( + self, + widget: QWidget, + identity: UiWindowIdentity, + ) -> WindowProjectionTarget | None: + if self._identity(widget) != identity: + return None + return WindowProjectionTarget(widget=widget, summary=self.summary(widget)) + + def target_for_operation( + self, + request: UiWindowOperationRequest, + ) -> WindowProjectionTarget | None: + return self.target(request) + + def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: + target = self.target(request) + if target is None: + return UiWindowFocusResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + focused=False, + errors=(WindowProjectionResultAuthority.unknown_window(request),), + ) + target.widget.show() + target.widget.raise_() + target.widget.activateWindow() + return UiWindowFocusResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + focused=True, + summary=self.summary(target.widget), + ) + + def navigate( + self, + request: UiWindowNavigateRequest, + completed: Callable[[UiWindowNavigateResult], None] | None = None, + ) -> UiWindowNavigateResult: + focus_result = self.focus( + UiWindowFocusRequest( + window_id=request.window_id, + open_policy=request.open_policy, + ) + ) + return UiWindowNavigateResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + focused=focus_result.focused, + navigated=False, + created=False, + summary=focus_result.summary, + errors=( + focus_result.errors + or ( + (WindowProjectionResultAuthority.unsupported_navigation_target(request),) + if request.has_target + else () + ) + ), + warnings=focus_result.warnings, + ) + + def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: + target = self.target(request) + if target is None: + return WindowCloseResultBoundaryPolicy.error( + request, + "unknown_ui_window", + f"Unknown or closed UI window: {request.window_id!r}", + ) + if target.widget is self._main_window: + return WindowCloseResultBoundaryPolicy.error( + request, + "ui_window_close_unsupported", + "The main window cannot be closed through this UI bridge operation.", + summary=target.summary, + ) + if target.widget.close(): + return WindowCloseResultBoundaryPolicy.closed( + request, + target.summary, + ) + return WindowCloseResultBoundaryPolicy.error( + request, + "ui_window_close_rejected", + f"Qt rejected the close request for UI window: {request.window_id!r}", + summary=target.summary, + ) + + def summary(self, widget: QWidget) -> UiWindowSummary: + identity = self._identity(widget) + return UiWindowSummary( + schema_version=SCHEMA_VERSION, + identity=identity, + title=self._title(widget), + window_kind=QT_TOP_LEVEL_WINDOW_KIND, + visible=widget.isVisible(), + focusable=True, + manager_scope=None, + error_dialog=self._is_error_dialog(widget), + ) + + def _projected_top_level_widgets(self) -> tuple[QWidget, ...]: + excluded_widgets = self._excluded_widgets() + return tuple( + widget + for widget in WindowManager.visible_top_level_windows() + if widget not in excluded_widgets + ) + + def _excluded_widgets(self) -> frozenset[QWidget]: + widgets: set[QWidget] = set() + for scope_id in WindowManager.get_open_scopes(): + widget = WindowManager.get_window(scope_id) + if widget is not None: + top_level_widget = widget.window() + if top_level_widget is not self._main_window: + widgets.add(top_level_widget) + if self._main_window is not None: + widgets.update( + pane.dock_widget + for pane in self._main_window.embedded_widgets.panes() + if pane.dock_widget.isFloating() + ) + return frozenset(widgets) + + def _identity(self, widget: QWidget) -> UiWindowIdentity: + if widget is self._main_window: + return UiWindowIdentity( + window_id=self._main_window.UI_BRIDGE_WIDGET_IDENTITY.require_value() + ) + return UiWindowIdentity(window_id=f"{QT_TOP_LEVEL_WINDOW_ID_PREFIX}{int(widget.winId())}") + + @staticmethod + def _title(widget: QWidget) -> str: + title = widget.windowTitle() + if title: + return title + return type(widget).__name__ + + @staticmethod + def _is_error_dialog(widget: QWidget) -> bool: + return isinstance(widget, QMessageBox) and widget.icon() is QMessageBox.Icon.Critical + + +class UiWindowSnapshotResultFactory: + """Build UI bridge screenshot results from resolved Qt window targets.""" + + def __init__(self) -> None: + self._snapshotter = QtWindowSnapshotService() + + def capture( + self, + request: UiWindowSnapshotRequest, + target: WindowProjectionTarget, + completed: Callable[[UiWindowSnapshotResult], None] | None = None, + ) -> UiWindowSnapshotResult: + qt_request = QtWindowSnapshotRequest( + widget=target.widget, + capture=request, + subject_id=request.window_id, + title=target.summary.title, + ) + try: + if request.frame_condition.observes: + if completed is None: + raise ValueError("Observed snapshots require an operation completion owner.") + self._snapshotter.observe( + qt_request, + lambda snapshot: completed(self.from_snapshot(request, target, snapshot)), + lambda failure: completed( + self.error( + request, + AgentError.from_exception( + UiBridgeSnapshotWindowOperation.failure_error_code, + failure.error, + ), + summary=target.summary, + observation=failure.observation, + ) + ), + ) + return project_dataclass( + UiWindowSnapshotResult, + request, + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + captured=False, + summary=target.summary, + ) + snapshot = self._snapshotter.capture(qt_request) + except Exception as exc: + return self.error( + request, + AgentError.from_exception(UiBridgeSnapshotWindowOperation.failure_error_code, exc), + summary=target.summary, + ) + return self.from_snapshot(request, target, snapshot) + + @staticmethod + def from_snapshot( + request: UiWindowSnapshotRequest, + target: WindowProjectionTarget, + snapshot: QtWindowSnapshot, + ) -> UiWindowSnapshotResult: + return project_dataclass( + UiWindowSnapshotResult, + request, + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + captured=True, + resource=AgentResourceRef( + uri=snapshot.uri, + title=snapshot.title, + mime_type=snapshot.mime_type, + path=snapshot.path, + size_bytes=snapshot.size_bytes, + sha256=snapshot.sha256, + ), + summary=target.summary, + width=snapshot.width, + height=snapshot.height, + observation=snapshot.observation, + ) + + @staticmethod + def error( + request: UiWindowSnapshotRequest, + error: AgentError, + *, + summary: UiWindowSummary | None = None, + observation: WindowVisualObservation | None = None, + ) -> UiWindowSnapshotResult: + return project_dataclass( + UiWindowSnapshotResult, + request, + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + captured=False, + summary=summary, + observation=observation, + errors=(error,), + ) + + +@dataclass(frozen=True, slots=True) +class _ItemViewIndexResolution: + view: QAbstractItemView + index: QModelIndex + + +class UiWidgetPathResolver: + """Resolve widget-tree path ids against live Qt widgets and descriptors.""" + + @classmethod + def path_from_id(cls, path_id: str) -> tuple[int, ...]: + if path_id == ROOT_WIDGET_PATH_ID: + return () + if not path_id: + raise ValueError("Widget path_id must not be empty.") + parts = path_id.split(WIDGET_PATH_SEPARATOR) + path: list[int] = [] + for part in parts: + try: + index = int(part) + except ValueError as exc: + raise ValueError(f"Invalid widget path component: {part!r}") from exc + if index < 0: + raise ValueError(f"Widget path component must be non-negative: {part!r}") + path.append(index) + return tuple(path) + + @classmethod + def widget_at_path(cls, widget: QWidget, path: tuple[int, ...]) -> QWidget | None: + current = widget + for child_index in path: + children = cls.direct_children(current) + if child_index >= len(children): + return None + current = children[child_index] + return current + + @classmethod + def descriptor_at_path( + cls, + descriptor: WidgetDescriptor, + path: tuple[int, ...], + ) -> WidgetDescriptor | None: + current = descriptor + for child_index in path: + child = current.child_at_index(child_index) + if child is None: + return None + current = child + return current + + @staticmethod + def direct_children(widget: QWidget) -> tuple[QWidget, ...]: + return tuple( + widget.findChildren( + QWidget, + options=Qt.FindChildOption.FindDirectChildrenOnly, + ) + ) + + @classmethod + def item_view_index_at_path( + cls, + widget: QWidget, + path: tuple[int, ...], + ) -> _ItemViewIndexResolution | None: + current_widget = widget + current_index = QModelIndex() + active_view: QAbstractItemView | None = None + + for child_index in path: + if active_view is None: + children = cls.direct_children(current_widget) + if child_index < len(children): + current_widget = children[child_index] + continue + if not isinstance(current_widget, QAbstractItemView): + return None + active_view = current_widget + model_row = child_index - len(children) + else: + model_row = child_index + + model = active_view.model() + if model is None: + return None + current_index = model.index(model_row, 0, current_index) + if not current_index.isValid(): + return None + + if active_view is None: + return None + return _ItemViewIndexResolution(active_view, current_index) + + +class UiWidgetActionInvokeResultFactory: + """Invoke projected widget actions through live Qt widgets.""" + + AUTO_ACTION_KIND = "auto" + + def invoke( + self, + request: UiWidgetActionInvokeRequest, + target: WindowProjectionTarget, + ) -> UiWidgetActionInvokeResult: + try: + projection = WidgetTreeProjectionService.project(target.widget) + path = UiWidgetPathResolver.path_from_id(request.path_id) + except Exception as exc: + return self.error( + request, + AgentError.from_exception( + UiWidgetActionIssueCode.RESOLUTION_FAILED.value, + exc, + ), + summary=target.summary, + ) + + descriptor = UiWidgetPathResolver.descriptor_at_path(projection.root, path) + if descriptor is None: + return self.error( + request, + AgentError( + code=UiWidgetActionIssueCode.WIDGET_UNKNOWN.value, + message=( + f"Widget path_id {request.path_id!r} was not found in " + f"window {request.window_id!r}." + ), + hint="Refresh the widget tree and retry with a current path_id.", + ), + summary=target.summary, + ) + + action_summary = UiWidgetTreeResultFactory.action_summary( + descriptor, + field_semantics=_WidgetFieldSemanticContext.from_target(target), + item_semantics=_WidgetItemSemanticContext.from_target(target), + ) + + action_kind = self._resolve_action_kind(request, descriptor) + if isinstance(action_kind, AgentError): + return self.error(request, action_kind, summary=action_summary) + + if action_kind.item_action is not None: + return self._invoke_item_action( + request, + target, + path, + descriptor, + action_summary, + action_kind=action_kind, + ) + + widget = UiWidgetPathResolver.widget_at_path(target.widget, path) + if widget is None: + return self.error( + request, + AgentError( + code=UiWidgetActionIssueCode.WIDGET_UNKNOWN.value, + message=( + f"Widget path_id {request.path_id!r} was not found in " + f"window {request.window_id!r}." + ), + hint="Refresh the widget tree and retry with a current path_id.", + ), + summary=action_summary, + ) + + guard_error = self._guard_error(request, descriptor, widget, action_kind) + if guard_error is not None: + return self.error( + request, + guard_error, + summary=action_summary, + action_kind=action_kind.value, + ) + + return self._invoke_projector_action( + request, + action_summary, + widget, + action_kind=action_kind, + ) + + def _invoke_projector_action( + self, + request: UiWidgetActionInvokeRequest, + action_summary: UiWidgetActionSummary, + widget: QWidget, + *, + action_kind: WidgetActionKind, + ) -> UiWidgetActionInvokeResult: + try: + DEFAULT_WIDGET_DESCRIPTOR_PROJECTOR_REGISTRY.projector_for(widget).invoke_action( + widget, + action_kind, + target_index=request.target_index, + ) + except WidgetActionUnsupportedError: + return self.error( + request, + AgentError( + code=UiWidgetActionIssueCode.ACTION_UNSUPPORTED.value, + message=( + f"Widget path_id {request.path_id!r} does not support " + f"action kind {action_kind.value!r}." + ), + ), + summary=action_summary, + action_kind=action_kind.value, + ) + except WidgetActionTargetInvalidError as error: + return self.error( + request, + AgentError( + code=UiWidgetActionIssueCode.INDEX_INVALID.value, + message=( + f"Target index {request.target_index!r} is outside the " + f"available range for widget path_id {request.path_id!r}." + ), + hint=( + "Refresh the widget tree and choose target_index from 0 " + f"through {error.item_count - 1}." + ), + ), + summary=action_summary, + action_kind=action_kind.value, + ) + + return UiWidgetActionInvokeResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + path_id=request.path_id, + action_kind=action_kind.value, + invoked=True, + receipt=UiMutationReceipt.accepted_for(request.request_token), + summary=action_summary, + ) + + def _invoke_item_action( + self, + request: UiWidgetActionInvokeRequest, + target: WindowProjectionTarget, + path: tuple[int, ...], + descriptor: WidgetDescriptor, + action_summary: UiWidgetActionSummary, + *, + action_kind: WidgetActionKind, + ) -> UiWidgetActionInvokeResult: + guard_error = self._descriptor_guard_error(request, descriptor, action_kind) + if guard_error is not None: + return self.error( + request, + guard_error, + summary=action_summary, + action_kind=action_kind.value, + ) + + resolution = UiWidgetPathResolver.item_view_index_at_path(target.widget, path) + if resolution is None: + return self.error( + request, + AgentError( + code=UiWidgetActionIssueCode.ACTION_UNSUPPORTED.value, + message=( + f"Widget path_id {request.path_id!r} does not resolve to " + "an actionable item-view row." + ), + ), + summary=action_summary, + action_kind=action_kind.value, + ) + + action_kind.item_action.defer(resolution.view, resolution.index) + return UiWidgetActionInvokeResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + path_id=request.path_id, + action_kind=action_kind.value, + invoked=True, + receipt=UiMutationReceipt.accepted_for(request.request_token), + summary=action_summary, + ) + + def _resolve_action_kind( + self, + request: UiWidgetActionInvokeRequest, + descriptor: WidgetDescriptor, + ) -> WidgetActionKind | AgentError: + if request.action_kind == self.AUTO_ACTION_KIND: + if len(descriptor.action_kinds) == 1: + return next(iter(descriptor.action_kinds)) + default_actions = tuple( + kind + for kind in descriptor.action_kinds + if kind.item_action is not None and kind.item_action.default + ) + if len(default_actions) == 1: + return default_actions[0] + return self._action_kind_unavailable_error(request, descriptor) + try: + action_kind = WidgetActionKind(request.action_kind) + except ValueError: + return self._action_kind_unavailable_error(request, descriptor) + if action_kind not in descriptor.action_kinds: + return self._action_kind_unavailable_error(request, descriptor) + return action_kind + + def _guard_error( + self, + request: UiWidgetActionInvokeRequest, + descriptor: WidgetDescriptor, + widget: QWidget, + action_kind: WidgetActionKind, + ) -> AgentError | None: + if not descriptor.visible or not widget.isVisible(): + return AgentError( + code=UiWidgetActionIssueCode.NOT_VISIBLE.value, + message=f"Widget path_id {request.path_id!r} is not visible.", + ) + if not descriptor.enabled or not widget.isEnabled(): + return AgentError( + code=UiWidgetActionIssueCode.DISABLED.value, + message=f"Widget path_id {request.path_id!r} is disabled.", + ) + if not descriptor.clickable: + return AgentError( + code=UiWidgetActionIssueCode.NOT_CLICKABLE.value, + message=f"Widget path_id {request.path_id!r} is not clickable.", + ) + return None + + def _descriptor_guard_error( + self, + request: UiWidgetActionInvokeRequest, + descriptor: WidgetDescriptor, + action_kind: WidgetActionKind, + ) -> AgentError | None: + if not descriptor.visible: + return AgentError( + code=UiWidgetActionIssueCode.NOT_VISIBLE.value, + message=f"Widget path_id {request.path_id!r} is not visible.", + ) + if not descriptor.enabled: + return AgentError( + code=UiWidgetActionIssueCode.DISABLED.value, + message=f"Widget path_id {request.path_id!r} is disabled.", + ) + if not descriptor.clickable: + return AgentError( + code=UiWidgetActionIssueCode.NOT_CLICKABLE.value, + message=f"Widget path_id {request.path_id!r} is not clickable.", + ) + return None + + @staticmethod + def _action_kind_unavailable_error( + request: UiWidgetActionInvokeRequest, + descriptor: WidgetDescriptor, + ) -> AgentError: + action_kinds = tuple(kind.value for kind in descriptor.action_kinds) + return AgentError( + code=UiWidgetActionIssueCode.ACTION_KIND_UNAVAILABLE.value, + message=( + f"Widget path_id {request.path_id!r} does not expose " + f"action kind {request.action_kind!r}." + ), + hint=f"Available action kinds: {', '.join(action_kinds) or 'none'}.", + ) + + @staticmethod + def error( + request: UiWidgetActionInvokeRequest, + error: AgentError, + *, + summary: UiWindowSummary | UiWidgetActionSummary | None = None, + action_kind: str | None = None, + ) -> UiWidgetActionInvokeResult: + action_summary = summary if isinstance(summary, UiWidgetActionSummary) else None + return UiWidgetActionInvokeResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + path_id=request.path_id, + action_kind=action_kind or request.action_kind, + invoked=False, + receipt=UiMutationReceipt.rejected_for(request.request_token), + summary=action_summary, + errors=(error,), + ) + + +@dataclass(frozen=True, slots=True) +class _WidgetGeometryKey: + """Stable enough bridge key for matching live form widgets to descriptors.""" + + x: int + y: int + width: int + height: int + + @classmethod + def from_qwidget(cls, widget: QWidget) -> _WidgetGeometryKey: + rect = widget.rect() + top_left = widget.mapToGlobal(rect.topLeft()) + return cls( + x=top_left.x(), + y=top_left.y(), + width=rect.width(), + height=rect.height(), + ) + + @classmethod + def from_descriptor(cls, descriptor: WidgetDescriptor) -> _WidgetGeometryKey: + rect = descriptor.global_geometry + return cls( + x=rect.x, + y=rect.y, + width=rect.width, + height=rect.height, + ) + + +@dataclass(frozen=True, slots=True) +class _WidgetFieldSemanticRecord: + """Field semantics for one live widget action.""" + + action_role: str + projection: ObjectStateFieldSemanticProjection + + def to_action_carrier(self, *, window_id: str) -> UiWidgetActionSemanticCarrier: + return project_dataclass( + UiWidgetActionSemanticCarrier, + self.projection.to_semantic_carrier(include_values=True), + action_role=self.action_role, + semantic_address=self.projection.semantic_address(window_id=window_id), + object_state_scope_id=self.projection.agent_scope_id, + field_path=self.projection.field_path, + ) + + +@dataclass(frozen=True, slots=True) +class _WidgetFieldSemanticContext: + """Form-manager-backed lookup for widget action ObjectState semantics.""" + + window_id: str + records_by_geometry: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord] + + @classmethod + def empty(cls, window_id: str) -> _WidgetFieldSemanticContext: + return cls(window_id=window_id, records_by_geometry={}) + + @classmethod + def from_target( + cls, + target: WindowProjectionTarget, + ) -> _WidgetFieldSemanticContext: + if not isinstance(target.widget, BaseManagedWindow): + return cls.empty(target.summary.window_id) + + records: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord] = {} + for form_manager in target.widget.form_managers(): + cls._collect_form_manager(records, form_manager) + return cls( + window_id=target.summary.window_id, + records_by_geometry=records, + ) + + @classmethod + def _collect_form_manager( + cls, + records: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord], + form_manager: ParameterFormManager, + ) -> None: + state = form_manager.state + if not isinstance(state, ObjectState): + return + + for field_name, widget in form_manager.widgets.items(): + cls._add_field_widget( + records, + form_manager=form_manager, + state=state, + field_name=field_name, + widget=widget, + action_role=FIELD_INPUT_ACTION_ROLE, + ) + for field_name, widget in form_manager.reset_buttons.items(): + cls._add_field_widget( + records, + form_manager=form_manager, + state=state, + field_name=field_name, + widget=widget, + action_role=FIELD_RESET_ACTION_ROLE, + ) + for nested_manager in form_manager.nested_managers.values(): + cls._collect_form_manager(records, nested_manager) + + @classmethod + def _add_field_widget( + cls, + records: dict[_WidgetGeometryKey, _WidgetFieldSemanticRecord], + *, + form_manager: ParameterFormManager, + state: ObjectState, + field_name: str, + widget: QWidget, + action_role: str, + ) -> None: + field_path = cls._field_path(form_manager, field_name) + if field_path not in state.parameters: + return + records[_WidgetGeometryKey.from_qwidget(widget)] = _WidgetFieldSemanticRecord( + action_role=action_role, + projection=ObjectStateFieldSemanticProjection.of( + state, + field_path, + ), + ) + + @staticmethod + def _field_path(form_manager: ParameterFormManager, field_name: str) -> str: + if form_manager.field_id: + return f"{form_manager.field_id}.{field_name}" + return field_name + + def for_descriptor( + self, + descriptor: WidgetDescriptor, + ) -> _WidgetFieldSemanticRecord | None: + return self.records_by_geometry.get(_WidgetGeometryKey.from_descriptor(descriptor)) + + +@dataclass(frozen=True, slots=True) +class _WidgetItemSemanticRecord: + """ObjectState semantics already attached to one projected item-view row.""" + + agent_scope_id: str | None + dirty_fields: tuple[str, ...] + signature_diff_fields: tuple[str, ...] + + @classmethod + def from_index(cls, index: QModelIndex) -> _WidgetItemSemanticRecord: + scope_value = index.data(OBJECT_STATE_PATH_ROLE) + agent_scope_id = None + if isinstance(scope_value, str): + agent_scope_id = _agent_object_state_scope_id(scope_value) + return cls( + agent_scope_id=agent_scope_id, + dirty_fields=cls._field_tuple(index.data(DIRTY_FIELDS_ROLE)), + signature_diff_fields=cls._field_tuple(index.data(SIG_DIFF_FIELDS_ROLE)), + ) + + @staticmethod + def _field_tuple(value) -> tuple[str, ...]: + if isinstance(value, str): + return (value,) + if isinstance(value, (frozenset, list, set, tuple)): + return tuple(field for field in value if isinstance(field, str)) + return () + + @property + def has_semantics(self) -> bool: + return bool(self.agent_scope_id or self.dirty_fields or self.signature_diff_fields) + + @property + def dirty(self) -> bool: + return bool(self.dirty_fields) + + @property + def signature_diff(self) -> bool: + return bool(self.signature_diff_fields) + + @property + def semantic_markers(self) -> tuple[str, ...]: + return field_markers(dirty=self.dirty, signature_diff=self.signature_diff) + + def to_action_carrier(self) -> UiWidgetActionSemanticCarrier: + return UiWidgetActionSemanticCarrier( + action_role=ITEM_SELECT_ACTION_ROLE, + object_state_scope_id=self.agent_scope_id, + dirty=self.dirty, + signature_diff=self.signature_diff, + semantic_markers=self.semantic_markers, + ) + + +@dataclass(frozen=True, slots=True) +class _WidgetItemSemanticContext: + """Resolve shared manager-list item roles for projected item rows.""" + + root_widget: QWidget | None + + @classmethod + def empty(cls) -> _WidgetItemSemanticContext: + return cls(root_widget=None) + + @classmethod + def from_target( + cls, + target: WindowProjectionTarget, + ) -> _WidgetItemSemanticContext: + return cls(root_widget=target.widget) + + def for_descriptor( + self, + descriptor: WidgetDescriptor, + ) -> _WidgetItemSemanticRecord | None: + if self.root_widget is None: + return None + resolution = UiWidgetPathResolver.item_view_index_at_path( + self.root_widget, + descriptor.path, + ) + if resolution is None: + return None + record = _WidgetItemSemanticRecord.from_index(resolution.index) + if not record.has_semantics: + return None + return record + + +class UiWidgetTreeResultFactory: + """Build UI bridge widget-tree results from resolved Qt window targets.""" + + def project( + self, + request: UiWidgetTreeRequest, + target: WindowProjectionTarget, + ) -> UiWidgetTreeResult: + try: + projection = WidgetTreeProjectionService.project( + target.widget, + policy=request.as_projection_policy(), + ) + except Exception as exc: + return self.error( + request, + AgentError.from_exception("ui_widget_tree_projection_failed", exc), + summary=target.summary, + ) + return self.from_projection( + request, + target.summary, + projection, + field_semantics=_WidgetFieldSemanticContext.from_target(target), + item_semantics=_WidgetItemSemanticContext.from_target(target), + ) + + @classmethod + def from_projection( + cls, + request: UiWidgetTreeRequest, + summary: UiWindowSummary, + projection: WidgetTreeProjection, + field_semantics: _WidgetFieldSemanticContext | None = None, + item_semantics: _WidgetItemSemanticContext | None = None, + ) -> UiWidgetTreeResult: + if field_semantics is None: + field_semantics = _WidgetFieldSemanticContext.empty(summary.window_id) + if item_semantics is None: + item_semantics = _WidgetItemSemanticContext.empty() + action_draft = cls._draft_node( + projection.root, + actionable_only=False, + ) + assert action_draft is not None + draft = cls._draft_node( + projection.root, + actionable_only=request.actionable_only, + ) + actionable_widgets = tuple( + cls.action_summaries_from_draft( + action_draft, + field_semantics=field_semantics, + item_semantics=item_semantics, + ) + ) + root = None + returned_widget_count = 0 + if request.include_tree and draft is not None: + root = cls.node_from_draft(draft) + returned_widget_count = cls.included_node_count(draft) + return UiWidgetTreeResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + projected=True, + root=root, + actionable_widgets=actionable_widgets, + summary=summary, + widget_count=projection.widget_count, + actionable_count=len(actionable_widgets), + returned_widget_count=returned_widget_count, + returned_actionable_count=len(actionable_widgets), + tree_truncated=projection.truncated, + actionable_widgets_truncated=projection.truncated, + actionable_only=request.actionable_only, + include_tree=request.include_tree, + max_depth=request.max_depth, + max_nodes=request.max_nodes, + ) + + @classmethod + def _draft_node( + cls, + descriptor: WidgetDescriptor, + *, + actionable_only: bool, + ) -> _WidgetTreeNodeDraft | None: + children: list[_WidgetTreeNodeDraft] = [] + for child in descriptor.children: + child_draft = cls._draft_node(child, actionable_only=actionable_only) + if child_draft is not None: + children.append(child_draft) + + if descriptor.path and actionable_only and not descriptor.actionable and not children: + return None + return _WidgetTreeNodeDraft(descriptor, tuple(children)) + + @classmethod + def node_from_draft( + cls, + draft: _WidgetTreeNodeDraft, + ) -> UiWidgetTreeNode: + return cls.node( + draft.descriptor, + children=tuple(cls.node_from_draft(child) for child in draft.children), + ) + + @classmethod + def included_node_count(cls, draft: _WidgetTreeNodeDraft) -> int: + return 1 + sum(cls.included_node_count(child) for child in draft.children) + + @classmethod + def action_summaries_from_draft( + cls, + draft: _WidgetTreeNodeDraft, + *, + field_semantics: _WidgetFieldSemanticContext, + item_semantics: _WidgetItemSemanticContext, + ancestors: tuple[_WidgetTreeNodeDraft, ...] = (), + ): + if draft.descriptor.actionable: + summary = cls.action_summary( + draft.descriptor, + context_label=cls.action_context_label(draft, ancestors), + field_semantics=field_semantics, + item_semantics=item_semantics, + ) + if UiWidgetActionSummaryPolicy.includes(summary): + yield summary + + for child in draft.children: + yield from cls.action_summaries_from_draft( + child, + field_semantics=field_semantics, + item_semantics=item_semantics, + ancestors=(*ancestors, draft), + ) + + @classmethod + def action_summary( + cls, + descriptor: WidgetDescriptor, + *, + context_label: str | None = None, + field_semantics: _WidgetFieldSemanticContext | None = None, + item_semantics: _WidgetItemSemanticContext | None = None, + ) -> UiWidgetActionSummary: + label = cls.action_label(descriptor) + action_kinds = tuple(kind.value for kind in descriptor.action_kinds) + summary = project_dataclass( + UiWidgetActionSummary, + descriptor, + label=label, + geometry=cls.rect(descriptor.geometry), + global_geometry=cls.rect(descriptor.global_geometry), + action_kinds=action_kinds, + context_label=context_label, + ) + return overlay_non_none_dataclass( + summary, + cls.action_semantic_carrier( + descriptor, + label=label, + action_kinds=action_kinds, + field_semantics=field_semantics, + item_semantics=item_semantics, + ), + ) + + @staticmethod + def action_semantic_carrier( + descriptor: WidgetDescriptor, + *, + label: str | None, + action_kinds: tuple[str, ...], + field_semantics: _WidgetFieldSemanticContext | None, + item_semantics: _WidgetItemSemanticContext | None, + ) -> UiWidgetActionSemanticCarrier: + if field_semantics is not None: + field_record = field_semantics.for_descriptor(descriptor) + if field_record is not None: + return field_record.to_action_carrier(window_id=field_semantics.window_id) + if item_semantics is not None: + item_record = item_semantics.for_descriptor(descriptor) + if item_record is not None: + return item_record.to_action_carrier() + if FieldResetWidgetActionSummary.matches_fields( + class_name=descriptor.class_name, + label=label, + object_name=descriptor.object_name, + action_kinds=action_kinds, + ): + return UiWidgetActionSemanticCarrier(action_role=FIELD_RESET_ACTION_ROLE) + return UiWidgetActionSemanticCarrier() + + @classmethod + def action_context_label( + cls, + draft: _WidgetTreeNodeDraft, + ancestors: tuple[_WidgetTreeNodeDraft, ...], + ) -> str | None: + branch = draft + for ancestor in reversed(ancestors): + siblings = tuple( + child + for child in ancestor.children + if child.descriptor.path != branch.descriptor.path + ) + if siblings: + label = cls._first_context_label(siblings) + if label: + return label + branch = ancestor + return None + + @classmethod + def _first_context_label( + cls, + drafts: tuple[_WidgetTreeNodeDraft, ...], + ) -> str | None: + for draft in drafts: + descriptor = draft.descriptor + if not descriptor.actionable and not descriptor.action_kinds: + label = cls._descriptor_context_label(descriptor) + if label: + return label + child_label = cls._first_context_label(draft.children) + if child_label: + return child_label + return None + + @staticmethod + def _descriptor_context_label(descriptor: WidgetDescriptor) -> str | None: + for label in ( + descriptor.text, + descriptor.title, + descriptor.accessible_name, + ): + if label: + return label + return None + + @staticmethod + def action_label(descriptor: WidgetDescriptor) -> str | None: + for label in ( + descriptor.text, + descriptor.title, + descriptor.accessible_name, + descriptor.current_text, + descriptor.object_name, + ): + if label: + return label + return None + + @classmethod + def node( + cls, + descriptor: WidgetDescriptor, + *, + children: tuple[UiWidgetTreeNode, ...] | None = None, + ) -> UiWidgetTreeNode: + return UiWidgetTreeNode( + path=descriptor.path, + path_id=descriptor.path_id, + child_index=descriptor.child_index, + class_name=descriptor.class_name, + object_name=descriptor.object_name, + visible=descriptor.visible, + enabled=descriptor.enabled, + geometry=cls.rect(descriptor.geometry), + global_geometry=cls.rect(descriptor.global_geometry), + tool_tip=descriptor.tool_tip, + status_tip=descriptor.status_tip, + whats_this=descriptor.whats_this, + window_title=descriptor.window_title, + accessible_name=descriptor.accessible_name, + accessible_description=descriptor.accessible_description, + text=descriptor.text, + text_truncated=descriptor.text_truncated, + title=descriptor.title, + action_kinds=tuple(kind.value for kind in descriptor.action_kinds), + clickable=descriptor.clickable, + actionable=descriptor.actionable, + checkable=descriptor.checkable, + checked=descriptor.checked, + current_index=descriptor.current_index, + current_text=descriptor.current_text, + item_count=descriptor.item_count, + item_texts=descriptor.item_texts, + children=( + tuple(cls.node(child) for child in descriptor.children) + if children is None + else children + ), + ) + + @staticmethod + def rect(rect: WidgetRect) -> UiWidgetRect: + return UiWidgetRect( + x=rect.x, + y=rect.y, + width=rect.width, + height=rect.height, + ) + + @staticmethod + def error( + request: UiWidgetTreeRequest, + error: AgentError, + *, + summary: UiWindowSummary | None = None, + ) -> UiWidgetTreeResult: + return UiWidgetTreeResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + projected=False, + summary=summary, + errors=(error,), + actionable_only=request.actionable_only, + include_tree=request.include_tree, + max_depth=request.max_depth, + max_nodes=request.max_nodes, + ) + + +@dataclass(frozen=True, slots=True) +class _WidgetTreeNodeDraft: + descriptor: WidgetDescriptor + children: tuple[_WidgetTreeNodeDraft, ...] + + +class FieldResetWidgetActionSummary: + """Action-summary rule for per-field reset buttons.""" + + BUTTON_CLASS_NAME = "QPushButton" + LABEL = PARAMETER_FORM_CONSTANTS.RESET_BUTTON_TEXT + OBJECT_NAME_SUFFIX = ( + f"{PARAMETER_FORM_CONSTANTS.FIELD_ID_SEPARATOR}" + f"{PARAMETER_FORM_CONSTANTS.RESET_BUTTON_TEXT.lower()}" + ) + + @classmethod + def matches(cls, summary: UiWidgetActionSummary) -> bool: + return cls.matches_fields( + class_name=summary.class_name, + label=summary.label, + object_name=summary.object_name, + action_kinds=summary.action_kinds, + ) + + @classmethod + def matches_fields( + cls, + *, + class_name: str, + label: str | None, + object_name: str, + action_kinds: tuple[str, ...], + ) -> bool: + return ( + class_name == cls.BUTTON_CLASS_NAME + and label == cls.LABEL + and object_name.endswith(cls.OBJECT_NAME_SUFFIX) + and action_kinds == ("button",) + ) + + +class UiWidgetActionSummaryPolicy: + """Inclusion policy for action summaries.""" + + @staticmethod + def includes(summary: UiWidgetActionSummary) -> bool: + del summary + return True + + +class UiWindowProjectionService( + ScopeWindowTargetOperationProjectionABC, + WindowCatalogProjectionABC, +): + """Project the main-window/WindowManager window graph into agent DTOs.""" + + projection_id = MAIN_WINDOW_PROVIDER_ID + identity = UiWindowProviderIdentity( + provider_id=MAIN_WINDOW_PROVIDER_ID, + title="Main window windows", + ) + + def __init__(self, main_window: UiBridgeHostWindow) -> None: + self._embedded = EmbeddedWindowProjection(main_window) + self._managed = ManagedWindowProjection(main_window) + self._dynamic = DynamicScopeWindowProjection() + + def summaries(self) -> tuple[UiWindowSummary, ...]: + route_index = self._route_index() + return ( + tuple(route.summary() for route in route_index.embedded_routes) + + tuple(route.summary() for route in route_index.managed_routes) + + self._dynamic.summaries(route_index) + ) + + def overview_sections(self) -> tuple[UiLiveOverviewSection, ...]: + route_index = self._route_index() + return WindowCatalogProjectionABC.overview_sections(self) + tuple( + section + for route in route_index.embedded_routes + for section in route.overview_sections() + ) + + def handles(self, window_id: str) -> bool: + identity = UiWindowIdentity(window_id=window_id) + route_index = self._route_index() + if route_index.embedded_route(identity) is not None: + return True + if route_index.managed_route(identity) is not None: + return True + if identity.window_id in WindowManager.get_open_scopes(): + return True + if self._open_window_manager_scope_id(identity) is not None: + return True + return ScopeWindowRegistry.find_handler(identity.window_id) is not None + + def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: + identity = request.as_identity() + route_index = self._route_index() + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if embedded_route is not None: + return self._focused_result(request, embedded_route.focus()) + + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if managed_route is not None and request.open_policy.create_if_missing: + return self._focused_result(request, managed_route.focus()) + + open_scope_id = self._open_window_manager_scope_id(identity) + if open_scope_id is not None: + if WindowManager.focus_and_navigate(open_scope_id): + return self._focused_result( + request, + self._dynamic_scope_projection().summary( + identity, + open_scope_id, + ), + ) + + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if embedded_route is not None: + return self._focused_result(request, embedded_route.focus()) + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if managed_route is not None and request.open_policy.create_if_missing: + return self._focused_result(request, managed_route.focus()) + + result = ScopeWindowNavigationService.navigate( + WindowNavigationRequest( + scope_id=identity.window_id, + create_if_missing=request.open_policy.create_if_missing, + ) + ) + if result.focused: + return self._focused_result( + request, + self._dynamic_scope_projection().summary( + identity, + result.window_scope_id, + ), + ) + + return UiWindowFocusResult( + schema_version=SCHEMA_VERSION, + window_id=identity.window_id, + focused=False, + errors=(WindowProjectionResultAuthority.unknown_window(identity),), + ) + + def _navigate_static_route( + self, + request: UiWindowNavigateRequest, + resolution: WindowRouteResolution, + completed: Callable[[UiWindowNavigateResult], None] | None = None, + ) -> UiWindowNavigateResult: + dispatch = resolution.route.navigate( + request, + self._completion_for( + request, + completed, + created=False, + summary=resolution.summary, + ), + ) + return self._navigate_result( + request, + focused=dispatch.focused, + created=False, + navigated=dispatch.navigated, + summary=resolution.summary(), + ) + + def navigate( + self, + request: UiWindowNavigateRequest, + completed: Callable[[UiWindowNavigateResult], None] | None = None, + ) -> UiWindowNavigateResult: + identity = request.as_identity() + route_index = self._route_index() + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if embedded_route is not None: + return self._navigate_static_route(request, embedded_route, completed) + + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if managed_route is not None and request.open_policy.create_if_missing: + return self._navigate_static_route(request, managed_route, completed) + + open_scope_id = self._open_window_manager_scope_id(identity) + if open_scope_id is not None: + dispatch = WindowManager.focus_and_navigate_result( + open_scope_id, + item_id=request.item_id, + field_path=request.field_path, + requested_scope_id=identity.window_id, + completed=self._completion_for( + request, + completed, + created=False, + summary=lambda: self._dynamic_scope_projection().summary( + identity, open_scope_id + ), + ), + ) + if dispatch.focused: + return self._navigate_result( + request, + focused=True, + created=False, + navigated=dispatch.navigated, + summary=self._dynamic_scope_projection().summary( + identity, + open_scope_id, + ), + ) + + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if embedded_route is not None: + return self._navigate_static_route(request, embedded_route, completed) + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if managed_route is not None and request.open_policy.create_if_missing: + return self._navigate_static_route(request, managed_route, completed) + + result = ScopeWindowNavigationService.navigate( + WindowNavigationRequest( + scope_id=identity.window_id, + item_id=request.item_id, + field_path=request.field_path, + create_if_missing=request.open_policy.create_if_missing, + ), + completed=self._completion_for( + request, + completed, + created=True, + summary=lambda: self._dynamic_scope_projection().summary( + identity, identity.window_id + ), + ), + ) + if result.focused: + return self._navigate_result( + request, + focused=True, + created=result.created, + navigated=result.navigated, + summary=self._dynamic_scope_projection().summary( + identity, + result.window_scope_id, + ), + ) + + return UiWindowNavigateResult( + schema_version=SCHEMA_VERSION, + window_id=identity.window_id, + focused=False, + navigated=False, + created=result.created, + errors=(WindowProjectionResultAuthority.unknown_window(identity),), + ) + + def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: + identity = request.as_identity() + route_index = self._route_index() + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if embedded_route is not None: + return WindowCloseResultBoundaryPolicy.error( + request, + "ui_window_close_unsupported", + "Embedded main-window panes cannot be closed through this UI bridge operation.", + summary=embedded_route.summary(), + ) + + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=False, + ) + if managed_route is not None: + return self._close_window_manager_scope( + request, + managed_route.summary(), + ) + + open_scope_id = self._open_window_manager_scope_id(identity) + if open_scope_id is not None: + return self._close_window_manager_scope( + request, + self._dynamic_scope_projection().summary( + identity, + open_scope_id, + ), + ) + + embedded_route = self._embedded_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if embedded_route is not None: + return WindowCloseResultBoundaryPolicy.error( + request, + "ui_window_close_unsupported", + "Embedded main-window panes cannot be closed through this UI bridge operation.", + summary=embedded_route.summary(), + ) + managed_route = self._managed_route_resolution( + identity, + route_index, + resolve_scope_alias=True, + ) + if managed_route is not None: + return self._close_window_manager_scope( + request, + managed_route.summary(), + ) + + scope = UiWindowManagerScope.from_identity(self._resolved_window_identity(identity)) + scope_widget = WindowManager.get_window(scope.value) + if scope_widget is not None: + return self._close_window_manager_scope( + request, + self._dynamic_scope_projection().summary( + identity, + scope.value, + ), + ) + + if ScopeWindowRegistry.find_handler(identity.window_id) is not None: + return WindowCloseResultBoundaryPolicy.error( + request, + "ui_window_not_open", + f"UI window scope is not currently open: {identity.window_id!r}", + ) + + return WindowCloseResultBoundaryPolicy.error( + request, + "unknown_ui_window", + f"Unknown or closed UI window: {identity.window_id!r}", + ) + + @staticmethod + def _close_window_manager_scope( + request: UiWindowCloseRequest, + summary: UiWindowSummary, + ) -> UiWindowCloseResult: + scope = summary.manager_scope + if scope is None: + scope = UiWindowManagerScope.from_identity(request.as_identity()) + if WindowManager.close_window(scope.value): + return WindowCloseResultBoundaryPolicy.closed( + request, + summary, + ) + return WindowCloseResultBoundaryPolicy.error( + request, + "ui_window_close_rejected", + f"WindowManager rejected the close request for scope: {scope.value!r}", + summary=summary, + ) + + def _route_index(self) -> WindowRouteIndex: + embedded_routes = self._embedded.routes() + return WindowRouteIndex( + embedded_routes=embedded_routes, + managed_routes=self._managed.routes(embedded_routes), + ) + + def _dynamic_scope_projection(self) -> DynamicScopeWindowProjection: + return self._dynamic + + @staticmethod + def _focused_result( + request: UiWindowFocusRequest, + summary: UiWindowSummary, + ) -> UiWindowFocusResult: + return UiWindowFocusResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + focused=True, + summary=summary, + ) + + @staticmethod + def _completion_for( + request: UiWindowNavigateRequest, + completed: Callable[[UiWindowNavigateResult], None] | None, + *, + created: bool, + summary: Callable[[], UiWindowSummary], + ) -> Callable[[RegisteredWindowNavigationCompletion], None] | None: + if completed is None: + return None + + def finish(facts: RegisteredWindowNavigationCompletion) -> None: + errors = () + if facts.error is not None: + errors = ( + AgentError.from_exception( + UiBridgeNavigateWindowOperation.failure_error_code, facts.error + ), + ) + elif not facts.executed: + errors = ( + AgentError( + code=UiBridgeNavigateWindowOperation.failure_error_code, + message=( + "Window was destroyed before navigation." + if not facts.window_alive + else f"Navigation readiness exhausted: {facts.wait_reason}." + ), + ), + ) + completed( + UiWindowNavigateResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + focused=facts.window_alive, + created=created, + navigated=facts.executed, + target_exposed=facts.target_exposed, + summary=summary() if facts.window_alive else None, + errors=errors, + ) + ) + + return finish + + @staticmethod + def _navigate_result( + request: UiWindowNavigateRequest, + *, + focused: bool, + created: bool, + navigated: bool, + summary: UiWindowSummary, + ) -> UiWindowNavigateResult: + return UiWindowNavigateResult( + schema_version=SCHEMA_VERSION, + window_id=request.window_id, + focused=focused, + created=created, + navigated=navigated, + summary=summary, + errors=( + () + if navigated or not request.has_target + else (WindowProjectionResultAuthority.unsupported_navigation_target(request),) + ), + ) + + +class ManagedWindowActionProvider(UiActionProviderABC): + """Action provider for generic WindowManager-managed form windows.""" + + identity = MANAGED_WINDOW_ACTION_PROVIDER_IDENTITY + dispatch_error_code = "managed_window_action_failed" + + def action_ids(self) -> tuple[str, ...]: + return tuple(action.value for action in ManagedWindowAction) + + def summary(self, action_id: str) -> UiActionSummary: + action = self._action(action_id) + target_scope_ids = self._target_scope_ids(action) + return UiActionSummary( + schema_version=SCHEMA_VERSION, + identity=UiActionIdentity( + widget_id=self.identity.widget_id, + action_id=action.value, + ), + title=action.title, + enabled=bool(target_scope_ids), + invocation_mode="async", + side_effects=action.side_effects, + confirmation_required=action.confirmation_required, + selection_mode="targeted", + required_target_count=1, + current_selection_count=len(target_scope_ids), + target_scope_ids=target_scope_ids, + ) + + def dispatch(self, request: UiActionInvokeRequest) -> UiActionDispatch: + action = self._action(request.action_id) + (target_scope_id,) = request.selected_scope_ids + action.dispatch(self._target_window(target_scope_id, action)) + return UiActionDispatch() + + @staticmethod + def _action(action_id: str) -> ManagedWindowAction: + return ManagedWindowAction(action_id) + + @staticmethod + def _target_window( + scope_id: str, + action: ManagedWindowAction, + ) -> BaseFormDialog: + window = WindowManager.get_window(scope_id) + if not isinstance(window, BaseFormDialog): + raise ValueError(f"Window scope is not a managed form window: {scope_id!r}") + capabilities = window.managed_window_action_capabilities() + if not action.is_supported(capabilities): + raise ValueError(f"Window does not support {action.value!r}: {scope_id!r}") + return window + + @classmethod + def _target_scope_ids( + cls, + action: ManagedWindowAction, + ) -> tuple[str, ...]: + return tuple( + scope_id + for scope_id in WindowManager.get_open_scopes() + if cls._window_supports_action(WindowManager.get_window(scope_id), action) + ) + + @staticmethod + def _window_supports_action( + window: QWidget | None, + action: ManagedWindowAction, + ) -> bool: + if not isinstance(window, BaseFormDialog): + return False + return action.is_supported(window.managed_window_action_capabilities()) diff --git a/src/pyqt_reactive/forms/form_init_service.py b/src/pyqt_reactive/forms/form_init_service.py index 4b1b6346..f886fc9e 100644 --- a/src/pyqt_reactive/forms/form_init_service.py +++ b/src/pyqt_reactive/forms/form_init_service.py @@ -466,7 +466,6 @@ def _record_lineage(self, spec: BuilderSpec, service_type: Type) -> None: import pyqt_reactive.services.signal_service as signal_service import pyqt_reactive.services.parameter_ops_service as parameter_ops_service import pyqt_reactive.services.enabled_field_styling_service as enabled_field_styling_service -import formbridge.services.enum_dispatch_service as enum_dispatch_service class ServiceRegistryMeta(type): diff --git a/src/pyqt_reactive/forms/widget_creation_config.py b/src/pyqt_reactive/forms/widget_creation_config.py index 14358839..ad95168b 100644 --- a/src/pyqt_reactive/forms/widget_creation_config.py +++ b/src/pyqt_reactive/forms/widget_creation_config.py @@ -21,8 +21,6 @@ from enum import Enum import logging import itertools - -logger = logging.getLogger(__name__) from typing import Any, Callable, ClassVar, Optional, TYPE_CHECKING, Type, Tuple from metaclass_registry import AutoRegisterMeta from python_introspect import resolve_optional @@ -36,6 +34,8 @@ from pyqt_reactive.widgets.shared.responsive_layout_widgets import ResponsiveParameterRow from formbridge.forms.layout_constants import CURRENT_LAYOUT +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from PyQt6.QtWidgets import QGroupBox, QLayout, QWidget from pyqt_reactive.widgets.no_scroll_spinbox import NoneAwareCheckBox diff --git a/src/pyqt_reactive/services/parameter_ops_service.py b/src/pyqt_reactive/services/parameter_ops_service.py index 367e3595..53370531 100644 --- a/src/pyqt_reactive/services/parameter_ops_service.py +++ b/src/pyqt_reactive/services/parameter_ops_service.py @@ -13,35 +13,19 @@ """ from __future__ import annotations -from typing import Any, TYPE_CHECKING + import logging -from functools import wraps +from typing import TYPE_CHECKING +from formbridge.core.performance_monitor import get_monitor, timer from formbridge.services.parameter_service_abc import ParameterServiceABC -# Optional performance monitoring - stub if not available -try: - from formbridge.core.performance_monitor import timer, get_monitor -except ImportError: - def timer(name): - """No-op timer decorator when performance_monitor not available.""" - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - return wrapper - return decorator - - def get_monitor(): - """No-op monitor when performance_monitor not available.""" - return None - if TYPE_CHECKING: from formbridge.forms.parameter_info_types import ( - OptionalDataclassInfo, DirectDataclassInfo, GenericInfo, InlineDataclassWidgetInfo, + OptionalDataclassInfo, ) logger = logging.getLogger(__name__) diff --git a/src/pyqt_reactive/services/window_snapshot.py b/src/pyqt_reactive/services/window_snapshot.py index 9ec6a564..7ba44a97 100644 --- a/src/pyqt_reactive/services/window_snapshot.py +++ b/src/pyqt_reactive/services/window_snapshot.py @@ -3,183 +3,94 @@ from __future__ import annotations import hashlib -from abc import ABC, abstractmethod import re import time -from math import isfinite +from abc import ABC, abstractmethod from collections.abc import Callable -from dataclasses import dataclass, fields +from dataclasses import dataclass from datetime import UTC, datetime -from enum import StrEnum from pathlib import Path -from typing import TYPE_CHECKING -from python_introspect import project_dataclass +from typing import TYPE_CHECKING, ClassVar + +from formbridge.flash_trace import FlashTrace +from formbridge.services.window_snapshot import ( + FlashObservation, + FrameObservation, + NoObservation, + RenderObservation, + WindowRenderFrame, + WindowSnapshotCaptureScope, + WindowSnapshotCaptureSpec, + WindowSnapshotObservationFailure, + WindowVisualObservation, +) +from metaclass_registry import AutoRegisterMeta from zmqruntime.timeouts import OperationDeadline -from formbridge.flash_trace import FlashTrace, FlashTraceRecord - if TYPE_CHECKING: from PyQt6.QtCore import pyqtBoundSignal from PyQt6.QtGui import QPixmap - from PyQt6.QtWidgets import QWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget + from PyQt6.QtWidgets import QWidget -QtWindowCaptureCallable = Callable[["QWidget"], "QPixmap"] +class QtCaptureScope(ABC, metaclass=AutoRegisterMeta): + """Qt rendering of one capture scope; each scope is a subclass.""" -@dataclass(frozen=True, slots=True) -class FlashPaintElement: - """One source actually painted, in owning-window coordinates.""" + __registry_key__ = "scope" + __skip_if_no_key__ = True - key: str - source_token: str - rect: tuple[int, int, int, int] - rgba: tuple[int, int, int, int] + scope: ClassVar[WindowSnapshotCaptureScope | None] = None + @staticmethod + @abstractmethod + def render(widget: QWidget) -> QPixmap: + """Render the scope's pixels without sampling desktop pixels.""" -@dataclass(frozen=True, slots=True) -class FlashPaintFrame: - """Receipt emitted after the flash painter has ended.""" + @staticmethod + def capture(scope: WindowSnapshotCaptureScope, widget: QWidget) -> QPixmap: + return QtCaptureScope.__registry__[scope].render(widget) - window_identity: int - painted_at_monotonic: float - configured_maximum_alpha: int - configured_flash_duration_s: float - elements: tuple[FlashPaintElement, ...] - @property - def has_maximum_alpha(self) -> bool: - return any(element.rgba[3] == self.configured_maximum_alpha for element in self.elements) - - -def _maximum_alpha_frame(frame: FlashPaintFrame) -> bool: - return frame.has_maximum_alpha - - -def _no_frame(frame: FlashPaintFrame) -> bool: - return False - - -def _observe_flash(*args): - _WindowFlashSnapshotObservation(*args) - - -def _observe_render(*args): - _WindowRenderSnapshotObservation(*args) - - -def _capture_immediate(service, request, completed, failed): - completed(service.capture(request)) - - -def _validate_render_observation(observation): - if observation.render_frame is None: - raise ValueError("Render-complete capture has no native completion receipt.") - frame = observation.render_frame - if ( - frame.window_identity != observation.window_identity - or not observation.started_at_monotonic - <= frame.completed_at_monotonic - <= observation.completed_at_monotonic - ): - raise ValueError("Native completion receipt is outside this window observation.") - - -def _validate_visual_observation(observation): - pass - - -class WindowSnapshotFrameCondition(StrEnum): - """Closed capture conditions evaluated from real renderer receipts.""" - - IMMEDIATE = ( - "immediate", - False, - _no_frame, - False, - False, - _capture_immediate, - _validate_visual_observation, - ) - FLASH_MAXIMUM_ALPHA = ( - "flash_maximum_alpha", - True, - _maximum_alpha_frame, - False, - True, - _observe_flash, - _validate_visual_observation, - ) - NO_FLASH = ( - "no_flash", - True, - _no_frame, - True, - False, - _observe_flash, - _validate_visual_observation, - ) - RENDER_COMPLETE = ( - "render_complete", - True, - _no_frame, - False, - False, - _observe_render, - _validate_render_observation, - ) - - def __new__( - cls, - value, - observes, - accepts_frame, - accepts_quiet, - requests_native_render, - observe, - validate, - ): - member = str.__new__(cls, value) - member._value_ = value - member.observes = observes - member._accepts_frame = accepts_frame - member.accepts_quiet = accepts_quiet - member.requests_native_render = requests_native_render - member._observe = observe - member._validate = validate - return member - - def accepts_frame(self, frame: FlashPaintFrame) -> bool: - return self._accepts_frame(frame) - - def accepts_timeout(self, starts: int, frames: int) -> bool: - return self.accepts_quiet and starts == 0 and frames == 0 - - def observe(self, service, request, completed, failed) -> None: - if not self.observes: - raise ValueError("Immediate snapshots do not require observation.") - self._observe(service, request, completed, failed) +class QtWidgetCapture(QtCaptureScope): + scope = WindowSnapshotCaptureScope.WIDGET - def request_capture(self, service, request, completed, failed) -> None: - self._observe(service, request, completed, failed) + @staticmethod + def render(widget: QWidget) -> QPixmap: + return widget.grab() - def validate_observation(self, observation: WindowVisualObservation | None) -> None: - if observation is None: - if self.observes: - raise ValueError("Observed capture has no frame-condition receipt.") - return - if observation.condition is not self: - raise ValueError("Frame-condition receipt does not match the capture contract.") - self._validate(observation) +class QtWindowCapture(QtCaptureScope): + scope = WindowSnapshotCaptureScope.WINDOW -@dataclass(frozen=True, slots=True) -class WindowRenderFrame: - """Native renderer completion witnessed after this observation was armed.""" + @staticmethod + def render(widget: QWidget) -> QPixmap: + return widget.window().grab() - window_identity: int - renderer_identity: int - completed_at_monotonic: float + +class QtFrameObservation(ABC, metaclass=AutoRegisterMeta): + """Qt implementation of one frame observation kind; each kind is a subclass.""" + + __registry_key__ = "observation" + __skip_if_no_key__ = True + + observation: ClassVar[type[FrameObservation] | None] = None + + @staticmethod + def start(service, request, completed, failed) -> None: + """Run the requested condition's observation, then capture.""" + + condition = request.capture.frame_condition + QtFrameObservation.__registry__[condition.observation]( + service, request, completed, failed + ) + + +class _ImmediateSnapshot(QtFrameObservation): + observation = NoObservation + + def __init__(self, service, request, completed, failed): + completed(service.capture(request)) class WindowSnapshotRenderOwner(ABC): @@ -218,106 +129,6 @@ def request_frame(self) -> None: self.canvas.update() -@dataclass(frozen=True, slots=True) -class WindowVisualObservation: - """Actual target-window activity during one armed observation.""" - - condition: WindowSnapshotFrameCondition - window_identity: int - started_at_monotonic: float - completed_at_monotonic: float - configured_flash_duration_s: float - baseline_inactive: bool - flash_start_count: int - painted_frame_count: int - frame: FlashPaintFrame | None = None - # Failure diagnostics reuse the existing bounded process-local trace ring. - # These contextual records are not asserted to be target-window-only. - trace: tuple[FlashTraceRecord, ...] = () - render_frame: WindowRenderFrame | None = None - observation_budget_s: float | None = None - operation_deadline: OperationDeadline | None = None - - -@dataclass(frozen=True, slots=True) -class WindowSnapshotObservationFailure: - """Failed observation with its actual activity evidence preserved.""" - - error: Exception - observation: WindowVisualObservation - - -def _widget_pixmap(widget: QWidget) -> QPixmap: - """Render the exact requested widget without sampling desktop pixels.""" - - return widget.grab() - - -def _window_pixmap(widget: QWidget) -> QPixmap: - """Render the requested widget's owning Qt window.""" - - return widget.window().grab() - - -class WindowSnapshotCaptureScope(StrEnum): - """Safe Qt-rendered screenshot scopes with declaration-owned capture logic.""" - - WIDGET = ("widget", _widget_pixmap) - WINDOW = ("window", _window_pixmap) - - def __new__( - cls, - value: str, - capture: QtWindowCaptureCallable, - ) -> WindowSnapshotCaptureScope: - member = str.__new__(cls, value) - member._value_ = value - member._capture = capture - return member - - def capture(self, widget: QWidget) -> QPixmap: - """Capture through this member's Qt-rendered pixel authority.""" - - return self._capture(widget) - - -@dataclass(frozen=True, kw_only=True) -class WindowSnapshotCaptureSpec: - """Requested destination and safe Qt-rendered capture scope.""" - - output_dir_path: str - capture_scope: WindowSnapshotCaptureScope = WindowSnapshotCaptureScope.WIDGET - frame_condition: WindowSnapshotFrameCondition = WindowSnapshotFrameCondition.IMMEDIATE - observation_timeout_s: float = 5.0 - - def __post_init__(self) -> None: - if not isfinite(self.observation_timeout_s) or not 0 < self.observation_timeout_s <= 30: - raise ValueError("observation_timeout_s must be finite and in (0, 30].") - - def snapshot_operation_deadline(self) -> OperationDeadline | None: - """Optional enclosing operation supplied by a product request owner.""" - return None - - @staticmethod - def observation_phase_budget(remaining_seconds: float) -> float: - """Reserve the other half of an operation for capture/reply delivery.""" - return remaining_seconds / 2 - - def same_capture_contract(self, other: WindowSnapshotCaptureSpec) -> bool: - """Return whether two snapshot carriers request the same capture.""" - - return project_dataclass(WindowSnapshotCaptureSpec, self) == project_dataclass( - WindowSnapshotCaptureSpec, other - ) - - def capture_fields(self) -> dict[str, object]: - """Project every capture-owned field, excluding independent carrier fields.""" - return { - declared.name: getattr(self, declared.name) - for declared in fields(WindowSnapshotCaptureSpec) - } - - @dataclass(frozen=True, slots=True) class QtWindowSnapshot: """Saved screenshot metadata.""" @@ -388,7 +199,7 @@ def request_capture( failed: Callable[[WindowSnapshotObservationFailure], None], ) -> None: """Capture through the requested condition's declared implementation.""" - request.capture.frame_condition.request_capture(self, request, completed, failed) + QtFrameObservation.start(self, request, completed, failed) def observe( self, @@ -397,7 +208,9 @@ def observe( failed: Callable[[WindowSnapshotObservationFailure], None], ) -> None: """Arm a bounded real-renderer observation without blocking UI mutations.""" - request.capture.frame_condition.observe(self, request, completed, failed) + if not request.capture.frame_condition.observes: + raise ValueError("Immediate snapshots do not require observation.") + QtFrameObservation.start(self, request, completed, failed) def capture( self, @@ -408,7 +221,7 @@ def capture( request.capture.frame_condition.validate_observation(observation) request.require_operation_budget() - pixmap = request.capture.capture_scope.capture(request.widget) + pixmap = QtCaptureScope.capture(request.capture.capture_scope, request.widget) return self._persist(request, pixmap, observation) def _persist( @@ -475,7 +288,7 @@ def _safe_filename_token(self, value: str) -> str: return token[:80] if token else "window" -class _WindowSnapshotObservation(ABC): +class _WindowSnapshotObservation(QtFrameObservation): """One Qt-owned deadline, capture, failure and cleanup algorithm.""" def __init__(self, service, request, completed, failed, owner, flash_duration_s=0.0): @@ -603,6 +416,8 @@ def _destroyed(self): class _WindowRenderSnapshotObservation(_WindowSnapshotObservation): """Small native-render hooks on the shared observation lifecycle.""" + observation = RenderObservation + def __init__(self, service, request, completed, failed): owner = request.render_owner if owner is None: @@ -634,6 +449,8 @@ def _render_completed(self): class _WindowFlashSnapshotObservation(_WindowSnapshotObservation): """Flash-owner hooks on the shared observation lifecycle.""" + observation = FlashObservation + def __init__(self, service, request, completed, failed): from pyqt_reactive.animation.flash_mixin import WindowFlashOverlay @@ -674,7 +491,7 @@ def _render_current_frame(self): self.rendering = True self.rendered_frame = None try: - pixmap = self.request.capture.capture_scope.capture(self.request.widget) + pixmap = QtCaptureScope.capture(self.request.capture.capture_scope, self.request.widget) except Exception as exc: self._fail(exc) return diff --git a/src/pyqt_reactive/widgets/function_list_editor.py b/src/pyqt_reactive/widgets/function_list_editor.py index 32cf3d7d..de4fc13b 100644 --- a/src/pyqt_reactive/widgets/function_list_editor.py +++ b/src/pyqt_reactive/widgets/function_list_editor.py @@ -44,7 +44,6 @@ from pyqt_reactive.widgets.function_pane import FunctionPaneWidget from objectstate import ObjectStateRegistry, semantic_values_equal from pyqt_reactive.theming import ColorScheme, WidgetTheme -from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.widgets.shared.detachable_action_bar import ( DetachableActionBar, DetachableActionBarHost, diff --git a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py index ffb7f524..320b366a 100644 --- a/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py +++ b/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py @@ -81,6 +81,7 @@ from pyqt_reactive.services.window_navigation import ListItemWindowNavigationDriver from formbridge.services.window_code_document import WindowCodeDocument, WindowCodeDocumentDriver from pyqt_reactive.widgets.shared.scope_visual_config import ListItemType +from formbridge.services.host_services import UiHostServices # Backwards compat alias SEGMENTS_ROLE = LAYOUT_ROLE @@ -642,7 +643,7 @@ def code_document_driver(self) -> WindowCodeDocumentDriver | None: return None return AbstractManagerWidgetCodeDocumentDriver(self) - def __init__(self, service_adapter, color_scheme=None, parent=None): + def __init__(self, service_adapter: UiHostServices, color_scheme=None, parent=None): """ Initialize base widget. diff --git a/src/pyqt_reactive/widgets/system_monitor.py b/src/pyqt_reactive/widgets/system_monitor.py index 2c3b36d2..8615344e 100644 --- a/src/pyqt_reactive/widgets/system_monitor.py +++ b/src/pyqt_reactive/widgets/system_monitor.py @@ -19,25 +19,22 @@ from PyQt6.QtCore import QTimer, pyqtSignal, Qt from PyQt6.QtGui import QFont, QResizeEvent -from pyqt_reactive.services.system_monitor_actions import SystemMonitorAction - -# Lazy import of PyQtGraph to avoid blocking startup. PyQtGraph imports cupy at -# module level, so the presentation backend is selected only after that import. -pg = None # Will be set when pyqtgraph is imported - -# Import the SystemMonitorCore service (framework-agnostic) -from pyqt_reactive.animation import queue_visual_frame_callback -from pyqt_reactive.theming import ColorScheme - +from formbridge.services.system_metrics_sampler import SystemMetrics, SystemMetricsSampler +from formbridge.services.system_monitor_config import PerformanceMonitorConfig from formbridge.services.system_monitor_core import SystemMonitorCore +from pyqt_reactive.animation import queue_visual_frame_callback from pyqt_reactive.services.persistent_system_monitor import ( PersistentSystemMonitor, PersistentSystemMonitorThread, ) -from formbridge.services.system_monitor_config import PerformanceMonitorConfig -from formbridge.services.system_metrics_sampler import SystemMetrics, SystemMetricsSampler +from pyqt_reactive.services.system_monitor_actions import SystemMonitorAction +from pyqt_reactive.theming import ColorScheme from pyqt_reactive.widgets.shared.manager_ui_scaffold import create_manager_header +# Lazy import of PyQtGraph to avoid blocking startup. PyQtGraph imports cupy at +# module level, so the presentation backend is selected only after that import. +pg = None # Will be set when pyqtgraph is imported + logger = logging.getLogger(__name__) diff --git a/tests/test_only_qt_code.py b/tests/test_only_qt_code.py new file mode 100644 index 00000000..d9c406db --- /dev/null +++ b/tests/test_only_qt_code.py @@ -0,0 +1,64 @@ +"""pyqt-reactive holds only Qt code; toolkit-neutral modules live in formbridge.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from formbridge.services.window_snapshot import ( + FrameObservation, + WindowSnapshotCaptureScope, +) + +from pyqt_reactive.services.window_snapshot import QtCaptureScope, QtFrameObservation + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] / "src" / "pyqt_reactive" +QT_PACKAGES = frozenset({"PyQt6", "qtpy", "vispy", "magicgui", "pyqtgraph", "OpenGL"}) + + +def _module_name(path: Path) -> str: + parts = list(path.relative_to(PACKAGE_ROOT.parent).with_suffix("").parts) + return ".".join(parts[:-1] if parts[-1] == "__init__" else parts) + + +def _imports(path: Path) -> set[str]: + package = _module_name(path).split(".") + if path.name != "__init__.py": + package = package[:-1] + names: set[str] = set() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = package[: len(package) - node.level + 1] if node.level else [] + module = ".".join([*base, *([node.module] if node.module else [])]) + names.add(module) + names.update(f"{module}.{alias.name}" for alias in node.names) + return names + + +def test_every_module_is_qt_code() -> None: + """Each module imports a Qt binding, directly or through another package module.""" + + every_module = {_module_name(path): path for path in PACKAGE_ROOT.rglob("*.py")} + modules = {name: path for name, path in every_module.items() if path.name != "__init__.py"} + imports = {name: _imports(path) for name, path in every_module.items()} + qt = {name for name, names in imports.items() if {n.split(".")[0] for n in names} & QT_PACKAGES} + grew = True + while grew: + grew = False + for name, names in imports.items(): + if name not in qt and any( + imported == other or imported.startswith(other + ".") + for imported in names + for other in qt + ): + qt.add(name) + grew = True + assert sorted(set(modules) - qt) == [] + + +def test_every_capture_scope_and_frame_observation_has_a_qt_implementation() -> None: + assert set(QtCaptureScope.__registry__) == set(WindowSnapshotCaptureScope) + kinds = {kind for kind in FrameObservation.__subclasses__()} + assert set(QtFrameObservation.__registry__) == kinds diff --git a/tests/test_window_snapshot.py b/tests/test_window_snapshot.py index ab128ecc..bec74555 100644 --- a/tests/test_window_snapshot.py +++ b/tests/test_window_snapshot.py @@ -12,10 +12,12 @@ from pyqt_reactive.services.window_snapshot import ( QtWindowSnapshotRequest, QtWindowSnapshotService, + WindowSnapshotRenderOwner, +) +from formbridge.services.window_snapshot import ( WindowSnapshotCaptureScope, WindowSnapshotCaptureSpec, WindowSnapshotFrameCondition, - WindowSnapshotRenderOwner, ) diff --git a/tests/test_window_snapshot_bindings.py b/tests/test_window_snapshot_bindings.py index 76255a38..131de960 100644 --- a/tests/test_window_snapshot_bindings.py +++ b/tests/test_window_snapshot_bindings.py @@ -10,6 +10,8 @@ OpenGLWidgetSnapshotRenderOwner, QtWindowSnapshotRequest, QtWindowSnapshotService, +) +from formbridge.services.window_snapshot import ( WindowSnapshotCaptureSpec, WindowSnapshotFrameCondition, ) From 01532883954cb5987a90eaf3de972808b01f3531 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 14:56:44 -0400 Subject: [PATCH 3/5] Plain name WindowProjectionResults; CI installs formbridge at its branch head Co-Authored-By: Claude Opus 5.5 --- requirements-ci.txt | 2 +- src/pyqt_reactive/bridge/windows.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 37163b6b..19851d2d 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. # Remove once formbridge 0.1.0 is on PyPI. -formbridge @ git+https://github.com/OpenHCSDev/formbridge.git@efd973fde8ea4bcf3caeb32c722785b9608b0405 +formbridge @ git+https://github.com/OpenHCSDev/formbridge.git@4fa3a527b07f3ff817eff366db8ab88e24f1a36c diff --git a/src/pyqt_reactive/bridge/windows.py b/src/pyqt_reactive/bridge/windows.py index 09872b0c..7bf78c73 100644 --- a/src/pyqt_reactive/bridge/windows.py +++ b/src/pyqt_reactive/bridge/windows.py @@ -236,7 +236,7 @@ class WindowProjectionTarget: summary: UiWindowSummary -class WindowProjectionResultAuthority: +class WindowProjectionResults: """Shared agent-result fragments for UI window projection operations.""" @staticmethod @@ -444,7 +444,7 @@ def snapshot( if target is None: return snapshot_results.error( request, - WindowProjectionResultAuthority.unknown_window(request), + WindowProjectionResults.unknown_window(request), ) return snapshot_results.capture(request, target, completed) @@ -454,7 +454,7 @@ def widget_tree(self, request: UiWidgetTreeRequest) -> UiWidgetTreeResult: if target is None: return widget_tree_results.error( request, - WindowProjectionResultAuthority.unknown_window(request), + WindowProjectionResults.unknown_window(request), ) return widget_tree_results.project(request, target) @@ -467,7 +467,7 @@ def invoke_widget_action( if target is None: return invoke_results.error( request, - WindowProjectionResultAuthority.unknown_window(request), + WindowProjectionResults.unknown_window(request), ) return invoke_results.invoke(request, target) @@ -1061,7 +1061,7 @@ def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: schema_version=SCHEMA_VERSION, window_id=request.window_id, focused=False, - errors=(WindowProjectionResultAuthority.unknown_window(request),), + errors=(WindowProjectionResults.unknown_window(request),), ) target.widget.show() target.widget.raise_() @@ -1094,7 +1094,7 @@ def navigate( errors=( focus_result.errors or ( - (WindowProjectionResultAuthority.unsupported_navigation_target(request),) + (WindowProjectionResults.unsupported_navigation_target(request),) if request.has_target else () ) @@ -2394,7 +2394,7 @@ def focus(self, request: UiWindowFocusRequest) -> UiWindowFocusResult: schema_version=SCHEMA_VERSION, window_id=identity.window_id, focused=False, - errors=(WindowProjectionResultAuthority.unknown_window(identity),), + errors=(WindowProjectionResults.unknown_window(identity),), ) def _navigate_static_route( @@ -2520,7 +2520,7 @@ def navigate( focused=False, navigated=False, created=result.created, - errors=(WindowProjectionResultAuthority.unknown_window(identity),), + errors=(WindowProjectionResults.unknown_window(identity),), ) def close(self, request: UiWindowCloseRequest) -> UiWindowCloseResult: @@ -2713,7 +2713,7 @@ def _navigate_result( errors=( () if navigated or not request.has_target - else (WindowProjectionResultAuthority.unsupported_navigation_target(request),) + else (WindowProjectionResults.unsupported_navigation_target(request),) ), ) From c9bf8bedafb3f04ce78661de1ba4e041ae64cb19 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 15:42:28 -0400 Subject: [PATCH 4/5] CI installs formbridge at its branch head Co-Authored-By: Claude Opus 5.5 --- requirements-ci.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 19851d2d..ecccecc5 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. # Remove once formbridge 0.1.0 is on PyPI. -formbridge @ git+https://github.com/OpenHCSDev/formbridge.git@4fa3a527b07f3ff817eff366db8ab88e24f1a36c +formbridge @ git+https://github.com/OpenHCSDev/formbridge.git@dac016354f8d88924fd40073dac6b06f650bbc19 From f8178de94d8d55b2837c26bdf3db1f0155d6281a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:00:34 -0400 Subject: [PATCH 5/5] Depend on released formbridge 0.1.0 Co-Authored-By: Claude Opus 5.5 --- requirements-ci.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index ecccecc5..d8cee73e 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,2 @@ # Source candidates needed to validate this checkout before coordinated publication. -# Remove once formbridge 0.1.0 is on PyPI. -formbridge @ git+https://github.com/OpenHCSDev/formbridge.git@dac016354f8d88924fd40073dac6b06f650bbc19 +# Empty: every first-party dependency is released (formbridge 0.1.0, objectstate 2.1.1, python-introspect 0.2.8).