refactor add-on downloading/installing/updating

- web requests done on a background thread
- easier to use outside of the addon dialog
- gets max point version info from AnkiWeb, which we can use in
the future
This commit is contained in:
Damien Elmes 2020-01-19 10:37:15 +10:00
parent 49bba16f24
commit 6134ae9ec6
2 changed files with 365 additions and 213 deletions

View file

@ -1,13 +1,18 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import io import io
import json import json
import os import os
import re import re
import zipfile import zipfile
from collections import defaultdict from collections import defaultdict
from typing import IO, Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union from concurrent.futures import Future
from dataclasses import dataclass
from typing import IO, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
from zipfile import ZipFile from zipfile import ZipFile
import jsonschema import jsonschema
@ -20,7 +25,6 @@ import aqt.forms
from anki.httpclient import AnkiRequestsClient from anki.httpclient import AnkiRequestsClient
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.utils import intTime from anki.utils import intTime
from aqt.downloader import download
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
askUser, askUser,
@ -38,13 +42,42 @@ from aqt.utils import (
) )
class AddonInstallationResult(NamedTuple): @dataclass
success: bool class InstallOk:
errmsg: Optional[str] = None name: str
name: Optional[str] = None conflicts: List[str]
conflicts: Optional[List[str]] = None
@dataclass
class InstallError:
errmsg: str
@dataclass
class DownloadOk:
data: bytes
filename: str
@dataclass
class DownloadError:
# set if result was not 200
status_code: Optional[int] = None
# set if an exception occurred
exception: Optional[Exception] = None
@dataclass
class UpdateInfo:
id: int
last_updated: int
max_point_version: Optional[int]
# first arg is add-on id
DownloadLogEntry = Tuple[int, Union[DownloadError, InstallError, InstallOk]]
# fixme: this class should not have any GUI code in it
class AddonManager: class AddonManager:
ext: str = ".ankiaddon" ext: str = ".ankiaddon"
@ -59,7 +92,7 @@ class AddonManager:
"required": ["package", "name"], "required": ["package", "name"],
} }
def __init__(self, mw): def __init__(self, mw: aqt.main.AnkiQt):
self.mw = mw self.mw = mw
self.dirty = False self.dirty = False
f = self.mw.form f = self.mw.form
@ -118,7 +151,7 @@ When loading '%(name)s':
def _addonMetaPath(self, dir): def _addonMetaPath(self, dir):
return os.path.join(self.addonsFolder(dir), "meta.json") return os.path.join(self.addonsFolder(dir), "meta.json")
def addonMeta(self, dir): def addonMeta(self, dir: str) -> Dict[str, Any]:
path = self._addonMetaPath(dir) path = self._addonMetaPath(dir)
try: try:
with open(path, encoding="utf8") as f: with open(path, encoding="utf8") as f:
@ -163,6 +196,14 @@ and have been disabled: %(found)s"
buf += _(" (disabled)") buf += _(" (disabled)")
return buf return buf
def enabled_addon_ids(self) -> List[int]:
ids = []
for dir in self.managedAddons():
meta = self.addonMeta(dir)
if not meta.get("disabled"):
ids.append(int(dir))
return ids
# Conflict resolution # Conflict resolution
###################################################################### ######################################################################
@ -211,14 +252,14 @@ and have been disabled: %(found)s"
def install( def install(
self, file: Union[IO, str], manifest: dict = None self, file: Union[IO, str], manifest: dict = None
) -> AddonInstallationResult: ) -> Union[InstallOk, InstallError]:
"""Install add-on from path or file-like object. Metadata is read """Install add-on from path or file-like object. Metadata is read
from the manifest file, with keys overriden by supplying a 'manifest' from the manifest file, with keys overriden by supplying a 'manifest'
dictionary""" dictionary"""
try: try:
zfile = ZipFile(file) zfile = ZipFile(file)
except zipfile.BadZipfile: except zipfile.BadZipfile:
return AddonInstallationResult(success=False, errmsg="zip") return InstallError(errmsg="zip")
with zfile: with zfile:
file_manifest = self.readManifestFile(zfile) file_manifest = self.readManifestFile(zfile)
@ -226,7 +267,7 @@ and have been disabled: %(found)s"
file_manifest.update(manifest) file_manifest.update(manifest)
manifest = file_manifest manifest = file_manifest
if not manifest: if not manifest:
return AddonInstallationResult(success=False, errmsg="manifest") return InstallError(errmsg="manifest")
package = manifest["package"] package = manifest["package"]
conflicts = manifest.get("conflicts", []) conflicts = manifest.get("conflicts", [])
found_conflicts = self._disableConflicting(package, conflicts) found_conflicts = self._disableConflicting(package, conflicts)
@ -240,9 +281,7 @@ and have been disabled: %(found)s"
meta.update(manifest_meta) meta.update(manifest_meta)
self.writeAddonMeta(package, meta) self.writeAddonMeta(package, meta)
return AddonInstallationResult( return InstallOk(name=meta["name"], conflicts=found_conflicts)
success=True, name=meta["name"], conflicts=found_conflicts
)
def _install(self, dir, zfile): def _install(self, dir, zfile):
# previously installed? # previously installed?
@ -299,7 +338,7 @@ and have been disabled: %(found)s"
base = os.path.basename(path) base = os.path.basename(path)
result = self.install(path) result = self.install(path)
if not result.success: if isinstance(result, InstallError):
errs.extend( errs.extend(
self._installationErrorReport(result, base, mode="local") self._installationErrorReport(result, base, mode="local")
) )
@ -312,40 +351,11 @@ and have been disabled: %(found)s"
return log, errs return log, errs
# Downloading add-ons from AnkiWeb
######################################################################
def downloadIds(self, ids):
log = []
errs = []
self.mw.progress.start(immediate=True)
for n in ids:
ret = download(self.mw, n)
if ret[0] == "error":
errs.append(
_("Error downloading %(id)s: %(error)s") % dict(id=n, error=ret[1])
)
continue
data, fname = ret
fname = fname.replace("_", " ")
name = os.path.splitext(fname)[0]
result = self.install(
io.BytesIO(data),
manifest={"package": str(n), "name": name, "mod": intTime()},
)
if not result.success:
errs.extend(self._installationErrorReport(result, n))
else:
log.extend(self._installationSuccessReport(result, n))
self.mw.progress.finish()
return log, errs
# Installation messaging # Installation messaging
###################################################################### ######################################################################
def _installationErrorReport( def _installationErrorReport(
self, result: AddonInstallationResult, base: str, mode: str = "download" self, result: InstallError, base: str, mode: str = "download"
) -> List[str]: ) -> List[str]:
messages = { messages = {
@ -353,24 +363,19 @@ and have been disabled: %(found)s"
"manifest": _("Invalid add-on manifest."), "manifest": _("Invalid add-on manifest."),
} }
if result.errmsg: msg = messages.get(result.errmsg, _("Unknown error: {}".format(result.errmsg)))
msg = messages.get(
result.errmsg, _("Unknown error: {}".format(result.errmsg))
)
else:
msg = _("Unknown error")
if mode == "download": # preserve old format strings for i18n if mode == "download": # preserve old format strings for i18n
template = _("Error downloading <i>%(id)s</i>: %(error)s") template = _("Error downloading <i>%(id)s</i>: %(error)s")
else: else:
template = _("Error installing <i>%(base)s</i>: %(error)s") template = _("Error installing <i>%(base)s</i>: %(error)s")
name = result.name or base name = base
return [template % dict(base=name, id=name, error=msg)] return [template % dict(base=name, id=name, error=msg)]
def _installationSuccessReport( def _installationSuccessReport(
self, result: AddonInstallationResult, base: str, mode: str = "download" self, result: InstallOk, base: str, mode: str = "download"
) -> List[str]: ) -> List[str]:
if mode == "download": # preserve old format strings for i18n if mode == "download": # preserve old format strings for i18n
@ -393,44 +398,21 @@ and have been disabled: %(found)s"
# Updating # Updating
###################################################################### ######################################################################
def checkForUpdates(self): def update_max_supported_versions(self, items: List[UpdateInfo]) -> None:
client = AnkiRequestsClient() # todo
pass
# get mod times def updates_required(self, items: List[UpdateInfo]) -> List[int]:
self.mw.progress.start(immediate=True) """Return ids of add-ons requiring an update."""
try: need_update = []
# ..of enabled items downloaded from ankiweb for item in items:
addons = [] if not self.addon_is_latest(item.id, item.last_updated):
for dir in self.managedAddons(): need_update.append(item.id)
meta = self.addonMeta(dir)
if not meta.get("disabled"):
addons.append(dir)
mods = [] return need_update
while addons:
chunk = addons[:25]
del addons[:25]
mods.extend(self._getModTimes(client, chunk))
return self._updatedIds(mods)
finally:
self.mw.progress.finish()
def _getModTimes(self, client, chunk): def addon_is_latest(self, id: int, server_update: int) -> bool:
resp = client.get(aqt.appShared + "updates/" + ",".join(chunk)) return self.addonMeta(str(id)).get("mod", 0) >= (server_update or 0)
if resp.status_code == 200:
return resp.json()
else:
raise Exception(
"Unexpected response code from AnkiWeb: {}".format(resp.status_code)
)
def _updatedIds(self, mods):
updated = []
for dir, ts in mods:
sid = str(dir)
if self.addonMeta(sid).get("mod", 0) < (ts or 0):
updated.append(sid)
return updated
# Add-on Config # Add-on Config
###################################################################### ######################################################################
@ -532,7 +514,7 @@ and have been disabled: %(found)s"
class AddonsDialog(QDialog): class AddonsDialog(QDialog):
def __init__(self, addonsManager): def __init__(self, addonsManager: AddonManager):
self.mgr = addonsManager self.mgr = addonsManager
self.mw = addonsManager.mw self.mw = addonsManager.mw
@ -542,7 +524,7 @@ class AddonsDialog(QDialog):
f.setupUi(self) f.setupUi(self)
f.getAddons.clicked.connect(self.onGetAddons) f.getAddons.clicked.connect(self.onGetAddons)
f.installFromFile.clicked.connect(self.onInstallFiles) f.installFromFile.clicked.connect(self.onInstallFiles)
f.checkForUpdates.clicked.connect(self.onCheckForUpdates) f.checkForUpdates.clicked.connect(self.check_for_updates)
f.toggleEnabled.clicked.connect(self.onToggleEnabled) f.toggleEnabled.clicked.connect(self.onToggleEnabled)
f.viewPage.clicked.connect(self.onViewPage) f.viewPage.clicked.connect(self.onViewPage)
f.viewFiles.clicked.connect(self.onViewFiles) f.viewFiles.clicked.connect(self.onViewFiles)
@ -664,7 +646,16 @@ class AddonsDialog(QDialog):
self.redrawAddons() self.redrawAddons()
def onGetAddons(self): def onGetAddons(self):
GetAddons(self) obj = GetAddons(self)
if obj.ids:
download_addons(self, self.mgr, obj.ids, self.after_downloading)
def after_downloading(self, log: List[DownloadLogEntry]):
if log:
self.redrawAddons()
show_log_to_user(self, log)
else:
tooltip(_("No updates available."))
def onInstallFiles(self, paths: Optional[List[str]] = None): def onInstallFiles(self, paths: Optional[List[str]] = None):
if not paths: if not paths:
@ -679,32 +670,9 @@ class AddonsDialog(QDialog):
self.redrawAddons() self.redrawAddons()
def onCheckForUpdates(self): def check_for_updates(self):
try: tooltip(_("Checking..."))
updated = self.mgr.checkForUpdates() check_and_prompt_for_updates(self, self.mgr, self.after_downloading)
except Exception as e:
showWarning(
_("Please check your internet connection.") + "\n\n" + str(e),
textFormat="plain",
)
return
if not updated:
tooltip(_("No updates available."))
else:
names = [self.mgr.addonName(d) for d in updated]
if askUser(_("Update the following add-ons?") + "\n" + "\n".join(names)):
log, errs = self.mgr.downloadIds(updated)
if log:
log_html = "<br>".join(log)
if len(log) == 1:
tooltip(log_html, parent=self)
else:
showInfo(log_html, parent=self, textFormat="rich")
if errs:
showWarning("\n\n".join(errs), parent=self, textFormat="plain")
self.redrawAddons()
def onConfig(self): def onConfig(self):
addon = self.onlyOneSelected() addon = self.onlyOneSelected()
@ -736,6 +704,7 @@ class GetAddons(QDialog):
self.addonsDlg = dlg self.addonsDlg = dlg
self.mgr = dlg.mgr self.mgr = dlg.mgr
self.mw = self.mgr.mw self.mw = self.mgr.mw
self.ids: List[int] = []
self.form = aqt.forms.getaddons.Ui_Dialog() self.form = aqt.forms.getaddons.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
b = self.form.buttonBox.addButton( b = self.form.buttonBox.addButton(
@ -757,21 +726,287 @@ class GetAddons(QDialog):
showWarning(_("Invalid code.")) showWarning(_("Invalid code."))
return return
log, errs = self.mgr.downloadIds(ids) self.ids = ids
if log:
log_html = "<br>".join(log)
if len(log) == 1:
tooltip(log_html, parent=self)
else:
showInfo(log_html, parent=self, textFormat="rich")
if errs:
showWarning("\n\n".join(errs), textFormat="plain")
self.addonsDlg.redrawAddons()
QDialog.accept(self) QDialog.accept(self)
# Downloading
######################################################################
def download_addon(
client: AnkiRequestsClient, id: int
) -> Union[DownloadOk, DownloadError]:
"Fetch a single add-on from AnkiWeb."
try:
resp = client.get(aqt.appShared + f"download/{id}?v=2.1")
if resp.status_code != 200:
return DownloadError(status_code=resp.status_code)
data = client.streamContent(resp)
fname = re.match(
"attachment; filename=(.+)", resp.headers["content-disposition"]
).group(1)
return DownloadOk(data=data, filename=fname)
except Exception as e:
return DownloadError(exception=e)
def download_log_to_html(log: List[DownloadLogEntry]) -> str:
return "\n".join(map(describe_log_entry, log))
def describe_log_entry(id_and_entry: DownloadLogEntry) -> str:
(id, entry) = id_and_entry
buf = f"{id}: "
if isinstance(entry, DownloadError):
if entry.status_code is not None:
if entry.status_code in (403, 404):
buf += _(
"Invalid code, or add-on not available for your version of Anki."
)
else:
buf += _("Unexpected response code: %s") % entry.status_code
else:
buf += (
_("Please check your internet connection.")
+ "\n\n"
+ str(entry.exception)
)
elif isinstance(entry, InstallError):
buf += entry.errmsg
else:
buf += _("Installed successfully.")
return buf
def download_encountered_problem(log: List[DownloadLogEntry]) -> bool:
return any(not isinstance(e[1], InstallOk) for e in log)
def download_and_install_addon(
mgr: AddonManager, client: AnkiRequestsClient, id: int
) -> DownloadLogEntry:
"Download and install a single add-on."
result = download_addon(client, id)
if isinstance(result, DownloadError):
return (id, result)
fname = result.filename.replace("_", " ")
name = os.path.splitext(fname)[0]
result2 = mgr.install(
io.BytesIO(result.data),
manifest={"package": str(id), "name": name, "mod": intTime()},
)
return (id, result2)
class DownloaderInstaller(QObject):
progressSignal = pyqtSignal(int, int)
def __init__(
self, parent: QWidget, mgr: AddonManager, client: AnkiRequestsClient
) -> None:
QObject.__init__(self, parent)
self.mgr = mgr
self.client = client
self.progressSignal.connect(self._progress_callback) # type: ignore
def bg_thread_progress(up, down):
self.progressSignal.emit(up, down) # type: ignore
self.client.progress_hook = bg_thread_progress
def download(
self, ids: List[int], on_done: Callable[[List[DownloadLogEntry]], None]
) -> None:
self.ids = ids
self.log: List[DownloadLogEntry] = []
self.dl_bytes = 0
self.last_tooltip = 0
self.on_done = on_done
self.mgr.mw.progress.start(immediate=True, parent=self.parent())
self.mgr.mw.taskman.run(self._download_all, self._download_done)
def _progress_callback(self, up: int, down: int) -> None:
self.dl_bytes += down
self.mgr.mw.progress.update(
label=_("Downloading %(a)d/%(b)d (%(kb)0.2fKB)...")
% dict(a=len(self.log) + 1, b=len(self.ids), kb=self.dl_bytes / 1024)
)
def _download_all(self):
for id in self.ids:
self.log.append(download_and_install_addon(self.mgr, self.client, id))
def _download_done(self, future):
self.mgr.mw.progress.finish()
# qt gets confused if on_done() opens new windows while the progress
# modal is still cleaning up
self.mgr.mw.progress.timer(50, lambda: self.on_done(self.log), False)
def show_log_to_user(parent: QWidget, log: List[DownloadLogEntry]) -> None:
have_problem = download_encountered_problem(log)
if have_problem:
text = _("One or more errors occurred:")
else:
text = _("Download complete. Please restart Anki to apply changes.")
text += "<br><br>" + download_log_to_html(log)
if have_problem:
showWarning(text, textFormat="rich", parent=parent)
else:
showInfo(text, parent=parent)
def download_addons(
parent: QWidget,
mgr: AddonManager,
ids: List[int],
on_done: Callable[[List[DownloadLogEntry]], None],
client: Optional[AnkiRequestsClient] = None,
) -> None:
if client is None:
client = AnkiRequestsClient()
downloader = DownloaderInstaller(parent, mgr, client)
downloader.download(ids, on_done=on_done)
# Update checking
######################################################################
def fetch_update_info(client: AnkiRequestsClient, ids: List[int]) -> List[UpdateInfo]:
"""Fetch update info from AnkiWeb in one or more batches."""
all_info: List[UpdateInfo] = []
while ids:
# get another chunk
chunk = ids[:25]
del ids[:25]
batch_results = _fetch_update_info_batch(client, map(str, chunk))
all_info.extend(batch_results)
return all_info
def _fetch_update_info_batch(
client: AnkiRequestsClient, chunk: Iterable[str]
) -> Iterable[UpdateInfo]:
"""Get update info from AnkiWeb.
Chunk must not contain more than 25 ids."""
resp = client.get(aqt.appShared + "updates/" + ",".join(chunk) + "?v=2")
if resp.status_code == 200:
return json_update_info_to_native(resp.json())
else:
raise Exception(
"Unexpected response code from AnkiWeb: {}".format(resp.status_code)
)
def json_update_info_to_native(json_obj: List[Dict]) -> Iterable[UpdateInfo]:
def from_json(d: Dict[str, Any]) -> UpdateInfo:
return UpdateInfo(
id=d["id"], last_updated=d["updated"], max_point_version=d["maxver"]
)
return map(from_json, json_obj)
def check_and_prompt_for_updates(
parent: QWidget,
mgr: AddonManager,
on_done: Callable[[List[DownloadLogEntry]], None],
):
def on_updates_received(client: AnkiRequestsClient, items: List[UpdateInfo]):
handle_update_info(parent, mgr, client, items, on_done)
check_for_updates(mgr, on_updates_received)
def check_for_updates(
mgr: AddonManager, on_done: Callable[[AnkiRequestsClient, List[UpdateInfo]], None]
):
client = AnkiRequestsClient()
def check():
return fetch_update_info(client, mgr.enabled_addon_ids())
def update_info_received(future: Future):
# if syncing/in profile screen, defer message delivery
if not mgr.mw.col:
mgr.mw.progress.timer(
1000,
lambda: update_info_received(future),
False,
requiresCollection=False,
)
return
if future.exception():
# swallow network errors
print(str(future.exception()))
result = []
else:
result = future.result()
on_done(client, result)
mgr.mw.taskman.run(check, update_info_received)
def handle_update_info(
parent: QWidget,
mgr: AddonManager,
client: AnkiRequestsClient,
items: List[UpdateInfo],
on_done: Callable[[List[DownloadLogEntry]], None],
) -> None:
# record maximum supported versions
mgr.update_max_supported_versions(items)
updated_ids = mgr.updates_required(items)
if not updated_ids:
on_done([])
return
# tooltip(_("No updates available."))
prompt_to_update(parent, mgr, client, updated_ids, on_done)
def prompt_to_update(
parent: QWidget,
mgr: AddonManager,
client: AnkiRequestsClient,
ids: List[int],
on_done: Callable[[List[DownloadLogEntry]], None],
) -> None:
names = map(lambda x: mgr.addonName(str(x)), ids)
if not askUser(
_("The following add-ons have updates available. Install them now?")
+ "\n\n"
+ "\n".join(names)
):
# on_done is not called if the user cancels
return
download_addons(parent, mgr, ids, on_done, client)
# Editing config # Editing config
###################################################################### ######################################################################

View file

@ -1,83 +0,0 @@
# Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import re
import time
import aqt
from anki import hooks
from anki.httpclient import AnkiRequestsClient
from anki.lang import _
from aqt.qt import *
def download(mw, code):
"Download addon from AnkiWeb. Caller must start & stop progress diag."
# create downloading thread
thread = Downloader(code)
done = False
def onRecv():
if done:
return
mw.progress.update(label="%dKB downloaded" % (thread.recvTotal / 1024))
thread.recv.connect(onRecv)
thread.start()
while not thread.isFinished():
mw.app.processEvents()
thread.wait(100)
# make sure any posted events don't fire after we return
done = True
if not thread.error:
# success
return thread.data, thread.fname
else:
return "error", thread.error
class Downloader(QThread):
recv = pyqtSignal()
def __init__(self, code):
QThread.__init__(self)
self.code = code
self.error = None
def run(self):
# setup progress handler
self.byteUpdate = time.time()
self.recvTotal = 0
def recvEvent(bytes):
self.recvTotal += bytes
self.recv.emit()
hooks.http_data_did_receive.append(recvEvent)
client = AnkiRequestsClient()
try:
resp = client.get(aqt.appShared + "download/%s?v=2.1" % self.code)
if resp.status_code == 200:
data = client.streamContent(resp)
elif resp.status_code in (403, 404):
self.error = _(
"Invalid code, or add-on not available for your version of Anki."
)
return
else:
self.error = _("Unexpected response code: %s" % resp.status_code)
return
except Exception as e:
self.error = _("Please check your internet connection.") + "\n\n" + str(e)
return
finally:
hooks.http_data_did_receive.remove(recvEvent)
self.fname = re.match(
"attachment; filename=(.+)", resp.headers["content-disposition"]
).group(1)
self.data = data