PyQt5 Plotly viewer creates multiple save file dialogs

Question:

I’ve created a lightweight browser to view plotly .html files within a PyQt5 application instead of in the default browser, using QWebEngineView based on other questions such as this: Using a local file in html for a PyQt5 webengine

The viewer works, but when multiple windows are open with several plots, attempting to save one of the plots as .png file causes several save file dialogs to open (one for every window that has been open since the program started running).

I tried debugging this, after the download request it seems to jump to sys.exit(app.exec_()), then back to the download request again. Although several dialogs are open, only one plot is actually saved.

Is there a way to ensure only one dialog is created?

To reproduce, run the following code and click plot the button 2 or more times, creating several windows. Use the plotly "download plot as png" option and after saving the plot, one or more additional save file dialogs are presented.

import os
import sys
from pathlib import Path

import plotly
import plotly.express as px
from PyQt5 import QtCore, QtWidgets
from PyQt5 import QtWebEngineWidgets, QtGui

user_profile = Path(os.environ.get("USERPROFILE"))

APP_DATA_FOLDER = user_profile / "AppData" / "Local" / "program"
APP_DATA_FOLDER.mkdir(parents=True, exist_ok=True)


class PlotlyViewer(QtWebEngineWidgets.QWebEngineView):
    """A lightweight browser used to view Plotly
    figures without relying on an external browser.
    """

    def __init__(
        self, fig, title="Plot Viewer", count=0, download_directory=None
    ):
        super().__init__()
        self.windows = []

        # Create a temporary html file containing the plot
        self.file_path = str(APP_DATA_FOLDER / f"temp{count}.html")

        plotly.offline.plot(
            fig, filename=self.file_path, auto_open=False,
        )

        # Open the html file with the PlotlyViewer
        self.load(QtCore.QUrl.fromLocalFile(self.file_path))
        self.setWindowTitle(title)
        self.resize(1000, 600)

        # When a downloadRequest is received, run the download_file method
        self.page().profile().downloadRequested.connect(self.download_file)

    def closeEvent(self, event):
        # When the plot is closed, delete the temporary file
        os.remove(self.file_path)

    @QtCore.pyqtSlot(QtWebEngineWidgets.QWebEngineDownloadItem)
    def download_file(self, download):
        # Get a save_file_dialog... For some reason this happens twice!
        plot_path, _ = QtWidgets.QFileDialog.getSaveFileName(
            None, "Save Plot As...", str(user_profile), "Image (*.png)"
        )

        if plot_path:
            download.setPath(plot_path)
            download.accept()

    @staticmethod
    def save_file_dialog(export_dir):
        file_path, _ = QtWidgets.QFileDialog.getSaveFileName(
            None, "Save Plot As...", export_dir, "Image (*.png)"
        )

        return file_path


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.setFixedSize(150, 100)
        MainWindow.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.btn_plot = QtWidgets.QPushButton(self.centralwidget)
        self.btn_plot.setGeometry(QtCore.QRect(0, 0, 70, 23))
        self.btn_plot.setObjectName("btn_plot")
        self.btn_plot.setText("plot")

        self.connect_slots()

    def connect_slots(self):
        self.btn_plot.clicked.connect(self.create_plot)

    def create_plot(self):
        fig = px.scatter(x=[0, 1, 2, 3, 4], y=[0, 1, 4, 9, 16])
        browser_title = "a window title"

        plot_window = PlotlyViewer(fig, browser_title, download_directory=user_profile)
        plot_window.windows.append(plot_window)
        plot_window.show()


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    MainWindow = QtWidgets.QMainWindow()
    ui = Ui_MainWindow()
    ui.setupUi(MainWindow)
    MainWindow.show()
    sys.exit(app.exec_())
Asked By: lamdoug

||

Answers:

By default, all QWebEnginePages share the same QWebEngineProfile, meaning that self.page().profile() will always return the same profile object.

Qt connections are always cumulative, even for the same target slot/function: connecting a signal to the same function twice results in calling that function twice for every time the signal is emitted.

Since you’re connecting to the signal of the same profile every time a new PlotlyViewer instance is created, download_file will be called for each instance every time a download is requested.

You have three possibilities:

  • connect the signal just once using the defaultProfile(), and externally from the PlotlyViewer class;
  • create a new standalone profile (similar to the "private mode") for each view:
    self.profile = QWebEngineProfile()
    self.setPage(QWebEnginePage(profile))
    self.profile.downloadRequested.connect(self.download_file)
  • check that the page() of the download request belongs to the page of the view:
    def download_file(self, download):
        if download.page() != self.page():
            return
        # ...

Further important notes:

  • most widgets override event handlers, and it’s always good practice to call the base implementation (unless you really know what you’re doing); you must call super().closeEvent(event) in the closeEvent override;
  • since you probably don’t intend to reuse closed views, you should always delete them, otherwise their resources will unnecessarily occupy memory (and a lot); add self.setAttribute(Qt.WA_DeleteOnClose) in the __init__, or call self.deleteLater() in closeEvent();
  • self.windows is an instance attribute, adding the plot_window instance to it is completely useless, since it will always contain one object (the instance itself); if you want to keep track of all existing windows you should create the list as an attribute of the parent object (ie. the main window) or as a class attribute (for PlotlyViewer); also, considering the point above, you should delete the reference whenever the view is closed and destroyed;
  • you shall not edit pyuic generated files; unless you did this for the sake of the example, be aware that it’s considered bad practice, and you should instead follow the official guidelines about using Designer;
Answered By: musicamante