Add a "Check for Updates" dialog (#863)

* Add basic dialog for Updates check
* Add GitHub API call and processing
* Finalise the update dialog
* Improve test coverage
This commit is contained in:
Veronica Berglyd Olsen
2021-08-22 14:33:00 +02:00
committed by GitHub
parent 80911af4d2
commit b5e52b80a3
6 changed files with 250 additions and 20 deletions
+2
View File
@@ -27,6 +27,7 @@ from nw.dialogs.preferences import GuiPreferences
from nw.dialogs.projload import GuiProjectLoad from nw.dialogs.projload import GuiProjectLoad
from nw.dialogs.projsettings import GuiProjectSettings from nw.dialogs.projsettings import GuiProjectSettings
from nw.dialogs.quotes import GuiQuoteSelect from nw.dialogs.quotes import GuiQuoteSelect
from nw.dialogs.updates import GuiUpdates
from nw.dialogs.wordlist import GuiWordList from nw.dialogs.wordlist import GuiWordList
__all__ = [ __all__ = [
@@ -38,5 +39,6 @@ __all__ = [
"GuiProjectLoad", "GuiProjectLoad",
"GuiProjectSettings", "GuiProjectSettings",
"GuiQuoteSelect", "GuiQuoteSelect",
"GuiUpdates",
"GuiWordList", "GuiWordList",
] ]
+170
View File
@@ -0,0 +1,170 @@
"""
novelWriter GUI Updates
=========================
A dialog box for checking for latest updates
File History:
Created: 2021-08-21 [1.5-alpah0]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import nw
import json
import logging
from datetime import datetime
from urllib.request import Request, urlopen
from PyQt5.QtGui import QCursor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QLabel
)
from nw.common import logException
logger = logging.getLogger(__name__)
class GuiUpdates(QDialog):
def __init__(self, theParent):
QDialog.__init__(self, theParent)
logger.debug("Initialising GuiUpdates ...")
self.setObjectName("GuiUpdates")
self.mainConf = nw.CONFIG
self.theParent = theParent
self.setWindowTitle(self.tr("Check for Updates"))
nPx = self.mainConf.pxInt(96)
sPx = self.mainConf.pxInt(16)
tPx = self.mainConf.pxInt(8)
mPx = self.mainConf.pxInt(4)
# Left Box
self.nwIcon = QLabel()
self.nwIcon.setPixmap(self.theParent.theTheme.getPixmap("novelwriter", (nPx, nPx)))
self.leftBox = QVBoxLayout()
self.leftBox.addWidget(self.nwIcon)
self.leftBox.addStretch(1)
# Right Box
self.currentLabel = QLabel(self.tr("Current Release"))
self.currentValue = QLabel(self.tr(
"novelWriter {0} released on {1}"
).format(
"v%s" % nw.__version__,
datetime.strptime(nw.__date__, "%Y-%m-%d").strftime("%x"))
)
self.latestLabel = QLabel(self.tr("Latest Release"))
self.latestValue = QLabel(self.tr("Checking ..."))
self.latestLink = QLabel("")
self.latestLink.setOpenExternalLinks(True)
self.rightBox = QVBoxLayout()
self.rightBox.addWidget(self.currentLabel)
self.rightBox.addWidget(self.currentValue)
self.rightBox.addSpacing(tPx)
self.rightBox.addWidget(self.latestLabel)
self.rightBox.addWidget(self.latestValue)
self.rightBox.addSpacing(tPx)
self.rightBox.addWidget(self.latestLink)
self.rightBox.setSpacing(mPx)
hFont = self.currentLabel.font()
hFont.setBold(True)
self.currentLabel.setFont(hFont)
self.latestLabel.setFont(hFont)
# Buttons
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok)
self.buttonBox.accepted.connect(self._doClose)
# Assemble
self.innerBox = QHBoxLayout()
self.innerBox.addLayout(self.leftBox)
self.innerBox.addLayout(self.rightBox)
self.innerBox.setSpacing(sPx)
self.outerBox = QVBoxLayout()
self.outerBox.addLayout(self.innerBox)
self.outerBox.addWidget(self.buttonBox)
self.outerBox.setSpacing(sPx)
self.setLayout(self.outerBox)
logger.debug("GuiUpdates initialisation complete")
return
def checkLatest(self):
"""Check for latest release.
"""
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
urlReq = Request("https://api.github.com/repos/vkbo/novelwriter/releases/latest")
urlReq.add_header("User-Agent", "Mozilla/5.0 (compatible; novelWriter (Python))")
urlReq.add_header("Accept", "application/vnd.github.v3+json")
rawData = {}
try:
urlData = urlopen(urlReq, timeout=10)
rawData = json.loads(urlData.read().decode())
except Exception:
logger.error("Failed to contact GitHub API")
logException()
relVersion = rawData.get("tag_name", "Unknown")
relDate = rawData.get("created_at", None)
try:
relDate = datetime.strptime(relDate[:10], "%Y-%m-%d").strftime("%x")
except Exception:
relDate = "Unknown"
logException()
self.latestValue.setText(self.tr(
"novelWriter {0} released on {1}"
).format(
relVersion, relDate
))
self.latestLink.setText(self.tr(
"Download: {0}"
).format(
f'<a href="{nw.__url__}">{nw.__url__}</a>'
))
qApp.restoreOverrideCursor()
return
##
# Internal Functions
##
def _doClose(self):
self.close()
return
# END Class GuiUpdates
+9 -8
View File
@@ -1125,14 +1125,6 @@ class GuiMainMenu(QMenuBar):
self.aQuestion.triggered.connect(lambda: self._openWebsite(nw.__helpurl__)) self.aQuestion.triggered.connect(lambda: self._openWebsite(nw.__helpurl__))
self.helpMenu.addAction(self.aQuestion) self.helpMenu.addAction(self.aQuestion)
# Document > Latest Release
self.aRelease = QAction(self.tr("Latest Release (GitHub)"), self)
self.aRelease.setStatusTip(
self.tr("Open the Releases page on GitHub at {0}").format(nw.__releaseurl__)
)
self.aRelease.triggered.connect(lambda: self._openWebsite(nw.__releaseurl__))
self.helpMenu.addAction(self.aRelease)
# Document > Main Website # Document > Main Website
self.aWebsite = QAction(self.tr("The novelWriter Website"), self) self.aWebsite = QAction(self.tr("The novelWriter Website"), self)
self.aWebsite.setStatusTip( self.aWebsite.setStatusTip(
@@ -1141,6 +1133,15 @@ class GuiMainMenu(QMenuBar):
self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__))
self.helpMenu.addAction(self.aWebsite) self.helpMenu.addAction(self.aWebsite)
# Help > Separator
self.helpMenu.addSeparator()
# Document > Check for Updates
self.aUpdates = QAction(self.tr("Check for New Release"), self)
self.aUpdates.setStatusTip(self.tr("Check for latest release of novelWriter"))
self.aUpdates.triggered.connect(lambda: self.theParent.showUpdatesDialog())
self.helpMenu.addAction(self.aUpdates)
return return
# END Class GuiMainMenu # END Class GuiMainMenu
+17 -2
View File
@@ -44,7 +44,7 @@ from nw.gui import (
) )
from nw.dialogs import ( from nw.dialogs import (
GuiAbout, GuiDocMerge, GuiDocSplit, GuiItemEditor, GuiPreferences, GuiAbout, GuiDocMerge, GuiDocSplit, GuiItemEditor, GuiPreferences,
GuiProjectLoad, GuiProjectSettings, GuiWordList GuiProjectLoad, GuiProjectSettings, GuiUpdates, GuiWordList
) )
from nw.tools import GuiBuildNovel, GuiProjectWizard, GuiWritingStats from nw.tools import GuiBuildNovel, GuiProjectWizard, GuiWritingStats
from nw.core import NWProject, NWIndex from nw.core import NWProject, NWIndex
@@ -289,7 +289,7 @@ class GuiMain(QMainWindow):
logger.debug("GUI initialisation complete") logger.debug("GUI initialisation complete")
if nw.__hexversion__[-2] == "a": if nw.__hexversion__[-2] == "a" and logger.getEffectiveLevel() > logging.DEBUG:
self.makeAlert(self.tr( self.makeAlert(self.tr(
"You are running an untested development version of novelWriter. " "You are running an untested development version of novelWriter. "
"Please be careful when working on a live project " "Please be careful when working on a live project "
@@ -1096,6 +1096,21 @@ class GuiMain(QMainWindow):
msgBox.aboutQt(self, "About Qt") msgBox.aboutQt(self, "About Qt")
return return
def showUpdatesDialog(self):
"""Show the updates dialog for novelWriter.
"""
dlgUpdate = getGuiItem("GuiUpdates")
if dlgUpdate is None:
dlgUpdate = GuiUpdates(self)
dlgUpdate.setModal(True)
dlgUpdate.show()
dlgUpdate.raise_()
qApp.processEvents()
dlgUpdate.checkLatest()
return
def makeAlert(self, theMessage, theLevel=nwAlert.INFO): def makeAlert(self, theMessage, theLevel=nwAlert.INFO):
"""Alert both the user and the logger at the same time. Message """Alert both the user and the logger at the same time. Message
can be either a string or an array of strings. can be either a string or an array of strings.
+45 -3
View File
@@ -22,9 +22,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import pytest import pytest
from PyQt5.QtCore import QItemSelectionModel from PyQt5.QtCore import QItemSelectionModel
from PyQt5.QtWidgets import QListWidgetItem, QDialog, QMessageBox from PyQt5.QtWidgets import QAction, QListWidgetItem, QDialog, QMessageBox
from nw.dialogs import GuiQuoteSelect from nw.dialogs import GuiQuoteSelect, GuiUpdates
keyDelay = 2 keyDelay = 2
typeDelay = 1 typeDelay = 1
@@ -32,7 +32,7 @@ stepDelay = 20
@pytest.mark.gui @pytest.mark.gui
def testDlgOther_QuoteSelect(monkeypatch, nwGUI): def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI):
"""Test the quote symbols dialog. """Test the quote symbols dialog.
""" """
# Block message box # Block message box
@@ -59,3 +59,45 @@ def testDlgOther_QuoteSelect(monkeypatch, nwGUI):
nwQuot.close() nwQuot.close()
# END Test testDlgOther_QuoteSelect # END Test testDlgOther_QuoteSelect
@pytest.mark.gui
def testDlgOther_Updates(qtbot, monkeypatch, nwGUI):
"""Test the check for updates dialog.
"""
# Block message box
monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes)
nwUpdate = GuiUpdates(nwGUI)
nwUpdate.show()
class mockData:
def decode(self):
return '{"tag_name": "v1.0", "created_at": "2021-01-01T12:00:00Z"}'
class mockPayload:
def read(self):
return mockData()
def mockUrlopenA(*a, **k):
return None
def mockUrlopenB(*a, **k):
return mockPayload()
# Faulty Return
monkeypatch.setattr("nw.dialogs.updates.urlopen", mockUrlopenA)
nwUpdate.checkLatest()
# Valid Return
monkeypatch.setattr("nw.dialogs.updates.urlopen", mockUrlopenB)
nwUpdate.checkLatest()
assert nwUpdate.latestValue.text().startswith("novelWriter v1.0")
# Trigger from Menu
nwGUI.mainMenu.aUpdates.activate(QAction.Trigger)
# qtbot.stopForInteraction()
nwUpdate._doClose()
# END Test testDlgOther_Updates
+7 -7
View File
@@ -379,7 +379,7 @@ def testGuiMenu_ContextMenus(qtbot, monkeypatch, nwGUI, nwLipsum):
"""Test the context menus. """Test the context menus.
""" """
# Block message box # Block message box
monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes)
nwGUI.theProject.projTree.setSeed(42) nwGUI.theProject.projTree.setSeed(42)
assert nwGUI.openProject(nwLipsum) assert nwGUI.openProject(nwLipsum)
@@ -637,7 +637,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj):
# Faulty Keyword Inserts # Faulty Keyword Inserts
assert not nwGUI.docEditor.insertKeyWord("blabla") assert not nwGUI.docEditor.insertKeyWord("blabla")
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(QTextBlock, "isValid", lambda *args, **kwards: False) mp.setattr(QTextBlock, "isValid", lambda *a, **k: False)
assert not nwGUI.docEditor.insertKeyWord(nwKeyWords.TAG_KEY) assert not nwGUI.docEditor.insertKeyWord(nwKeyWords.TAG_KEY)
nwGUI.docEditor.clear() nwGUI.docEditor.clear()
@@ -667,16 +667,16 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj):
nwGUI.closeDocument() nwGUI.closeDocument()
# First, with no path # First, with no path
monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: ("", "")) monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: ("", ""))
assert not nwGUI.importDocument() assert not nwGUI.importDocument()
# Then with a path, but an invalid one # Then with a path, but an invalid one
monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: (" ", "")) monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (" ", ""))
assert not nwGUI.importDocument() assert not nwGUI.importDocument()
# Then a valid path, but bot a file that exists # Then a valid path, but bot a file that exists
theFile = os.path.join(fncDir, "import.txt") theFile = os.path.join(fncDir, "import.txt")
monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: (theFile, "")) monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (theFile, ""))
assert not nwGUI.importDocument() assert not nwGUI.importDocument()
# Create the file and try again, but with no target document open # Create the file and try again, but with no target document open
@@ -689,12 +689,12 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj):
assert nwGUI.docEditor.getText() == "Bar" assert nwGUI.docEditor.getText() == "Bar"
# The document isn't empty, so the message box should pop # The document isn't empty, so the message box should pop
monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.No)
assert not nwGUI.importDocument() assert not nwGUI.importDocument()
assert nwGUI.docEditor.getText() == "Bar" assert nwGUI.docEditor.getText() == "Bar"
# Finally, accept the replaced text, this time we use the menu entry to trigger it # Finally, accept the replaced text, this time we use the menu entry to trigger it
monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes)
nwGUI.mainMenu.aImportFile.activate(QAction.Trigger) nwGUI.mainMenu.aImportFile.activate(QAction.Trigger)
assert nwGUI.docEditor.getText() == "Foo" assert nwGUI.docEditor.getText() == "Foo"