diff --git a/app.py b/app.py index ca3f3f2..78175a3 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,7 @@ """Main application class for ClickHouse Client.""" +import logging + from dearpygui.dearpygui import * from async_worker import AsyncWorker @@ -17,6 +19,8 @@ from theme_manager import ThemeManager from utils import UIHelpers +logger = logging.getLogger(__name__) + class ClickHouseClientApp: """Main application class that orchestrates all components.""" @@ -85,6 +89,9 @@ def __init__(self): # Set up callbacks for connection manager self.connection_manager.on_connect_success = self._handle_connect_success + self.connection_manager.on_connect_failure = ( + self.table_browser_ui.handle_connect_failure + ) # Initialize status manager with theme StatusManager.set_theme_manager(self.theme_manager) @@ -114,6 +121,8 @@ def setup_ui(self): def _handle_connect_success(self): """Handle additional tasks after successful connection.""" try: + self.table_browser_ui.clear_connection_failure_notice() + # Configure the connection pool with the current credentials params = self.connection_manager.get_connection_parameters() if params: @@ -128,7 +137,7 @@ def _handle_connect_success(self): self.table_browser_ui.filter_tables_callback(None, current_search) except Exception as e: - print(f"[DEBUG] Error in connect success handler: {str(e)}") + logger.debug("Error in connect success handler: %s", e, exc_info=True) def _handle_credentials_saved(self): """Handle tasks after credentials are saved.""" @@ -136,7 +145,7 @@ def _handle_credentials_saved(self): # Update the connections list in the table browser UI self.table_browser_ui.show_saved_connections() except Exception as e: - print(f"[DEBUG] Error in credentials saved handler: {str(e)}") + logger.debug("Error in credentials saved handler: %s", e, exc_info=True) def run(self): """Run the application.""" @@ -157,6 +166,7 @@ def run(self): # Keep expensive result-grid callbacks within a small per-frame budget. self.async_worker.process_pending() self.table_browser_ui.process_pending_filter() + self.table_browser_ui.process_pending_connection_notice() self.ui_layout.splitter.update() self.tabbed_query_interface.update_resize() self.tabbed_query_interface.poll_closed_tabs() diff --git a/components/connection_manager.py b/components/connection_manager.py index db9c4aa..b0ff89e 100644 --- a/components/connection_manager.py +++ b/components/connection_manager.py @@ -1,5 +1,6 @@ """Connection management functionality for ClickHouse Client.""" +import logging import time import traceback @@ -21,6 +22,8 @@ from database import DatabaseManager from utils import UIHelpers, validate_connection_params +logger = logging.getLogger(__name__) + class ConnectionManager: """Manages database connections and related operations.""" @@ -40,6 +43,12 @@ def __init__( # Optional callbacks for additional functionality self.on_connect_success = None # Called after successful connection + self.on_connect_failure = None # Called after a failed connection attempt + + def _notify_connect_failure(self): + """Notify UI components that the current connection attempt has ended.""" + if self.on_connect_failure: + self.on_connect_failure() def _show_modal_status(self, message, error=False): """Show a status message in the connection modal's status area.""" @@ -55,7 +64,7 @@ def _show_modal_status(self, message, error=False): def get_connection_parameters(self): """Get connection parameters, prioritizing form values over stored credentials.""" - print("[DEBUG] Attempting to get connection parameters") + logger.debug("Attempting to get connection parameters") # Try to get values from form first host = UIHelpers.safe_get_value("host_input", None) @@ -73,7 +82,7 @@ def get_connection_parameters(self): ) if form_has_values: - print("[DEBUG] Using form values") + logger.debug("Using form values") # Use form values, with defaults for empty optional fields return { "host": host or DEFAULT_HOST, @@ -83,7 +92,7 @@ def get_connection_parameters(self): "database": database or DEFAULT_DATABASE, } elif self.stored_credentials: - print("[DEBUG] Form values not available, using stored credentials") + logger.debug("Form values not available, using stored credentials") return { "host": self.stored_credentials.get("host", DEFAULT_HOST), "port": self.stored_credentials.get("port", DEFAULT_PORT), @@ -92,8 +101,8 @@ def get_connection_parameters(self): "database": self.stored_credentials.get("database", DEFAULT_DATABASE), } else: - print( - "[DEBUG] No form values or stored credentials available, using defaults" + logger.debug( + "No form values or stored credentials available, using defaults" ) return { "host": DEFAULT_HOST, @@ -116,8 +125,12 @@ def connect_callback(self, sender, data): password = params["password"] database = params["database"] - print( - f"[DEBUG] Connection parameters: host={host}, port={port}, username={username}, database={database}" + logger.debug( + "Connection parameters: host=%s, port=%s, username=%s, database=%s", + host, + port, + username, + database, ) is_valid, error_msg = validate_connection_params( @@ -126,7 +139,7 @@ def connect_callback(self, sender, data): if not is_valid: raise ValueError(error_msg) - print("[DEBUG] Connection parameters validated successfully") + logger.debug("Connection parameters validated successfully") except Exception as e: # Validation failed on main thread — report immediately @@ -139,6 +152,7 @@ def connect_callback(self, sender, data): self.theme_manager.create_connection_indicator_theme(False), ) UIHelpers.safe_configure_item("connect_button", enabled=True) + self._notify_connect_failure() return port = int(port) @@ -177,8 +191,8 @@ def _on_connect_done(self, result): success, message = result UIHelpers.safe_configure_item("connect_button", enabled=True) - print( - f"[DEBUG] Connection attempt result: success={success}, message={message}" + logger.debug( + "Connection attempt result: success=%s, message=%s", success, message ) if success: @@ -200,6 +214,7 @@ def _on_connect_done(self, result): "connection_indicator", self.theme_manager.create_connection_indicator_theme(False), ) + self._notify_connect_failure() def _on_connect_error(self, e: Exception): """Called on main thread when connection raises an unexpected exception.""" @@ -212,6 +227,7 @@ def _on_connect_error(self, e: Exception): "connection_indicator", self.theme_manager.create_connection_indicator_theme(False), ) + self._notify_connect_failure() def test_credentials_callback(self, sender, data): """Test database credentials without establishing a persistent connection (non-blocking).""" @@ -226,8 +242,12 @@ def test_credentials_callback(self, sender, data): password = params["password"] database = params["database"] - print( - f"[DEBUG] Testing credentials: host={host}, port={port}, username={username}, database={database}" + logger.debug( + "Testing credentials: host=%s, port=%s, username=%s, database=%s", + host, + port, + username, + database, ) is_valid, error_msg = validate_connection_params( @@ -236,7 +256,7 @@ def test_credentials_callback(self, sender, data): if not is_valid: raise ValueError(error_msg) - print("[DEBUG] Connection parameters validated successfully") + logger.debug("Connection parameters validated successfully") except Exception as e: error_msg = f"Credential test failed:\n{str(e)}" @@ -285,7 +305,7 @@ def _on_test_done(self, result): success, message = result UIHelpers.safe_configure_item("connect_button", enabled=True) - print(f"[DEBUG] Credential test result: success={success}, message={message}") + logger.debug("Credential test result: success=%s, message=%s", success, message) if success: self._show_modal_status(f"✓ {message}", error=False) @@ -322,18 +342,18 @@ def _on_test_error(self, e: Exception): def auto_load_and_connect(self): """Auto-load credentials without attempting connection on startup.""" try: - print("[DEBUG] Starting auto_load_credentials") + logger.debug("Starting auto_load_credentials") # Try to load the first available credentials success, credentials, message = ( self.credentials_manager.load_credentials_legacy() ) - print( - f"[DEBUG] Load credentials result: success={success}, message={message}" + logger.debug( + "Load credentials result: success=%s, message=%s", success, message ) if credentials: - print(f"[DEBUG] Credentials found: {credentials}") + logger.debug("Saved credentials found") if success and credentials: StatusManager.show_status( @@ -346,23 +366,23 @@ def auto_load_and_connect(self): # Only populate the form if the form elements exist (modal is open) if does_item_exist("host_input"): self.set_form_values(credentials) - print("[DEBUG] Form populated with auto-loaded credentials") + logger.debug("Form populated with auto-loaded credentials") else: - print( - "[DEBUG] Form elements don't exist yet, credentials stored for later use" + logger.debug( + "Form elements do not exist yet; credentials stored for later use" ) # No auto-connecting anymore - print("[DEBUG] Credentials loaded but not auto-connecting") + logger.debug("Credentials loaded but not auto-connecting") else: - print("[DEBUG] No credentials found or load failed") + logger.debug("No credentials found or load failed") StatusManager.show_status( "No saved credentials found. Please enter connection details.", error=False, ) except Exception as e: - print(f"[DEBUG] Auto-load exception: {str(e)}") + logger.debug("Auto-load exception: %s", e, exc_info=True) StatusManager.show_status(f"Auto-load failed: {str(e)}", error=True) def set_form_values(self, credentials: dict): diff --git a/components/query_interface.py b/components/query_interface.py index 13adf2f..f6b2b03 100644 --- a/components/query_interface.py +++ b/components/query_interface.py @@ -2,6 +2,7 @@ import datetime import json +import logging import os import re import time @@ -24,6 +25,8 @@ from icon_manager import icon_manager from utils import get_result_column_types +logger = logging.getLogger(__name__) + MIN_QUERY_INPUT_HEIGHT = 80 MAX_QUERY_INPUT_HEIGHT = 600 RESIZER_HEIGHT = 6 @@ -826,7 +829,7 @@ def _show_loading(self, state: QueryTabState): if self.status_callback: self.status_callback("Executing query...", False) except Exception as e: - print(f"Error showing loading indicator: {e}") + logger.debug("Error showing loading indicator: %s", e, exc_info=True) state.loading_indicator = None state.loading_animation_running = False @@ -839,7 +842,7 @@ def _update_loading_message(self, state: QueryTabState, message: str): ): set_value(f"{state.loading_indicator}_text", f"⏳ {message}") except Exception as e: - print(f"Error updating loading message: {e}") + logger.debug("Error updating loading message: %s", e, exc_info=True) def _hide_loading(self, state: QueryTabState): state.loading_animation_running = False @@ -848,7 +851,7 @@ def _hide_loading(self, state: QueryTabState): if does_item_exist(state.loading_indicator): delete_item(state.loading_indicator) except Exception as e: - print(f"Error hiding loading indicator: {e}") + logger.debug("Error hiding loading indicator: %s", e, exc_info=True) state.loading_indicator = None # ------------------------------------------------------------------ diff --git a/components/table_browser_ui.py b/components/table_browser_ui.py index 4bd23a5..9fe8832 100644 --- a/components/table_browser_ui.py +++ b/components/table_browser_ui.py @@ -1,5 +1,6 @@ """Table browser UI component for ClickHouse Client.""" +import logging import time from collections.abc import Callable @@ -7,8 +8,15 @@ from utils import UIHelpers +logger = logging.getLogger(__name__) + TABLE_CACHE_TTL_SECONDS = 5.0 TABLE_SEARCH_DEBOUNCE_SECONDS = 0.2 +CONNECTION_FAILURE_NOTICE_SECONDS = 3.0 +CONNECTION_FAILURE_NOTICE = ( + "Could not connect with these details. Check the status below, fix the " + "connection, and try again." +) class TableBrowserUI: @@ -45,6 +53,7 @@ def __init__( self._table_cache_created_at = 0.0 self._pending_table_search: str | None = None self._table_search_due_at = 0.0 + self._connection_failure_notice_expires_at = 0.0 # Explicitly tracked active connection name (set when connecting to a saved connection) self.active_connection_name: str = "" @@ -59,6 +68,42 @@ def invalidate_table_cache(self): self._table_cache_key = None self._table_cache_created_at = 0.0 + def handle_connect_failure(self): + """Clear the connecting placeholder and restore saved connections.""" + self._tables_seq += 1 + self._pending_table_search = None + self._connection_failure_notice_expires_at = ( + time.monotonic() + CONNECTION_FAILURE_NOTICE_SECONDS + ) + + if not self.db_manager.is_connected: + self.active_connection_name = "" + self.connections_expanded.discard("current") + self.selected_table = None + self.invalidate_table_cache() + + self.show_saved_connections() + + def clear_connection_failure_notice(self): + """Cancel any temporary connection failure notice.""" + self._connection_failure_notice_expires_at = 0.0 + + def process_pending_connection_notice(self, now: float | None = None) -> bool: + """Remove the temporary failure notice after its display interval.""" + expires_at = self._connection_failure_notice_expires_at + if not expires_at: + return False + + if now is None: + now = time.monotonic() + if now < expires_at: + return False + + self.clear_connection_failure_notice() + if not self.db_manager.is_connected: + self.show_saved_connections() + return True + def _get_table_cache_key(self) -> tuple: """Identify the active database without retaining credentials.""" connection_info = self.db_manager.connection_info @@ -397,6 +442,15 @@ def show_saved_connections(self): # Clear the left panel delete_item("tables_list", children_only=True) + if self._connection_failure_notice_expires_at > time.monotonic(): + add_text( + CONNECTION_FAILURE_NOTICE, + parent="tables_list", + color=(255, 90, 90), + wrap=320, + ) + add_separator(parent="tables_list") + # Get list of all saved credential names credential_names = self.credentials_manager.get_credential_names() @@ -447,6 +501,8 @@ def connect_to_saved_callback(self, sender, app_data, user_data): """Handle clicking on a saved connection to connect to it.""" from components.status_manager import StatusManager + self.clear_connection_failure_notice() + # Get connection name from user_data connection_name = user_data @@ -590,8 +646,7 @@ def _copy_table_name_callback(self, sender, app_data, user_data): f"Table name copied to clipboard: {table_name}", error=False ) except Exception: - # Fallback: print to console if StatusManager isn't available - print(f"Table name copied to clipboard: {table_name}") + logger.debug("Table name copied to clipboard: %s", table_name) except Exception as e: try: @@ -601,8 +656,7 @@ def _copy_table_name_callback(self, sender, app_data, user_data): f"Error copying table name: {str(e)}", error=True ) except Exception: - # Fallback: print to console if StatusManager isn't available - print(f"Error copying table name: {str(e)}") + logger.debug("Error copying table name: %s", e, exc_info=True) def _get_connection_display_name(self) -> str: """Get a display name for the current connection.""" diff --git a/data_explorer.py b/data_explorer.py index 36e1025..8eec4f3 100644 --- a/data_explorer.py +++ b/data_explorer.py @@ -1,5 +1,6 @@ """Data Explorer component for ClickHouse Client.""" +import logging import time from collections.abc import Callable from dataclasses import dataclass @@ -15,6 +16,8 @@ from database import DatabaseManager from utils import get_result_column_types +logger = logging.getLogger(__name__) + class DataExplorer: """Manages the data explorer interface and functionality for a single tab.""" @@ -179,7 +182,7 @@ def load_table(self, table_name: str, status_callback=None) -> bool: self._clear_row_details() except Exception as e: - print(f"[DataExplorer] Error in explorer setup: {e}") + logger.debug("Error in explorer setup: %s", e, exc_info=True) if status_callback: status_callback(f"Error setting up data explorer: {str(e)}", True) return False @@ -315,7 +318,7 @@ def _on_data_ready(self, payload, seq: int, status_callback=None): policy=mvTable_SizingFixedFit, ) except Exception as table_e: - print(f"[DataExplorer] Error creating table: {table_e}") + logger.debug("Error creating table: %s", table_e, exc_info=True) add_text( f"Error creating data table: {str(table_e)}", parent=self.main_table_tag, @@ -515,7 +518,7 @@ def _handle_cell_click(self, sender, app_data, user_data): self._update_row_details(row_data, row_idx) except Exception as e: - print(f"Error handling cell click: {e}") + logger.debug("Error handling cell click: %s", e, exc_info=True) def _update_row_details(self, row_data, row_idx): """Update the row details panel with the selected row's data.""" @@ -607,7 +610,7 @@ def _copy_detail_to_clipboard(self, sender, app_data, user_data): f"Copied to clipboard: {detail_text[:50]}{'...' if len(detail_text) > 50 else ''}" ) except Exception as e: - print(f"Error copying detail to clipboard: {e}") + logger.debug("Error copying detail to clipboard: %s", e, exc_info=True) # --------------------------------------------------------------------------- @@ -730,7 +733,7 @@ def open_tab(self, table_name: str, status_callback=None): explorer.load_table(table_name, cb) except Exception as e: - print(f"[TabbedExplorerInterface] Error opening tab: {e}") + logger.debug("Error opening explorer tab: %s", e, exc_info=True) if cb: cb(f"Error opening explorer for '{table_name}': {e}", True) # Clean up partial state @@ -773,7 +776,7 @@ def _show_query_view(self): configure_item("explorer_section", show=False) configure_item("query_section", show=True) except Exception as e: - print(f"[TabbedExplorerInterface] Error restoring query view: {e}") + logger.debug("Error restoring query view: %s", e, exc_info=True) def _toggle_row_details(self, explorer: DataExplorer): """Toggle the row details panel for the given explorer tab.""" @@ -786,4 +789,4 @@ def _toggle_row_details(self, explorer: DataExplorer): else: configure_item(explorer.main_table_tag, width=-410) except Exception as e: - print(f"[TabbedExplorerInterface] Error toggling row details: {e}") + logger.debug("Error toggling row details: %s", e, exc_info=True) diff --git a/main.py b/main.py index 95633ab..2b57835 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ - app.py: Main application orchestration """ +import logging import sys from app import ClickHouseClientApp @@ -20,6 +21,12 @@ RELEASE_CONNECTION_TEST_FLAG = "--release-connection-test" +def configure_runtime_logging(): + """Keep development diagnostics silent in frozen production builds.""" + if getattr(sys, "frozen", False): + logging.disable(logging.DEBUG) + + def main(): """Main entry point for the ClickHouse Client application.""" app = ClickHouseClientApp() @@ -28,6 +35,7 @@ def main(): def cli(args: list[str] | None = None) -> int: """Dispatch command-line smoke checks or start the GUI application.""" + configure_runtime_logging() args = sys.argv[1:] if args is None else args if args == [RELEASE_CONNECTION_TEST_FLAG]: return run_release_connection_test() diff --git a/tests/test_main.py b/tests/test_main.py index b1b29a8..6eaab76 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -18,6 +18,13 @@ class TestMain(unittest.TestCase): """Test cases for the main module.""" + @patch("main.logging.disable") + def test_frozen_runtime_disables_debug_logging(self, disable_logging): + with patch.object(main.sys, "frozen", True, create=True): + main.configure_runtime_logging() + + disable_logging.assert_called_once_with(main.logging.DEBUG) + @patch("main.ClickHouseClientApp") def test_main_function_creates_app_and_runs(self, mock_app_class): """Test that main() creates ClickHouseClientApp instance and calls run().""" diff --git a/tests/test_table_browser_ui.py b/tests/test_table_browser_ui.py index d89dd5b..087224d 100644 --- a/tests/test_table_browser_ui.py +++ b/tests/test_table_browser_ui.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch from components.table_browser_ui import ( + CONNECTION_FAILURE_NOTICE, + CONNECTION_FAILURE_NOTICE_SECONDS, TABLE_CACHE_TTL_SECONDS, TABLE_SEARCH_DEBOUNCE_SECONDS, TableBrowserUI, @@ -123,3 +125,85 @@ def test_expired_cache_refreshes_tables(_get_scroll, _delete_item, _add_text): browser.filter_tables_callback(None, "events") assert db_manager.get_tables.call_count == 2 + + +def test_connection_failure_immediately_restores_saved_connections(): + browser, db_manager = _make_browser() + db_manager.is_connected = False + db_manager.connection_info = {} + browser.selected_table = "events" + browser._pending_table_search = "eve" + browser._table_cache = ("events",) + + with ( + patch.object(browser, "show_saved_connections") as show_saved_connections, + patch("components.table_browser_ui.time.monotonic", return_value=100.0), + ): + browser.handle_connect_failure() + + assert browser.active_connection_name == "" + assert "current" not in browser.connections_expanded + assert browser.selected_table is None + assert browser._pending_table_search is None + assert browser._table_cache is None + assert ( + browser._connection_failure_notice_expires_at + == 100.0 + CONNECTION_FAILURE_NOTICE_SECONDS + ) + show_saved_connections.assert_called_once_with() + + +@patch("components.table_browser_ui.add_separator") +@patch("components.table_browser_ui.add_button") +@patch("components.table_browser_ui.add_text") +@patch("components.table_browser_ui.delete_item") +def test_saved_connections_include_temporary_failure_notice( + _delete_item, add_text, add_button, add_separator +): + browser, db_manager = _make_browser() + db_manager.is_connected = False + db_manager.connection_info = {} + browser.credentials_manager.get_credential_names.return_value = ["Production"] + browser._connection_failure_notice_expires_at = 103.0 + + with patch("components.table_browser_ui.time.monotonic", return_value=101.0): + browser.show_saved_connections() + + add_text.assert_called_once_with( + CONNECTION_FAILURE_NOTICE, + parent="tables_list", + color=(255, 90, 90), + wrap=320, + ) + add_separator.assert_called_once_with(parent="tables_list") + add_button.assert_called_once() + + +def test_connection_failure_notice_expires_on_the_main_loop(): + browser, db_manager = _make_browser() + db_manager.is_connected = False + browser._connection_failure_notice_expires_at = 103.0 + + with patch.object(browser, "show_saved_connections") as show_saved_connections: + assert not browser.process_pending_connection_notice(now=102.9) + assert browser.process_pending_connection_notice(now=103.0) + + assert browser._connection_failure_notice_expires_at == 0.0 + show_saved_connections.assert_called_once_with() + + +def test_failed_connection_result_notifies_the_table_browser(): + from components.connection_manager import ConnectionManager + + browser, _db_manager = _make_browser() + manager = ConnectionManager(MagicMock(), MagicMock()) + + with ( + patch.object(browser, "handle_connect_failure") as handle_connect_failure, + patch("components.connection_manager.StatusManager.show_status"), + patch("components.connection_manager.UIHelpers.safe_configure_item"), + ): + manager.on_connect_failure = handle_connect_failure + manager._on_connect_done((False, "invalid credentials")) + + handle_connect_failure.assert_called_once_with() diff --git a/utils.py b/utils.py index b3f2fc1..e1edd8e 100644 --- a/utils.py +++ b/utils.py @@ -1,9 +1,12 @@ """Utility functions for ClickHouse Client.""" +import logging import os from dearpygui.dearpygui import * +logger = logging.getLogger(__name__) + class FontManager: """Manages font loading and configuration.""" @@ -60,8 +63,9 @@ def setup_monospace_font(): # Try to load the bundled font first if os.path.exists(bundled_font_path): try: - print( - f"Loading bundled JetBrains Mono font from: {bundled_font_path}" + logger.debug( + "Loading bundled JetBrains Mono font from: %s", + bundled_font_path, ) monospace_font = FontManager._load_font_with_icons( bundled_font_path, 16 @@ -69,12 +73,12 @@ def setup_monospace_font(): bind_font(monospace_font) return True except Exception as e: - print(f"Failed to load bundled font: {e}") + logger.debug("Failed to load bundled font: %s", e, exc_info=True) else: - print(f"Bundled font not found at: {bundled_font_path}") + logger.debug("Bundled font not found at: %s", bundled_font_path) # Fallback to system fonts if bundled font fails - print("Bundled font not available, trying system fallback fonts...") + logger.debug("Bundled font not available; trying system fallback fonts") system_fallback_fonts = [ ("~/Library/Fonts/JetBrainsMono-Regular.ttf", "JetBrains Mono (User)"), ("/System/Library/Fonts/Monaco.ttf", "Monaco"), @@ -88,8 +92,10 @@ def setup_monospace_font(): font_path = os.path.expanduser(font_path) if os.path.exists(font_path): - print( - f"Loading system fallback font {font_name} from: {font_path}" + logger.debug( + "Loading system fallback font %s from: %s", + font_name, + font_path, ) monospace_font = FontManager._load_font_with_icons( font_path, 14 @@ -97,11 +103,13 @@ def setup_monospace_font(): bind_font(monospace_font) return True except Exception as e: - print(f"Failed to load {font_name} font: {e}") + logger.debug( + "Failed to load %s font: %s", font_name, e, exc_info=True + ) continue # Use default font if all custom fonts fail - print("Using default system font") + logger.debug("Using default system font") return False @@ -127,7 +135,7 @@ def safe_configure_item(tag: str, **kwargs): else: return False except Exception as e: - print(f"[DEBUG] Failed to configure item {tag}: {e}") + logger.debug("Failed to configure item %s: %s", tag, e, exc_info=True) return False @staticmethod @@ -141,7 +149,7 @@ def safe_bind_item_theme(tag: str, theme): else: return False except Exception as e: - print(f"[DEBUG] Failed to bind theme to {tag}: {e}") + logger.debug("Failed to bind theme to %s: %s", tag, e, exc_info=True) return False