Rewrite checking for updates (#1696)

This commit is contained in:
Veronica Berglyd Olsen
2024-02-14 23:35:32 +01:00
committed by GitHub
12 changed files with 336 additions and 287 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ buttontext = 204, 204, 204
brighttext = 62, 62, 62
highlight = 44, 152, 247
highlightedtext = 255, 255, 255
link = 44, 152, 247
linkvisited = 44, 152, 247
link = 102, 153, 204
linkvisited = 102, 153, 204
[GUI]
helptext = 164, 164, 164
+2 -2
View File
@@ -21,8 +21,8 @@ buttontext = 0, 0, 0
brighttext = 255, 255, 255
highlight = 48, 135, 198
highlightedtext = 255, 255, 255
link = 0, 84, 255
linkvisited = 0, 84, 255
link = 66, 113, 174
linkvisited = 66, 113, 174
[GUI]
helptext = 92, 92, 92
+6 -36
View File
@@ -25,8 +25,6 @@ from __future__ import annotations
import logging
from datetime import datetime
from PyQt5.QtGui import QCloseEvent, QColor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
@@ -34,10 +32,10 @@ from PyQt5.QtWidgets import (
QWidget
)
from novelwriter import CONFIG, SHARED, __version__, __date__
from novelwriter.common import formatVersion, readTextFile
from novelwriter.constants import nwConst, nwUnicode
from novelwriter import CONFIG, SHARED
from novelwriter.common import readTextFile
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.versioninfo import VersionInfoWidget
logger = logging.getLogger(__name__)
@@ -68,12 +66,7 @@ class GuiAbout(QDialog):
self.nwLabel = QLabel(self)
self.nwLabel.setPixmap(self.nwImage)
self.nwInfo = QLabel(self.tr("Version {0} {1} Released on {2} {1} {3}").format(
formatVersion(__version__), nwUnicode.U_ENDASH,
datetime.strptime(__date__, "%Y-%m-%d").strftime("%x"),
"<a href='{0}'>{1}</a>".format(nwConst.URL_RELEASES, self.tr("Release Notes"))
))
self.nwInfo.setOpenExternalLinks(True)
self.nwInfo = VersionInfoWidget(self)
self.nwLicence = QLabel(self.tr("This application is licenced under {0}".format(
"<a href='https://www.gnu.org/licenses/gpl-3.0.html'>GPL v3.0</a>"
@@ -82,8 +75,7 @@ class GuiAbout(QDialog):
# Credits
self.lblCredits = NColourLabel(
self.tr("Credits"), SHARED.theme.helpText,
scale=NColourLabel.HEADER_SCALE, parent=self
self.tr("Credits"), scale=1.6, parent=self, bold=True
)
self.txtCredits = QTextBrowser(self)
@@ -100,6 +92,7 @@ class GuiAbout(QDialog):
self.innerBox.addSpacing(hB)
self.innerBox.addWidget(self.nwLabel)
self.innerBox.addWidget(self.nwInfo)
self.innerBox.addSpacing(hA)
self.innerBox.addWidget(self.nwLicence)
self.innerBox.addSpacing(hA)
self.innerBox.addWidget(self.lblCredits)
@@ -156,33 +149,10 @@ class GuiAbout(QDialog):
def _setStyleSheet(self) -> None:
"""Set stylesheet for all browser tabs."""
colHead = SHARED.theme.colHead
colKey = SHARED.theme.colKey
styleSheet = (
"h1, h2, h3, h4 {{"
" color: rgb({hColR}, {hColG}, {hColB});"
"}}\n"
"a {{"
" color: rgb({hColR}, {hColG}, {hColB});"
"}}\n"
".alt {{"
" color: rgb({kColR}, {kColG}, {kColB});"
"}}\n"
).format(
hColR=colHead.red(),
hColG=colHead.green(),
hColB=colHead.blue(),
kColR=colKey.red(),
kColG=colKey.green(),
kColB=colKey.blue(),
)
self.txtCredits.document().setDefaultStyleSheet(styleSheet)
baseCol = self.palette().window().color()
self.txtCredits.setStyleSheet((
"QTextBrowser {{border: none; background: rgb({r},{g},{b});}} "
).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue()))
return
# END Class GuiAbout
-172
View File
@@ -1,172 +0,0 @@
"""
novelWriter GUI Updates
=========================
File History:
Created: 2021-08-21 [1.5b1] GuiUpdates
This file is a part of novelWriter
Copyright 20182024, 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/>.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from urllib.request import Request, urlopen
from PyQt5.QtGui import QCloseEvent, QCursor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QWidget, qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QLabel
)
from novelwriter import CONFIG, SHARED, __version__, __date__
from novelwriter.common import logException
from novelwriter.constants import nwConst
logger = logging.getLogger(__name__)
class GuiUpdates(QDialog):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
logger.debug("Create: GuiUpdates")
self.setObjectName("GuiUpdates")
self.setWindowTitle(self.tr("Check for Updates"))
nPx = CONFIG.pxInt(96)
sPx = CONFIG.pxInt(16)
tPx = CONFIG.pxInt(8)
mPx = CONFIG.pxInt(4)
# Left Box
self.nwIcon = QLabel()
self.nwIcon.setPixmap(SHARED.theme.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" % __version__,
datetime.strptime(__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.Close)
self.buttonBox.rejected.connect(self.close)
# 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("Ready: GuiUpdates")
return
def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: GuiUpdates")
return
def checkLatest(self) -> None:
"""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", nwConst.USER_AGENT)
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", "")
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="{nwConst.URL_WEB}">{nwConst.URL_WEB}</a>'
))
qApp.restoreOverrideCursor()
return
##
# Events
##
def closeEvent(self, event: QCloseEvent) -> None:
"""Capture the user closing the window."""
event.accept()
self.deleteLater()
return
# END Class GuiUpdates
+10 -7
View File
@@ -26,7 +26,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
from PyQt5.QtGui import QColor, QPalette
from PyQt5.QtGui import QColor, QFont, QPalette
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QAbstractButton, QFrame, QHBoxLayout, QLabel, QLayout, QScrollArea,
@@ -198,7 +198,7 @@ class NScrollableForm(QScrollArea):
if helpText:
qHelp = NColourLabel(
str(helpText), self._helpCol, parent=self,
str(helpText), color=self._helpCol, parent=self,
scale=self._fontScale, wrap=True, indent=self._indent
)
labelBox = QVBoxLayout()
@@ -249,16 +249,19 @@ class NColourLabel(QLabel):
HELP_SCALE = DEFAULT_SCALE
HEADER_SCALE = 1.25
def __init__(self, text: str, color: QColor, parent: QWidget | None = None,
scale: float = HELP_SCALE, wrap: bool = False, indent: int = 0) -> None:
def __init__(self, text: str, color: QColor | None = None, parent: QWidget | None = None,
scale: float = HELP_SCALE, wrap: bool = False, indent: int = 0,
bold: bool = False) -> None:
super().__init__(text, parent=parent)
font = self.font()
font.setPointSizeF(scale*font.pointSizeF())
colour = self.palette()
colour.setColor(QPalette.WindowText, color)
font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal)
if color:
colour = self.palette()
colour.setColor(QPalette.WindowText, color)
self.setPalette(colour)
self.setPalette(colour)
self.setFont(font)
self.setIndent(indent)
self.setWordWrap(wrap)
+153
View File
@@ -0,0 +1,153 @@
"""
novelWriter Custom Widget: Version Info
=========================================
File History:
Created: 2024-02-14 [2.3b1] VersionInfoWidget
This file is a part of novelWriter
Copyright 20182024, 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/>.
"""
from __future__ import annotations
import json
import logging
from time import sleep
from datetime import datetime
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtCore import QObject, QRunnable, QUrl, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget
from novelwriter import CONFIG, SHARED, __version__, __date__, __domain__
from novelwriter.common import formatVersion
from novelwriter.constants import nwConst
logger = logging.getLogger(__name__)
API_URL = "https://api.github.com/repos/vkbo/novelwriter/releases/latest"
class VersionInfoWidget(QWidget):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
# Label Strings
self._trLatest = self.tr("Latest Version: {0}")
self._trChecking = self.tr("Checking ...")
self._trDownload = self.tr("Download from {0}")
# Labels
self._lblInfo = QLabel("{0} {1} \u2013 {2} {3} \u2013 {4}".format(
self.tr("Version"), formatVersion(__version__),
self.tr("Released on"), datetime.strptime(__date__, "%Y-%m-%d").strftime("%x"),
"<a href='#notes'>{0}</a>".format(self.tr("Release Notes")),
), self)
self._lblInfo.linkActivated.connect(self._processLink)
self._lblRelease = QLabel(self._trLatest.format(
"<a href='#update'>{0}</a>".format(self.tr("Check Now"))
), self)
self._lblRelease.linkActivated.connect(self._processLink)
# Assemble
self._layout = QVBoxLayout()
self._layout.addWidget(self._lblInfo)
self._layout.addWidget(self._lblRelease)
self._layout.setSpacing(CONFIG.pxInt(2))
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
return
##
# Private Slots
##
@pyqtSlot(str)
def _processLink(self, link: str) -> None:
"""Process an activated link."""
if link == "#notes":
QDesktopServices.openUrl(QUrl(nwConst.URL_RELEASES))
elif link == "#website":
QDesktopServices.openUrl(QUrl(nwConst.URL_WEB))
elif link == "#update":
self._lblRelease.setText(self._trLatest.format(self._trChecking))
lookup = _Retriever()
lookup.signals.dataReady.connect(self._updateReleaseInfo)
SHARED.runInThreadPool(lookup)
return
##
# Private Slots
##
@pyqtSlot(str, str)
def _updateReleaseInfo(self, tag: str, reason: str) -> None:
"""Update the widget release info."""
if version := tag.lstrip("v"):
download = f"<a href='#website'>{__domain__}</a>"
self._lblRelease.setText(self._trLatest.format(
f"{version} \u2013 {self._trDownload.format(download)}"
))
else:
self._lblRelease.setText(self._trLatest.format(reason or self.tr("Failed")))
return
# END Class VersionInfoWidget
class _Retriever(QRunnable):
def __init__(self) -> None:
super().__init__()
self.signals = _RetrieverSignal()
return
@pyqtSlot()
def run(self) -> None:
"""Poll the GitHub API in the background.
Note: The GitHub API is rate limited at 60 requests per hour.
"""
logger.info("Contacting: %s", API_URL)
req = Request(API_URL)
req.add_header("User-Agent", nwConst.USER_AGENT)
req.add_header("Accept", "application/vnd.github.v3+json")
sleep(0.2)
try:
with urlopen(req, timeout=15) as ret:
if isinstance(raw := json.loads(ret.read().decode()), dict):
self.signals.dataReady.emit(raw.get("tag_name", ""), "")
except HTTPError as e:
reason = f"{e.reason.capitalize()} (HTTP {e.code})"
logger.error(reason)
self.signals.dataReady.emit("", reason)
except Exception as e:
logger.error("Failed to retrieve release info")
self.signals.dataReady.emit("", str(e))
return
# END Class _Retriever
class _RetrieverSignal(QObject):
dataReady = pyqtSignal(str, str)
# END Class _RetrieverSignal
-7
View File
@@ -964,13 +964,6 @@ class GuiMainMenu(QMenuBar):
self.aWebsite = self.helpMenu.addAction(self.tr("The novelWriter Website"))
self.aWebsite.triggered.connect(lambda: self._openWebsite(nwConst.URL_WEB))
# Help > Separator
self.helpMenu.addSeparator()
# Document > Check for Updates
self.aUpdates = self.helpMenu.addAction(self.tr("Check for New Release"))
self.aUpdates.triggered.connect(self.mainGui.showUpdatesDialog)
return
# END Class GuiMainMenu
-12
View File
@@ -51,7 +51,6 @@ from novelwriter.gui.statusbar import GuiMainStatus
from novelwriter.gui.itemdetails import GuiItemDetails
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
from novelwriter.dialogs.about import GuiAbout
from novelwriter.dialogs.updates import GuiUpdates
from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings
@@ -884,17 +883,6 @@ class GuiMain(QMainWindow):
msgBox.aboutQt(self, "About Qt")
return
@pyqtSlot()
def showUpdatesDialog(self) -> None:
"""Show the check for updates dialog."""
dialog = GuiUpdates(self)
dialog.setModal(True)
dialog.show()
dialog.raise_()
qApp.processEvents()
dialog.checkLatest()
return
@pyqtSlot()
def showDictionariesDialog(self) -> None:
"""Show the download dictionaries dialog."""
+5 -7
View File
@@ -40,13 +40,14 @@ from PyQt5.QtWidgets import (
QToolButton, QVBoxLayout, QWidget, qApp
)
from novelwriter import CONFIG, SHARED, __version__, __date__
from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwItemClass
from novelwriter.common import formatInt, formatVersion, makeFileNameSafe
from novelwriter.constants import nwFiles, nwUnicode
from novelwriter.common import formatInt, makeFileNameSafe
from novelwriter.constants import nwFiles
from novelwriter.core.coretools import ProjectBuilder
from novelwriter.extensions.switch import NSwitch
from novelwriter.extensions.modified import NComboBox, NSpinBox
from novelwriter.extensions.versioninfo import VersionInfoWidget
logger = logging.getLogger(__name__)
@@ -90,10 +91,7 @@ class GuiWelcome(QDialog):
self.nwLabel = QLabel(self)
self.nwLabel.setPixmap(self.nwImage)
self.nwInfo = QLabel(self.tr("Version {0} {1} Released on {2}").format(
formatVersion(__version__), nwUnicode.U_ENDASH,
datetime.strptime(__date__, "%Y-%m-%d").strftime("%x")
))
self.nwInfo = VersionInfoWidget(self)
self.tabOpen = _OpenProjectPage(self)
self.tabOpen.openProjectRequest.connect(self._openProjectPath)
+1 -40
View File
@@ -23,10 +23,9 @@ from __future__ import annotations
import pytest
from PyQt5.QtCore import QItemSelectionModel
from PyQt5.QtWidgets import QAction, QListWidgetItem, QDialog
from PyQt5.QtWidgets import QListWidgetItem, QDialog
from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.dialogs.updates import GuiUpdates
from novelwriter.dialogs.editlabel import GuiEditLabel
@@ -66,44 +65,6 @@ def testDlgOther_QuoteSelect(qtbot, monkeypatch, nwGUI):
# END Test testDlgOther_QuoteSelect
@pytest.mark.gui
def testDlgOther_Updates(qtbot, monkeypatch, nwGUI):
"""Test the check for updates dialog."""
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("novelwriter.dialogs.updates.urlopen", mockUrlopenA)
nwUpdate.checkLatest()
# Valid Return
monkeypatch.setattr("novelwriter.dialogs.updates.urlopen", mockUrlopenB)
nwUpdate.checkLatest()
assert nwUpdate.latestValue.text().startswith("novelWriter v1.0")
# Trigger from Menu
nwGUI.mainMenu.aUpdates.activate(QAction.Trigger)
# qtbot.stop()
nwUpdate.close()
# END Test testDlgOther_Updates
@pytest.mark.gui
def testDlgOther_EditLabel(qtbot, monkeypatch):
"""Test the label editor dialog."""
+2 -2
View File
@@ -1,6 +1,6 @@
"""
novelWriter Wheel Event Filter Tester
=======================================
novelWriter Modified Widgets Tester
=====================================
This file is a part of novelWriter
Copyright 20182024, Veronica Berglyd Olsen
+155
View File
@@ -0,0 +1,155 @@
"""
novelWriter Version Info Widget Tester
========================================
This file is a part of novelWriter
Copyright 20182024, 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/>.
"""
from __future__ import annotations
import pytest
from urllib.error import HTTPError
from tools import SimpleDialog
from PyQt5.QtCore import QUrl
from novelwriter import SHARED
from novelwriter.constants import nwConst
from novelwriter.extensions.versioninfo import VersionInfoWidget, _Retriever, _RetrieverSignal
class MockRetriever:
signals = _RetrieverSignal()
class MockDesktopServices:
url = None
@staticmethod
def openUrl(url):
MockDesktopServices.url = url
return
class MockData:
pass
class MockPayload:
def __enter__(self):
return self
def __exit__(self, *args):
return
def read(self):
data = MockData()
data.decode = lambda *a: '{"tag_name": "v1.0"}'
return data
class MockHTTPError:
def __enter__(self):
raise HTTPError("http://example.com", 403, "Rate limit", None, None)
def __exit__(self, *args):
return
class MockException:
def __enter__(self):
raise Exception("Oh noes!")
def __exit__(self, *args):
return
@pytest.mark.gui
def testExtVersionInfo_Main(qtbot, monkeypatch):
"""Test the VersionInfoWidget class."""
version = VersionInfoWidget(None)
dialog = SimpleDialog(version)
dialog.show()
with monkeypatch.context() as mp:
mp.setattr("novelwriter.extensions.versioninfo.QDesktopServices", MockDesktopServices)
version._processLink("#notes")
assert MockDesktopServices.url == QUrl(nwConst.URL_RELEASES)
with monkeypatch.context() as mp:
mp.setattr("novelwriter.extensions.versioninfo.QDesktopServices", MockDesktopServices)
version._processLink("#website")
assert MockDesktopServices.url == QUrl(nwConst.URL_WEB)
with monkeypatch.context() as mp:
mp.setattr("novelwriter.extensions.versioninfo._Retriever", MockRetriever)
mp.setattr(SHARED, "runInThreadPool", lambda *a: None)
version._processLink("#update")
assert version._lblRelease.text() == "Latest Version: Checking ..."
version._updateReleaseInfo("v2.0", "")
assert version._lblRelease.text() == (
"Latest Version: 2.0 Download from <a href='#website'>novelwriter.io</a>"
)
version._updateReleaseInfo("", "Error")
assert version._lblRelease.text() == "Latest Version: Error"
version._updateReleaseInfo("", "")
assert version._lblRelease.text() == "Latest Version: Failed"
dialog.close()
# qtbot.stop()
# END Test testExtVersionInfo_Main
@pytest.mark.gui
def testExtVersionInfo_Retriever(qtbot, monkeypatch):
"""Test the _Retriever class."""
task = _Retriever()
# Valid Data
with monkeypatch.context() as mp:
mp.setattr("novelwriter.extensions.versioninfo.urlopen", lambda *a, **k: MockPayload())
with qtbot.waitSignal(task.signals.dataReady) as signal:
task.run()
assert signal.args == ["v1.0", ""]
def httpErr():
raise HTTPError("http://example.com", 403, "Rate limit")
# HTTP Error
with monkeypatch.context() as mp:
mp.setattr("novelwriter.extensions.versioninfo.urlopen", lambda *a, **k: MockHTTPError())
with qtbot.waitSignal(task.signals.dataReady) as signal:
task.run()
assert signal.args == ["", "Rate limit (HTTP 403)"]
# Other Error
with monkeypatch.context() as mp:
mp.setattr("novelwriter.extensions.versioninfo.urlopen", lambda *a, **k: MockException())
with qtbot.waitSignal(task.signals.dataReady) as signal:
task.run()
assert signal.args == ["", "Oh noes!"]
# END Test testExtVersionInfo_Retriever