rework update checks to match latest AnkiWeb API

This commit is contained in:
Damien Elmes 2020-01-27 17:01:09 +10:00
parent 45e8a9a364
commit b76f153ffd
2 changed files with 148 additions and 79 deletions

View file

@ -26,7 +26,6 @@ import aqt
import aqt.forms import aqt.forms
from anki.httpclient import HttpClient from anki.httpclient import HttpClient
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.utils import intTime
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
askUser, askUser,
@ -60,9 +59,10 @@ class InstallError:
class DownloadOk: class DownloadOk:
data: bytes data: bytes
filename: str filename: str
min_point_version: Optional[int] mod_time: int
max_point_version: Optional[int] min_point_version: int
package_index: Optional[int] max_point_version: int
branch_index: int
@dataclass @dataclass
@ -80,9 +80,10 @@ DownloadLogEntry = Tuple[int, Union[DownloadError, InstallError, InstallOk]]
@dataclass @dataclass
class UpdateInfo: class UpdateInfo:
id: int id: int
last_updated: int suitable_branch_last_modified: int
min_point_version: Optional[int] current_branch_last_modified: int
max_point_version: Optional[int] current_branch_min_point_ver: int
current_branch_max_point_ver: int
ANKIWEB_ID_RE = re.compile(r"^\d+$") ANKIWEB_ID_RE = re.compile(r"^\d+$")
@ -97,9 +98,9 @@ class AddonMeta:
enabled: bool enabled: bool
installed_at: int installed_at: int
conflicts: List[str] conflicts: List[str]
min_point_version: Optional[int] min_point_version: int
max_point_version: Optional[int] max_point_version: int
package_index: Optional[int] branch_index: int
def human_name(self) -> str: def human_name(self) -> str:
return self.provided_name or self.dir_name return self.provided_name or self.dir_name
@ -128,9 +129,9 @@ class AddonMeta:
enabled=not json_meta.get("disabled"), enabled=not json_meta.get("disabled"),
installed_at=json_meta.get("mod", 0), installed_at=json_meta.get("mod", 0),
conflicts=json_meta.get("conflicts", []), conflicts=json_meta.get("conflicts", []),
min_point_version=json_meta.get("min_point_version"), min_point_version=json_meta.get("min_point_version", 0) or 0,
max_point_version=json_meta.get("max_point_version"), max_point_version=json_meta.get("max_point_version", 0) or 0,
package_index=json_meta.get("package_index"), branch_index=json_meta.get("branch_index", 0) or 0,
) )
@ -141,13 +142,21 @@ class AddonManager:
_manifest_schema: dict = { _manifest_schema: dict = {
"type": "object", "type": "object",
"properties": { "properties": {
# the name of the folder
"package": {"type": "string", "meta": False}, "package": {"type": "string", "meta": False},
# the displayed name to the user
"name": {"type": "string", "meta": True}, "name": {"type": "string", "meta": True},
# the time the add-on was last modified
"mod": {"type": "number", "meta": True}, "mod": {"type": "number", "meta": True},
# a list of other packages that conflict
"conflicts": {"type": "array", "items": {"type": "string"}, "meta": True}, "conflicts": {"type": "array", "items": {"type": "string"}, "meta": True},
# the minimum 2.1.x version this add-on supports
"min_point_version": {"type": "number", "meta": True}, "min_point_version": {"type": "number", "meta": True},
# if negative, abs(n) is the maximum 2.1.x version this add-on supports
# if positive, indicates version tested on, and is ignored
"max_point_version": {"type": "number", "meta": True}, "max_point_version": {"type": "number", "meta": True},
"package_index": {"type": "number", "meta": True}, # AnkiWeb sends this to indicate which branch the user downloaded.
"branch_index": {"type": "number", "meta": True},
}, },
"required": ["package", "name"], "required": ["package", "name"],
} }
@ -226,7 +235,7 @@ When loading '%(name)s':
json_obj["conflicts"] = addon.conflicts json_obj["conflicts"] = addon.conflicts
json_obj["max_point_version"] = addon.max_point_version json_obj["max_point_version"] = addon.max_point_version
json_obj["min_point_version"] = addon.min_point_version json_obj["min_point_version"] = addon.min_point_version
json_obj["package_index"] = addon.package_index json_obj["branch_index"] = addon.branch_index
self.writeAddonMeta(addon.dir_name, json_obj) self.writeAddonMeta(addon.dir_name, json_obj)
@ -492,31 +501,56 @@ and have been disabled: %(found)s"
# Updating # Updating
###################################################################### ######################################################################
def update_max_supported_versions(self, items: List[UpdateInfo]) -> None: def extract_update_info(self, items: List[Dict]) -> List[UpdateInfo]:
def extract_one(item: Dict) -> UpdateInfo:
id = item["id"]
meta = self.addon_meta(str(id))
branch_idx = meta.branch_index
return extract_update_info(current_point_version, branch_idx, item)
return list(map(extract_one, items))
def update_supported_versions(self, items: List[UpdateInfo]) -> None:
for item in items: for item in items:
self.update_max_supported_version(item) self.update_supported_version(item)
def update_max_supported_version(self, item: UpdateInfo): def update_supported_version(self, item: UpdateInfo):
addon = self.addon_meta(str(item.id)) addon = self.addon_meta(str(item.id))
updated = False
is_latest = self.addon_is_latest(item.id, item.current_branch_last_modified)
# if different to the stored value # if max different to the stored value
if addon.max_point_version != item.max_point_version: cur_max = item.current_branch_max_point_ver
# max version currently specified? if addon.max_point_version != cur_max:
if item.max_point_version is not None: if is_latest:
addon.max_point_version = item.max_point_version addon.max_point_version = cur_max
self.write_addon_meta(addon) updated = True
else: else:
# no max currently specified. we can clear any # user is not up to date; only update if new version is stricter
# existing record provided the user is up to date if cur_max is not None and cur_max < addon.max_point_version:
if self.addon_is_latest(item.id, item.last_updated): addon.max_point_version = cur_max
addon.max_point_version = item.max_point_version updated = True
# if min different to the stored value
cur_min = item.current_branch_min_point_ver
if addon.min_point_version != cur_min:
if is_latest:
addon.min_point_version = cur_min
updated = True
else:
# user is not up to date; only update if new version is stricter
if cur_min is not None and cur_min > addon.min_point_version:
addon.min_point_version = cur_min
updated = True
if updated:
self.write_addon_meta(addon) self.write_addon_meta(addon)
def updates_required(self, items: List[UpdateInfo]) -> List[int]: def updates_required(self, items: List[UpdateInfo]) -> List[int]:
"""Return ids of add-ons requiring an update.""" """Return ids of add-ons requiring an update."""
need_update = [] need_update = []
for item in items: for item in items:
if not self.addon_is_latest(item.id, item.last_updated): if not self.addon_is_latest(item.id, item.suitable_branch_last_modified):
need_update.append(item.id) need_update.append(item.id)
return need_update return need_update
@ -892,9 +926,10 @@ def download_addon(client: HttpClient, id: int) -> Union[DownloadOk, DownloadErr
return DownloadOk( return DownloadOk(
data=data, data=data,
filename=fname, filename=fname,
mod_time=meta.mod_time,
min_point_version=meta.min_point_version, min_point_version=meta.min_point_version,
max_point_version=meta.max_point_version, max_point_version=meta.max_point_version,
package_index=meta.package_index, branch_index=meta.branch_index,
) )
except Exception as e: except Exception as e:
return DownloadError(exception=e) return DownloadError(exception=e)
@ -902,28 +937,22 @@ def download_addon(client: HttpClient, id: int) -> Union[DownloadOk, DownloadErr
@dataclass @dataclass
class ExtractedDownloadMeta: class ExtractedDownloadMeta:
min_point_version: Optional[int] = None mod_time: int
max_point_version: Optional[int] = None min_point_version: int
package_index: Optional[int] = None max_point_version: int
branch_index: int
def extract_meta_from_download_url(url: str) -> ExtractedDownloadMeta: def extract_meta_from_download_url(url: str) -> ExtractedDownloadMeta:
urlobj = urlparse(url) urlobj = urlparse(url)
query = parse_qs(urlobj.query) query = parse_qs(urlobj.query)
meta = ExtractedDownloadMeta() meta = ExtractedDownloadMeta(
mod_time=int(query.get("t")[0]),
min = query.get("minpt") min_point_version=int(query.get("minpt")[0]),
if min is not None: max_point_version=int(query.get("maxpt")[0]),
meta.min_point_version = int(min[0]) branch_index=int(query.get("bidx")[0]),
)
max = query.get("maxpt")
if max is not None:
meta.max_point_version = int(max[0])
pkgidx = query.get("pkgidx")
if pkgidx is not None:
meta.package_index = int(pkgidx[0])
return meta return meta
@ -973,15 +1002,16 @@ def download_and_install_addon(
fname = result.filename.replace("_", " ") fname = result.filename.replace("_", " ")
name = os.path.splitext(fname)[0] name = os.path.splitext(fname)[0]
manifest = {"package": str(id), "name": name, "mod": intTime()} manifest = dict(
if result.min_point_version is not None: package=str(id),
manifest["min_point_version"] = result.min_point_version name=name,
if result.max_point_version is not None: mod=result.mod_time,
manifest["max_point_version"] = result.max_point_version min_point_version=result.min_point_version,
if result.package_index is not None: max_point_version=result.max_point_version,
manifest["package_index"] = result.package_index branch_index=result.branch_index,
)
result2 = mgr.install(io.BytesIO(result.data), manifest=manifest,) result2 = mgr.install(io.BytesIO(result.data), manifest=manifest)
return (id, result2) return (id, result2)
@ -1064,9 +1094,9 @@ def download_addons(
###################################################################### ######################################################################
def fetch_update_info(client: HttpClient, ids: List[int]) -> List[UpdateInfo]: def fetch_update_info(client: HttpClient, ids: List[int]) -> List[Dict]:
"""Fetch update info from AnkiWeb in one or more batches.""" """Fetch update info from AnkiWeb in one or more batches."""
all_info: List[UpdateInfo] = [] all_info: List[Dict] = []
while ids: while ids:
# get another chunk # get another chunk
@ -1081,44 +1111,32 @@ def fetch_update_info(client: HttpClient, ids: List[int]) -> List[UpdateInfo]:
def _fetch_update_info_batch( def _fetch_update_info_batch(
client: HttpClient, chunk: Iterable[str] client: HttpClient, chunk: Iterable[str]
) -> Iterable[UpdateInfo]: ) -> Iterable[Dict]:
"""Get update info from AnkiWeb. """Get update info from AnkiWeb.
Chunk must not contain more than 25 ids.""" Chunk must not contain more than 25 ids."""
resp = client.get(aqt.appShared + "updates/" + ",".join(chunk) + "?v=2") resp = client.get(aqt.appShared + "updates/" + ",".join(chunk) + "?v=3")
if resp.status_code == 200: if resp.status_code == 200:
return json_update_info_to_native(resp.json()) return resp.json()
else: else:
raise Exception( raise Exception(
"Unexpected response code from AnkiWeb: {}".format(resp.status_code) "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"],
min_point_version=d.get("minver"),
)
return map(from_json, json_obj)
def check_and_prompt_for_updates( def check_and_prompt_for_updates(
parent: QWidget, parent: QWidget,
mgr: AddonManager, mgr: AddonManager,
on_done: Callable[[List[DownloadLogEntry]], None], on_done: Callable[[List[DownloadLogEntry]], None],
): ):
def on_updates_received(client: HttpClient, items: List[UpdateInfo]): def on_updates_received(client: HttpClient, items: List[Dict]):
handle_update_info(parent, mgr, client, items, on_done) handle_update_info(parent, mgr, client, items, on_done)
check_for_updates(mgr, on_updates_received) check_for_updates(mgr, on_updates_received)
def check_for_updates( def check_for_updates(
mgr: AddonManager, on_done: Callable[[HttpClient, List[UpdateInfo]], None] mgr: AddonManager, on_done: Callable[[HttpClient, List[Dict]], None]
): ):
client = HttpClient() client = HttpClient()
@ -1148,17 +1166,41 @@ def check_for_updates(
mgr.mw.taskman.run_in_background(check, update_info_received) mgr.mw.taskman.run_in_background(check, update_info_received)
def extract_update_info(
current_point_version: int, current_branch_idx: int, info_json: Dict
) -> UpdateInfo:
"Process branches to determine the updated mod time and min/max versions."
branches = info_json["branches"]
current = branches[current_branch_idx]
last_mod = 0
for branch in branches:
if branch["minpt"] > current_point_version:
continue
if branch["maxpt"] < 0 and abs(branch["maxpt"]) < current_point_version:
continue
last_mod = branch["fmod"]
return UpdateInfo(
id=info_json["id"],
suitable_branch_last_modified=last_mod,
current_branch_last_modified=current["fmod"],
current_branch_min_point_ver=current["minpt"],
current_branch_max_point_ver=current["maxpt"],
)
def handle_update_info( def handle_update_info(
parent: QWidget, parent: QWidget,
mgr: AddonManager, mgr: AddonManager,
client: HttpClient, client: HttpClient,
items: List[UpdateInfo], items: List[Dict],
on_done: Callable[[List[DownloadLogEntry]], None], on_done: Callable[[List[DownloadLogEntry]], None],
) -> None: ) -> None:
# record maximum supported versions update_info = mgr.extract_update_info(items)
mgr.update_max_supported_versions(items) mgr.update_supported_versions(update_info)
updated_ids = mgr.updates_required(items) updated_ids = mgr.updates_required(update_info)
if not updated_ids: if not updated_ids:
on_done([]) on_done([])

View file

@ -4,7 +4,7 @@ from zipfile import ZipFile
from mock import MagicMock from mock import MagicMock
from aqt.addons import AddonManager from aqt.addons import AddonManager, extract_update_info
def test_readMinimalManifest(): def test_readMinimalManifest():
@ -64,3 +64,30 @@ def assertReadManifest(contents, expectedManifest, nameInZip="manifest.json"):
with ZipFile(zfn, "r") as zfile: with ZipFile(zfn, "r") as zfile:
assert adm.readManifestFile(zfile) == expectedManifest assert adm.readManifestFile(zfile) == expectedManifest
def test_update_info():
json_info = dict(
id=999,
branches=[
{"minpt": 0, "maxpt": -15, "fmod": 222},
{"minpt": 20, "maxpt": -25, "fmod": 333},
{"minpt": 30, "maxpt": 35, "fmod": 444},
],
)
r = extract_update_info(5, 0, json_info)
assert r.current_branch_max_point_ver == -15
assert r.suitable_branch_last_modified == 222
r = extract_update_info(5, 1, json_info)
assert r.current_branch_max_point_ver == -25
assert r.suitable_branch_last_modified == 222
r = extract_update_info(19, 1, json_info)
assert r.current_branch_max_point_ver == -25
assert r.suitable_branch_last_modified == 0
r = extract_update_info(20, 1, json_info)
assert r.current_branch_max_point_ver == -25
assert r.suitable_branch_last_modified == 333