diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a2ec38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Build artifacts +*.egg-info/ +dist/ +build/ +*.pyc +__pycache__/ + +# IDE +.vscode/ +.idea/ + +# Virtual environments +venv/ +env/ +.env + +# Test artifacts +.pytest_cache/ +.coverage +htmlcov/ +*.png + +# OS files +.DS_Store +Thumbs.db diff --git a/LOGGING.md b/LOGGING.md new file mode 100644 index 0000000..3dbde92 --- /dev/null +++ b/LOGGING.md @@ -0,0 +1,135 @@ +# Logging in SpyDE + +## Overview + +SpyDE uses Python's built-in `logging` module for all application output. All `print()` statements have been replaced with appropriate logging calls to provide better control and visibility into application behavior. + +## Configuration + +Logging is configured centrally in `spyde/logging_config.py` and initialized when the application starts in `main_window.py:main()`. + +### Setting Log Level + +There are two ways to set the logging level: + +#### 1. Via the GUI (Recommended) + +Use the menu: **View → Set Log Level** and select from: +- **DEBUG**: Show all diagnostic information +- **INFO**: Show general informational messages (default) +- **WARNING**: Show only warnings and errors +- **ERROR**: Show only errors +- **CRITICAL**: Show only critical errors + +The log level can be changed at any time while the application is running, and changes take effect immediately. + +#### 2. Via Environment Variable + +- **LOG_LEVEL**: Set the logging level at startup (DEBUG, INFO, WARNING, ERROR, CRITICAL) + - Default: `INFO` in production + - For debugging, set `LOG_LEVEL=DEBUG` + +Example: +```bash +export LOG_LEVEL=DEBUG +spyde +``` + +## Usage in Code + +### Creating a Logger + +Each module should create its own logger at the module level: + +```python +import logging + +logger = logging.getLogger(__name__) +``` + +### Logging Messages + +Use appropriate logging levels: + +```python +# Debug - Detailed diagnostic information +logger.debug("Processing data with shape: %s", data.shape) + +# Info - General informational messages +logger.info("Signal loaded: %s", signal) + +# Warning - Something unexpected but not critical +logger.warning("Could not find tool button for action %s", action_name) + +# Error - An error occurred but the application can continue +logger.error("Plot update failed: %s", result) + +# Critical - A serious error that may prevent the application from continuing +logger.critical("Unable to initialize Dask client") +``` + +### Exception Logging + +For exceptions, use `logger.exception()` inside an except block: + +```python +try: + process_data() +except Exception as e: + logger.exception("Failed to process data: %s", e) + raise +``` + +This automatically includes the traceback in the log output. + +## Best Practices + +1. **Use string formatting with %**: Instead of f-strings, use `%s` placeholders + ```python + # Good + logger.info("Loading file: %s", filename) + + # Avoid + logger.info(f"Loading file: {filename}") + ``` + This is more efficient because string formatting only happens if the log level is enabled. + +2. **Choose appropriate log levels**: + - `DEBUG`: Detailed information for diagnosing problems + - `INFO`: Confirmation that things are working as expected + - `WARNING`: Something unexpected happened, but the software is still working + - `ERROR`: A serious problem occurred + - `CRITICAL`: The program may be unable to continue + +3. **No print statements**: Use logging instead. Print statements should only be used in: + - Example scripts that demonstrate usage + - Test output that should always be visible + +4. **Third-party library logging**: Overly verbose third-party loggers are suppressed in `logging_config.py` to reduce noise. + +## Testing Logging + +To verify logging is working in a module: + +```python +import os +os.environ['LOG_LEVEL'] = 'DEBUG' + +from spyde.logging_config import setup_logging +setup_logging() + +# Your code with logging calls +``` + +## Output Format + +The default log format is: +``` +%(asctime)s - %(name)s - %(levelname)s - %(message)s +``` + +Example output: +``` +2025-11-04 15:04:16 - spyde.main_window - INFO - Starting Dask LocalCluster with 4 workers +2025-11-04 15:04:16 - spyde.signal_tree - DEBUG - Created Signal Tree with root signal: +``` diff --git a/spyde/__init__.py b/spyde/__init__.py index e73564f..28d6431 100644 --- a/spyde/__init__.py +++ b/spyde/__init__.py @@ -1,5 +1,8 @@ from importlib.resources import files import yaml +import logging + +logger = logging.getLogger(__name__) # Load the configuration .yaml files at package initialization @@ -10,7 +13,7 @@ "r", encoding="utf-8" ) as f: METADATA_WIDGET_CONFIG = yaml.safe_load(f) -print(METADATA_WIDGET_CONFIG) +logger.debug("Loaded metadata widget config: %s", METADATA_WIDGET_CONFIG) __all__ = ["TOOLBAR_ACTIONS", "METADATA_WIDGET_CONFIG"] diff --git a/spyde/actions/base.py b/spyde/actions/base.py index 7c99f5b..b43c32b 100644 --- a/spyde/actions/base.py +++ b/spyde/actions/base.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING +import logging if TYPE_CHECKING: from spyde.drawing.toolbars.rounded_toolbar import RoundedToolBar @@ -7,6 +8,8 @@ from PySide6 import QtWidgets +logger = logging.getLogger(__name__) + ZOOM_STEP = 0.8 @@ -138,7 +141,7 @@ def toggle_navigation_plots( # current_buttons[add_button_name] = add_button if toolbar.action_widgets.get(action_name, None) is None: - print(f"Adding toolbar action widget: {action_name}") + logger.debug("Adding toolbar action widget: %s", action_name) toolbar.add_action_widget(action_name, group, layout) if toggle is not None: @@ -233,7 +236,7 @@ def tree2buttons(tree): layout = toolbar.action_widgets[action_name]["layout"] if toolbar.action_widgets.get(action_name, None) is None: - print(f"Adding toolbar action widget: {action_name}") + logger.debug("Adding toolbar action widget: %s", action_name) toolbar.add_action_widget(action_name, group, layout) if toggle is not None: diff --git a/spyde/drawing/multiplot.py b/spyde/drawing/multiplot.py index 8d9e056..f55545c 100644 --- a/spyde/drawing/multiplot.py +++ b/spyde/drawing/multiplot.py @@ -1,5 +1,6 @@ from PySide6 import QtCore, QtWidgets, QtGui +import logging import pyqtgraph as pg from spyde.external.pyqtgraph.scale_bar import OutlinedScaleBar as ScaleBar from math import floor, log10 @@ -10,6 +11,8 @@ from typing import TYPE_CHECKING, Union +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from spyde.signal_tree import BaseSignalTree from spyde.main_window import MainWindow @@ -118,7 +121,7 @@ def __init__( self.parent_selector = None # type: BaseSelector | None # Register with the main window - print("Registering plot with main window") + logger.debug("Registering plot with main window") self.main_window.add_plot(self) # Creating all the floating toolbars... @@ -318,7 +321,7 @@ def update_toolbars(self): functions, icons, names, toolbar_sides, toggles, params = ( get_toolbar_actions_for_plot(self) ) - print(params) + logger.debug("Toolbar params: %s", params) # Clear existing toolbars for tb in [ self.toolbar_right, @@ -332,8 +335,8 @@ def update_toolbars(self): for func, icon, name, side, toggle, param in zip( functions, icons, names, toolbar_sides, toggles, params ): - print(f"Adding toolbar action: {name} to {side} toolbar") - print(f"Function: {func}, Icon: {icon}, Toggle: {toggle}, Params: {param}") + logger.debug("Adding toolbar action: %s to %s toolbar", name, side) + logger.debug("Function: %s, Icon: %s, Toggle: %s, Params: %s", func, icon, toggle, param) if side == "right": self.toolbar_right.add_action(name, icon, func, toggle, param) elif side == "left": @@ -388,7 +391,7 @@ def update_image_rectangle(self): getattr(self.nav_plot_manager, "navigation_selectors", []) ) - print("Updating selectors for new image transform:", selectors) + logger.debug("Updating selectors for new image transform: %s", selectors) for sel in selectors: sel.apply_transform_to_selector(transform) self.enable_scale_bar(True) @@ -436,7 +439,7 @@ def update_data( If the new_data is a Future, the update will be deferred until the Future is complete, the update will be handled by the event loop instead. """ - print("Updating plot data", new_data) + logger.debug("Updating plot data: %s", new_data) self.current_data = new_data if isinstance(new_data, Future) and not force: pass @@ -445,7 +448,7 @@ def update_data( self.update() else: self.update() - print("Plot data updated.") + logger.debug("Plot data updated.") def add_fft_selector(self): """Add an FFT selector to the plot.""" @@ -484,8 +487,8 @@ def show_selector_control_widget(self): ) if self.nav_plot_manager is not None: visible_selectors += self.nav_plot_manager.navigation_selectors - print(self.nav_plot_manager) - print("Showing selector control widgets for plot:", visible_selectors) + logger.debug("Nav plot manager: %s", self.nav_plot_manager) + logger.debug("Showing selector control widgets for plot: %s", visible_selectors) # Hide selectors from other plots. Faster than deleting and recreating them (also renders nicer). for selector in self.main_window.navigation_selectors: if selector not in visible_selectors: @@ -578,10 +581,9 @@ def _update_main_cursor(self, x, y, pixel_x, pixel_y, value): def update(self): """Push the current data to the plot items.""" - print( - "Plot update called with data:", + logger.debug( + "Plot update called with data: %s with type: %s", self.current_data, - " with type:", type(self.current_data), ) if self.plot_state.dimensions == 1: @@ -591,8 +593,8 @@ def update(self): else self.current_data ) axis = self.plot_state.current_signal.axes_manager.signal_axes[0].axis - print("Updating 1D plot with axis:", axis) - print("Data shape:", current_data) + logger.debug("Updating 1D plot with axis: %s", axis) + logger.debug("Data shape: %s", current_data) self.line_item.setData(axis, current_data) elif self.plot_state.dimensions == 2: img = ( @@ -631,10 +633,10 @@ def closeEvent(self, event): pass tb.close() setattr(self, attr, None) - print("Closing plot:", self) - print("Closing parent selector if exists") + logger.debug("Closing plot: %s", self) + logger.debug("Closing parent selector if exists") if self.parent_selector is not None: - print("Closing parent selector") + logger.debug("Closing parent selector") self.parent_selector.close() # need to delete the current selectors and child plots @@ -649,7 +651,7 @@ def closeEvent(self, event): # if part of a nav plot manager close everything and clean up the signal if self.nav_plot_manager is not None: - print("Closing nav plot manager plots") + logger.debug("Closing nav plot manager plots") for plot in self.nav_plot_manager.plots: try: plot.close() @@ -699,7 +701,7 @@ def __init__(self, main_window: "MainWindow", signal_tree: "BaseSignalTree"): ) # type: dict[BaseSignal:NavigationManagerState] self.navigation_manager_state = None # type: NavigationManagerState | None - print(f"NavigationPlotManager: dim:{self.nav_dim}") + logger.debug("NavigationPlotManager: dim: %d", self.nav_dim) if self.nav_dim < 1: raise ValueError( "NavigationPlotManager requires at least 1 navigation dimension." @@ -715,8 +717,8 @@ def __init__(self, main_window: "MainWindow", signal_tree: "BaseSignalTree"): for signal in self.signal_tree.navigator_signals.values(): self.add_state(signal) - print("Setting initial navigation manager state") - print(list(self.signal_tree.navigator_signals.values())[0]) + logger.debug("Setting initial navigation manager state") + logger.debug("Navigator signal: %s", list(self.signal_tree.navigator_signals.values())[0]) self.set_navigation_manager_state( list(self.signal_tree.navigator_signals.values())[0] ) @@ -736,7 +738,7 @@ def add_state(self, signal: BaseSignal): ) dim = self.navigation_manager_states[BaseSignal].dimensions - print("Adding navigation state for signal:", signal, " with dimensions:", dim) + logger.debug("Adding navigation state for signal: %s with dimensions: %s", signal, dim) dim = [d for d in dim if d > 0] for plot, d in zip(self.plots, dim): plot.plot_states[signal] = PlotState( @@ -758,8 +760,8 @@ def set_navigation_manager_state(self, signal: Union[BaseSignal, str]): signal : BaseSignal | str The signal for which to set the navigation state. """ - print(self.navigation_manager_states) - print("Setting navigation manager state for signal:", signal) + logger.debug("Navigation manager states: %s", self.navigation_manager_states) + logger.debug("Setting navigation manager state for signal: %s", signal) if isinstance(signal, str): signal = self.navigation_signals[signal] self.navigation_manager_state = self.navigation_manager_states.get( @@ -807,7 +809,7 @@ def add_navigation_selector_and_signal_plot(self, selector_type=None): # create plot states for the child plot child.plot_states = self.signal_tree.create_plot_states() - print("Added Child plot states: ", child.plot_states) + logger.debug("Added Child plot states: %s", child.plot_states) selector = selector_type( parent=self.plots[0], children=child, @@ -818,9 +820,9 @@ def add_navigation_selector_and_signal_plot(self, selector_type=None): # Auto range... selector.update_data() child.update_data(child.current_data, force=True) - print("Auto-ranging child plot") + logger.debug("Auto-ranging child plot") child.plot_item.getViewBox().autoRange() - print() + self.signal_tree.signal_plots.append(child) child.needs_auto_level = True - print("Added navigation selector and signal plot:", selector, child) + logger.debug("Added navigation selector and signal plot: %s, %s", selector, child) diff --git a/spyde/drawing/selector.py b/spyde/drawing/selector.py index 9fb5065..efdbfae 100644 --- a/spyde/drawing/selector.py +++ b/spyde/drawing/selector.py @@ -1,5 +1,6 @@ from pyqtgraph import LinearRegionItem, RectROI, CircleROI, LineROI +import logging from PySide6 import QtCore, QtWidgets, QtGui import numpy as np @@ -7,6 +8,8 @@ from typing import TYPE_CHECKING, Union, List +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from spyde.drawing.multiplot import Plot, NavigationPlotManager @@ -111,18 +114,18 @@ def update_data(self, ev=None): if ev is None: self.delayed_update_data() else: - print("Restarting Timer") + logger.debug("Restarting Timer") self.update_timer.start() def delayed_update_data(self, force: bool = False, update_contrast: bool = False): """ Perform the actual update if the indices have not changed. """ - print("updating the data:", self.children) + logger.debug("updating the data: %s", self.children) indices = self.get_selected_indices() if not np.array_equal(indices, self.current_indices) or force: for child in self.children: - print("Updating Child Plot:", child) + logger.debug("Updating Child Plot: %s", child) new_data = self.children[child](self, child, indices) child.update_data( new_data @@ -194,8 +197,8 @@ def __init__( super().__init__( parent, children, update_function, live_delay=live_delay, *args, **kwargs ) - print("Creating Rectangle Selector") - print(args, kwargs) + logger.debug("Creating Rectangle Selector") + logger.debug("Args: %s, Kwargs: %s", args, kwargs) self.selector = RectROI( pos=(0, 0), size=(10, 10), @@ -264,8 +267,8 @@ def __init__(self, *args, **kwargs): self.size_limits = (1, 15, 1, 15) def on_integrate_toggled(self, checked): - print("Integrate Toggled") - print(self.is_live) + logger.debug("Integrate Toggled") + logger.debug("Is live: %s", self.is_live) if self.is_live: self.is_integrating = checked self.delayed_update_data(force=True, update_contrast=True) @@ -273,7 +276,7 @@ def on_integrate_toggled(self, checked): def on_integrate_pressed(self): if not self.is_live: # fire off the integration - print("Computing!") + logger.debug("Computing!") self.delayed_update_data(force=True, update_contrast=True) def on_live_toggled(self, checked): @@ -302,7 +305,7 @@ def update_data(self, ev=None): if ev is None: self.delayed_update_data() else: - print("Restarting Timer") + logger.debug("Restarting Timer") self.update_timer.start() diff --git a/spyde/drawing/toolbars/caret_group.py b/spyde/drawing/toolbars/caret_group.py index d6b376a..a9474d2 100644 --- a/spyde/drawing/toolbars/caret_group.py +++ b/spyde/drawing/toolbars/caret_group.py @@ -1,10 +1,11 @@ -from PySide6 import QtCore, QtGui, QtWidgets - # python +import logging from PySide6 import QtCore, QtGui, QtWidgets from spyde.drawing.toolbars.floating_button_trees import RoundedButton from spyde.drawing.toolbars.rounded_toolbar import RoundedToolBar +logger = logging.getLogger(__name__) + class CaretGroup(QtWidgets.QGroupBox): """ @@ -42,8 +43,8 @@ def _opposite(pos: str) -> str: }.get(pos, "bottom") if toolbar is not None and (side is None or side == "auto"): - print( - "Setting CaretGroup side opposite to toolbar position:", + logger.debug( + "Setting CaretGroup side opposite to toolbar position: %s", toolbar.position, ) side = _opposite(getattr(toolbar, "position", "right")) @@ -293,7 +294,7 @@ def __init__( ) self.kwargs = {} - print("Creating CaretParams with parameters:", parameters) + logger.debug("Creating CaretParams with parameters: %s", parameters) for key, item in parameters.items(): dtype = item.get("type", "str") name = item.get("name", key) diff --git a/spyde/drawing/toolbars/rounded_toolbar.py b/spyde/drawing/toolbars/rounded_toolbar.py index 8783220..29d26a4 100644 --- a/spyde/drawing/toolbars/rounded_toolbar.py +++ b/spyde/drawing/toolbars/rounded_toolbar.py @@ -1,10 +1,13 @@ # python +import logging from typing import TYPE_CHECKING, Callable, Optional from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import Qt from PySide6.QtGui import QIcon +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from spyde.drawing.multiplot import Plot @@ -113,8 +116,8 @@ def add_action( if parameters is None: parameters = dict() action = self.addAction(QIcon(icon_path), name) - print(f"Adding action '{name}' to toolbar.") - print(" Toggle:", toggle) + logger.debug("Adding action '%s' to toolbar.", name) + logger.debug(" Toggle: %s", toggle) if parameters != {}: # create a popout menu for the action with a submit button @@ -247,7 +250,7 @@ def position_widget(): w_w, w_h = hint.width(), hint.height() if btn is None: - print("Warning: Could not find tool button for action", action_name) + logger.warning("Warning: Could not find tool button for action %s", action_name) if self.position == "left": x = tb_global_tl.x() - w_w - self._margin @@ -297,14 +300,14 @@ def position_widget(): # Auto-bind to an action with the same name action = self._find_action(action_name) - print(f"Auto-binding action widget '{action_name}' to toolbar action.") - print( - f" Found action: {action}, isCheckable={action.isCheckable() if action else 'N/A'}" + logger.debug("Auto-binding action widget '%s' to toolbar action.", action_name) + logger.debug( + " Found action: %s, isCheckable=%s", action, action.isCheckable() if action else 'N/A' ) if action is not None: if action.isCheckable(): - print(f"Binding action widget '{action_name}' to toggle action.") - print("Positioning widget on toggle.") + logger.debug("Binding action widget '%s' to toggle action.", action_name) + logger.debug("Positioning widget on toggle.") action.toggled.connect( lambda checked: (widget.setVisible(checked), position_widget()) ) diff --git a/spyde/external/pyqtgraph/scale_bar.py b/spyde/external/pyqtgraph/scale_bar.py index 6617898..5ddb6ea 100644 --- a/spyde/external/pyqtgraph/scale_bar.py +++ b/spyde/external/pyqtgraph/scale_bar.py @@ -10,10 +10,12 @@ ) # to avoid linting error from pyqtgraph.graphicsItems.TextItem import TextItem # to avoid linting error import re - +import logging import html +logger = logging.getLogger(__name__) + def tex_to_html(s: str) -> str: """ @@ -101,8 +103,8 @@ def __init__( new_text = f"{size} {suffix}" - print(new_text) - print(tex_to_html(new_text)) + logger.debug("Scale bar text: %s", new_text) + logger.debug("HTML rendered text: %s", tex_to_html(new_text)) self.text.setHtml(tex_to_html(new_text)) self.text.setParentItem(self) diff --git a/spyde/logging_config.py b/spyde/logging_config.py new file mode 100644 index 0000000..20a89eb --- /dev/null +++ b/spyde/logging_config.py @@ -0,0 +1,72 @@ +""" +Centralized logging configuration for the spyde application. + +This module provides a standardized logging setup that works in both +development and production environments. +""" +import logging +import os +import sys + + +def setup_logging(): + """ + Configure logging for the application. + + Uses environment variables: + - LOG_LEVEL: Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + Default: INFO in production, DEBUG in development + """ + # Get log level from environment, default to INFO + log_level_name = os.getenv("LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, log_level_name, logging.INFO) + + # Create formatter + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + + # Remove any existing handlers to avoid duplicates + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # Add console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + # Suppress overly verbose third-party loggers + logging.getLogger("matplotlib").setLevel(logging.WARNING) + logging.getLogger("PIL").setLevel(logging.WARNING) + logging.getLogger("distributed").setLevel(logging.WARNING) + logging.getLogger("bokeh").setLevel(logging.WARNING) + + return root_logger + + +def set_log_level(level_name): + """ + Dynamically change the logging level. + + Parameters + ---------- + level_name : str + One of: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' + """ + log_level = getattr(logging, level_name.upper(), logging.INFO) + + # Update root logger level + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + + # Update all handlers + for handler in root_logger.handlers: + handler.setLevel(log_level) + + root_logger.info("Log level changed to %s", level_name.upper()) diff --git a/spyde/main_window.py b/spyde/main_window.py index 4e4b209..105a8c1 100644 --- a/spyde/main_window.py +++ b/spyde/main_window.py @@ -1,10 +1,11 @@ import sys import os +import logging from typing import Union from functools import partial import webbrowser -from PySide6.QtGui import QAction, QIcon, QBrush, QPalette +from PySide6.QtGui import QAction, QIcon, QBrush, QPalette, QActionGroup from PySide6.QtCore import Qt, QTimer, QEvent from PySide6.QtWidgets import ( QSplashScreen, @@ -31,6 +32,9 @@ HistogramLUTItem, ) from spyde.workers.plot_update_worker import PlotUpdateWorker +from spyde.logging_config import setup_logging, set_log_level + +logger = logging.getLogger(__name__) COLORMAPS = { "gray": pg.colormap.get("CET-L1"), @@ -60,7 +64,7 @@ def __init__(self, app=None): self.axes_layout = None # type: Union[QtWidgets.QVBoxLayout, None] cpu_count = os.cpu_count() - print("CPU Count:", cpu_count) + logger.info("CPU Count: %s", cpu_count) if cpu_count is None or cpu_count < 4: workers = 1 # Don't overdo it on small systems threads_per_worker = 1 @@ -72,13 +76,13 @@ def __init__(self, app=None): else: workers = (cpu_count // 4) - 1 # For very large systems, limit workers threads_per_worker = 4 - print(f"Starting Dask LocalCluster with {workers} workers, and {threads_per_worker} threads per worker") + logger.info("Starting Dask LocalCluster with %d workers, and %d threads per worker", workers, threads_per_worker) cluster = LocalCluster(n_workers=workers, threads_per_worker=threads_per_worker) self.client = Client( cluster ) # Start a local Dask client (this should be settable eventually) - print(f"Starting Dashboard at: {self.client.dashboard_link}") + logger.info("Starting Dashboard at: %s", self.client.dashboard_link) self.setWindowTitle("DE-Spy") # get screen size and set window size to 3/4 of the screen size self.dock_widget = None @@ -212,7 +216,7 @@ def update_plots_loop(self): """ for p in self.plot_subwindows: if isinstance(p.current_data, Future) and p.current_data.done(): - print("Updating Plot in loop...") + logger.debug("Updating Plot in loop...") p.current_data = p.current_data.result() p.update() @@ -226,14 +230,14 @@ def on_plot_future_ready(self, plot: Plot, result: object) -> None: result: Either the computed data or an Exception. """ if isinstance(result, Exception): - print(f"Plot update failed: {result}") + logger.error("Plot update failed: %s", result) return try: - print("Updating Plot from worker signal...") + logger.debug("Updating Plot from worker signal...") plot.current_data = result plot.update() except Exception as e: - print(f"Failed to update plot: {e}") + logger.error("Failed to update plot: %s", e) def create_menu(self): """ @@ -279,6 +283,37 @@ def create_menu(self): view_plot_control_action.triggered.connect(self.toggle_plot_control_dock) view_menu.addAction(view_plot_control_action) + # Add log level submenu + log_level_menu = view_menu.addMenu("Set Log Level") + log_level_group = QActionGroup(self) + log_level_group.setExclusive(True) + + # Get current log level + current_level = logging.getLogger().level + + # Create actions for each log level + log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + for level_name in log_levels: + level_value = getattr(logging, level_name) + action = QAction(level_name, self) + action.setCheckable(True) + action.setChecked(level_value == current_level) + action.triggered.connect(partial(self.set_logging_level, level_name)) + log_level_group.addAction(action) + log_level_menu.addAction(action) + + def set_logging_level(self, level_name): + """ + Set the logging level for the application. + + Parameters + ---------- + level_name : str + The logging level name (DEBUG, INFO, WARNING, ERROR, CRITICAL) + """ + set_log_level(level_name) + logger.info("Logging level changed to %s", level_name) + def toggle_plot_control_dock(self): """ Toggle the visibility of the plot control dock widget. @@ -307,11 +342,11 @@ def open_dask_dashboard(self): def create_data(self): dialog = CreateDataDialog(self) - print("Creating Data") + logger.debug("Creating Data") if dialog.exec() == QDialog.DialogCode.Accepted: - print("Dialog accepted") + logger.debug("Dialog accepted") data, navigators = dialog.get_data() - print("Data created") + logger.debug("Data created") if data is not None: self.add_signal(data, navigators=navigators) @@ -320,7 +355,7 @@ def _create_signals(self, file_paths): kwargs = {"lazy": True} if file_path.endswith(".mrc"): dialog = DatasetSizeDialog(self, filename=file_path) - print("Opening Dataset Size Dialog for .mrc file") + logger.debug("Opening Dataset Size Dialog for .mrc file") if dialog.exec() == QDialog.DialogCode.Accepted: x_size = dialog.x_input.value() y_size = dialog.y_input.value() @@ -328,9 +363,9 @@ def _create_signals(self, file_paths): kwargs["navigation_shape"] = tuple( [val for val in (x_size, y_size, time_size) if val > 1] ) - print(f"{kwargs['navigation_shape']}") + logger.debug("Navigation shape: %s", kwargs['navigation_shape']) else: - print("Dialog cancelled") + logger.debug("Dialog cancelled") return # .mrc always have 2 signal axes. Maybe needs changed for eels. if len(kwargs["navigation_shape"]) == 3: @@ -343,18 +378,18 @@ def _create_signals(self, file_paths): -1, ) - print(f"chunks: {kwargs['chunks']}") + logger.debug("chunks: %s", kwargs['chunks']) if hasattr(kwargs, "navigation_shape") and kwargs["navigation_shape"] == (): kwargs.pop("navigation_shape") kwargs.pop("chunks") - print("Loading signal from file:", file_path, "with kwargs:", kwargs) + logger.info("Loading signal from file: %s with kwargs: %s", file_path, kwargs) signal = hs.load(file_path, **kwargs) if kwargs.get("lazy", False): if signal.axes_manager.navigation_dimension == 1: signal.cache_pad = 5 elif signal.axes_manager.navigation_dimension == 2: signal.cache_pad = 2 - print("Signal loaded:", signal) + logger.info("Signal loaded: %s", signal) self.add_signal(signal) def open_file(self): @@ -382,7 +417,7 @@ def add_signal(self, signal, navigators=None): root_signal=signal, main_window=self, distributed_client=self.client ) self.signal_trees.append(signal_tree) - print("Signal Tree Created") + logger.info("Signal Tree Created") if navigators is not None: for i, nav in enumerate(navigators): title = nav.metadata.get_item( @@ -390,12 +425,12 @@ def add_signal(self, signal, navigators=None): ) if title == "": title = "navigation_" + str(i) - print("Adding navigator signal:", title) + logger.info("Adding navigator signal: %s", title) signal_tree.add_navigator_signal(title, nav) if signal.metadata.get_item("General.virtual_images", False): for key, item in signal.metadata.General.virtual_images: - print("Adding virtual image navigator signal:", key) + logger.info("Adding virtual image navigator signal: %s", key) signal_tree.add_navigator_signal(key, item) def load_example_data(self, name): @@ -404,7 +439,7 @@ def load_example_data(self, name): """ signal = getattr(pyxem.data, name)(allow_download=True, lazy=True) self.add_signal(signal) - print("Example data loaded:", name) + logger.info("Example data loaded: %s", name) def add_plot(self, plot: Plot): """Add a plot to the MDI area. @@ -516,7 +551,7 @@ def update_axes_widget(self, window: "Plot"): # Add new axes information if hasattr(window, "signal_tree"): plot_state = window.plot_state - print("Updating axes widget, plot state:", plot_state) + logger.debug("Updating axes widget, plot state: %s", plot_state) if plot_state is None: current_signal = None else: @@ -544,15 +579,15 @@ def on_subwindow_activated(self, window: "Plot"): # Show controls for the active window if hasattr(window, "show_selector_control_widget"): - print("Showing selector control widget for window:", window) + logger.debug("Showing selector control widget for window: %s", window) window.show_selector_control_widget() if hasattr(window, "show_toolbars"): - print("Showing toolbars for window:", window) + logger.debug("Showing toolbars for window: %s", window) window.show_toolbars() ps = getattr(window, "plot_state", None) if ps is not None: - print("Updating axes widget for window:", window) + logger.debug("Updating axes widget for window: %s", window) self.update_axes_widget(window) if hasattr(ps, "toolbar") and ps.toolbar is not None: ps.toolbar.setVisible(True) @@ -574,14 +609,14 @@ def on_subwindow_activated(self, window: "Plot"): and img_item is not self._histogram_image_item ): try: - print("Binding histogram to new image item:", img_item) + logger.debug("Binding histogram to new image item: %s", img_item) self.histogram.setImageItem(img_item) self._histogram_image_item = img_item if ps is not None: self.histogram.setLevels(ps.min_level, ps.max_level) except Exception: pass - print("updating histogram levels from plot state:", ps) + logger.debug("updating histogram levels from plot state: %s", ps) # Update metadata if signal tree changed st = getattr(window, "signal_tree", None) @@ -596,7 +631,7 @@ def on_subwindow_activated(self, window: "Plot"): and self.cmap_selector is not None ): self.cmap_selector.setCurrentText(ps.colormap) - print("Sub-window activated:", window) + logger.debug("Sub-window activated: %s", window) def add_plot_control_widget(self): """ @@ -721,7 +756,7 @@ def on_cmap_changed(self, cmap_name: str): if sub is None: return if hasattr(sub, "set_colormap"): - print("Setting colormap on plot:", cmap_name) + logger.debug("Setting colormap on plot: %s", cmap_name) sub.set_colormap(cmap_name) def on_histogram_levels_finished(self, signal: HistogramLUTItem): @@ -746,7 +781,7 @@ def on_histogram_levels_finished(self, signal: HistogramLUTItem): w.plot_state.min_level = levels[0] w.plot_state.max_percentile = percentiles[1] w.plot_state.min_percentile = percentiles[0] - print("Setting levels:", levels, "percentiles:", percentiles, "on plot:", w) + logger.debug("Setting levels: %s, percentiles: %s, on plot: %s", levels, percentiles, w) def _is_supported_file(self, path: str) -> bool: try: @@ -810,7 +845,12 @@ def close(self): super().close() -if __name__ == "__main__": +def main(): + """Main entry point for the SpyDE application.""" + # Initialize logging first + setup_logging() + logger.info("Starting SpyDE application") + app = QtWidgets.QApplication(sys.argv) app.setApplicationName("SpyDe") # Set the application name # Create and show the splash screen @@ -839,3 +879,7 @@ def close(self): splash.finish(main_window) # Close the splash screen when the main window is shown app.exec() + + +if __name__ == "__main__": + main() diff --git a/spyde/misc/dialogs/dialogs.py b/spyde/misc/dialogs/dialogs.py index 414d40d..0590b34 100644 --- a/spyde/misc/dialogs/dialogs.py +++ b/spyde/misc/dialogs/dialogs.py @@ -1,4 +1,5 @@ from functools import partial +import logging import numpy as np from PySide6.QtWidgets import ( @@ -16,23 +17,25 @@ import dask.array as da import pyxem +logger = logging.getLogger(__name__) + class DatasetSizeDialog(QDialog): def __init__(self, parent=None, filename=None): - print("Creating Dialog") + logger.debug("Creating Dialog") super().__init__(parent) self.filename = filename kwargs = {} # try to load the dataset - print(f"loading: {filename}") + logger.info("loading: %s", filename) if ".mrc" in filename: kwargs["distributed"] = True try: data = hs.load(filename, lazy=True, **kwargs) except Exception as e: - print(f"Error loading dataset: {e}") + logger.error("Error loading dataset: %s", e) self.reject() return nav_shape = [a.size for a in data.axes_manager.navigation_axes] @@ -43,7 +46,7 @@ def __init__(self, parent=None, filename=None): * (3 - len(nav_shape)) ) self.total_frames = np.prod(nav_shape) - print(self.total_frames) + logger.debug("Total frames: %s", self.total_frames) sig_shape = [a.size for a in data.axes_manager.signal_axes] kx, ky, kz = sig_shape + ( @@ -52,7 +55,7 @@ def __init__(self, parent=None, filename=None): ] * (3 - len(sig_shape)) ) - print("setting_size") + logger.debug("setting_size") self.setWindowTitle("Dataset Size Configuration") @@ -107,7 +110,7 @@ def __init__(self, parent=None, filename=None): def exec(self, /): """Override exec to handle immediate acceptance.""" - print("Executing Dialog", f"x: {self.x}, y: {self.y}, t: {self.t}") + logger.debug("Executing Dialog x: %s, y: %s, t: %s", self.x, self.y, self.t) if self.x < 1 and self.y < 1 and self.t < 1: self.x_input.setValue(1) self.y_input.setValue(1) diff --git a/spyde/misc/dialogs/movie_export.py b/spyde/misc/dialogs/movie_export.py index ddca120..5869e7f 100644 --- a/spyde/misc/dialogs/movie_export.py +++ b/spyde/misc/dialogs/movie_export.py @@ -4,6 +4,8 @@ This dialog allows the user to select parameters for exporting a movie from a selected signal. """ +import logging + from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QDialog, @@ -31,6 +33,8 @@ from typing import TYPE_CHECKING +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from spyde.drawing.multiplot import Plot @@ -259,7 +263,7 @@ def _on_accept(self): self._export_movie() except Exception as e: # Minimal user feedback; in a real app, show a message box - print(f"Movie export failed: {e}") + logger.exception("Movie export failed: %s", e) raise self.accept() diff --git a/spyde/qt/subwindow.py b/spyde/qt/subwindow.py index 72602bc..e9f90f2 100644 --- a/spyde/qt/subwindow.py +++ b/spyde/qt/subwindow.py @@ -2,8 +2,11 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QCursor, QIcon from pathlib import Path +import logging from spyde.drawing.toolbars.plot_control_toolbar import resolve_icon_path +logger = logging.getLogger(__name__) + class FramelessSubWindow(QtWidgets.QMdiSubWindow): def __init__(self, *args, **kwargs): @@ -42,8 +45,8 @@ def __init__(self, *args, **kwargs): self._icon_maximize = QIcon(resolve_icon_path("qt/assets/icons/maximize.svg")) self._icon_close = QIcon(resolve_icon_path("qt/assets/icons/close.svg")) - print("resolved icons", resolve_icon_path("qt/assets/icons/minimize.svg")) - print(self._icon_minimize.isNull()) + logger.debug("resolved icons: %s", resolve_icon_path("qt/assets/icons/minimize.svg")) + logger.debug("Icon is null: %s", self._icon_minimize.isNull()) self.minimize_button.setFixedSize(25, 25) self.minimize_button.clicked.connect(self.toggle_minimize) diff --git a/spyde/qt_scrapper.py b/spyde/qt_scrapper.py index 17ce2d9..4ab075f 100644 --- a/spyde/qt_scrapper.py +++ b/spyde/qt_scrapper.py @@ -1,5 +1,6 @@ from __future__ import annotations import os +import logging from typing import List from PySide6 import QtWidgets, QtTest @@ -11,6 +12,8 @@ ) from sphinx_gallery.scrapers import figure_rst +logger = logging.getLogger(__name__) + def qt_sg_scraper(block, block_vars, gallery_conf) -> List[str]: """ @@ -21,26 +24,26 @@ def qt_sg_scraper(block, block_vars, gallery_conf) -> List[str]: Saves to the 'image_path' prefix provided by Sphinx-Gallery. """ - print("Qt scraper activated!!") + logger.debug("Qt scraper activated!!") ensure_app() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(50) - print("Qt scraper after wait") + logger.debug("Qt scraper after wait") # Preferred: explicitly registered windows targets = list(iter_registered_windows()) - print(f"Registered windows found: {targets}") + logger.debug("Registered windows found: %s", targets) # Fallback: visible top-level windows (main windows and dialogs) if not targets: - print("Falling back to top-level visible windows") + logger.debug("Falling back to top-level visible windows") for w in QtWidgets.QApplication.topLevelWidgets(): if ( isinstance(w, (QtWidgets.QMainWindow, QtWidgets.QDialog)) and w.isVisible() ): targets.append(w) - print(f"Total target windows to capture: {len(targets)}") + logger.debug("Total target windows to capture: %d", len(targets)) saved: List[str] = [] image_path_iterator = block_vars["image_path_iterator"] @@ -51,6 +54,6 @@ def qt_sg_scraper(block, block_vars, gallery_conf) -> List[str]: # Reset registry between blocks clear_registered_windows() - print("Stored images:", saved) - print(f"Qt scraper saved {len(saved)} images.") + logger.debug("Stored images: %s", saved) + logger.info("Qt scraper saved %d images.", len(saved)) return figure_rst(saved, gallery_conf["src_dir"]) diff --git a/spyde/signal_tree.py b/spyde/signal_tree.py index 04cc5e8..44d9041 100644 --- a/spyde/signal_tree.py +++ b/spyde/signal_tree.py @@ -1,4 +1,5 @@ from functools import partial +import logging from PySide6 import QtWidgets from PySide6.QtCore import Qt @@ -6,6 +7,8 @@ from typing import TYPE_CHECKING, Union, List +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from spyde.drawing.plot_states import PlotState from spyde.main_window import MainWindow @@ -125,7 +128,7 @@ def __init__( self.signal_plots.append(plot) plot.update() - print("Created Signal Tree with root signal: ", self.root) + logger.info("Created Signal Tree with root signal: %s", self.root) def _preprocess_navigator(self, signal: BaseSignal) -> BaseSignal: """ @@ -177,7 +180,7 @@ def _on_axis_field_edit( index = sig.axes_manager._axes.index(axis) sig.axes_manager._axes[index].__setattr__(field, line_edit.text()) for plot in self.navigator_plot_manager.plots: - print("Updating navigator plot image rectangle for: ", plot) + logger.debug("Updating navigator plot image rectangle for: %s", plot) plot.update_image_rectangle() else: index = signal.axes_manager._axes.index(axis) @@ -453,15 +456,15 @@ def get_metadata_widget(self) -> dict: metadata : dict A dictionary containing metadata for each signal in the tree. """ - print("Getting metadata widget") + logger.debug("Getting metadata widget") subsections = {} for subsection in METADATA_WIDGET_CONFIG["metadata_widget"]: - print(f"Processing subsection: {subsection}") + logger.debug("Processing subsection: %s", subsection) subsections[subsection] = {} for prop, value in METADATA_WIDGET_CONFIG["metadata_widget"][ subsection ].items(): - print(f"Processing property: {prop} with value: {value}") + logger.debug("Processing property: %s with value: %s", prop, value) if "key" in value: current_value = self.root.metadata.get_item( item_path=value["key"], default=value.get("default", "--") @@ -469,10 +472,10 @@ def get_metadata_widget(self) -> dict: elif "attr" in value: current_value = self.get_nested_attr(value["attr"]) elif "function" in value: - print(f"Calling function for property {prop}: {value['function']}") + logger.debug("Calling function for property %s: %s", prop, value['function']) fun = self.get_nested_attr(value["function"]) if fun is None or not callable(fun): - print(f"Function {value['function']} not found.") + logger.debug("Function %s not found.", value['function']) current_value = "--" else: current_value = self.get_nested_attr(value["function"])() @@ -481,9 +484,9 @@ def get_metadata_widget(self) -> dict: current_value_string = ( f"{current_value} {value.get('units', '')}".strip() ) - print(f"Resolved value for {prop}: {current_value_string}") + logger.debug("Resolved value for %s: %s", prop, current_value_string) subsections[subsection][prop] = current_value_string - print("Final Subsections:", subsections) + logger.debug("Final Subsections: %s", subsections) return subsections def get_node(self, signal: BaseSignal): @@ -594,6 +597,6 @@ def add_transformation( "kwargs": kwargs, "children": {}, } - print(f"Added transformation '{node_name}' to the tree under parent signal.") + logger.info("Added transformation '%s' to the tree under parent signal.", node_name) self.update_plot_states(new_signal) return new_signal