Issue
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_())
Solution
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 thecloseEvent
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 callself.deleteLater()
incloseEvent()
; self.windows
is an instance attribute, adding theplot_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 (forPlotlyViewer
); 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
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.