Skip to content

Commit 3902a63

Browse files
authored
Merge 7991508 into 0a4e2ea
2 parents 0a4e2ea + 7991508 commit 3902a63

6 files changed

Lines changed: 103 additions & 46 deletions

File tree

source/_addonStore/dataManager.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
if TYPE_CHECKING:
5050
from addonHandler import Addon as AddonHandlerModel # noqa: F401
5151
# AddonGUICollectionT must only be imported when TYPE_CHECKING
52-
from .models.addon import AddonGUICollectionT # noqa: F401
52+
from .models.addon import AddonGUICollectionT, _AddonStoreModel # noqa: F401
5353
from gui._addonStoreGui.viewModels.addonList import AddonListItemVM # noqa: F401
5454
from gui.message import DisplayableError # noqa: F401
5555

@@ -69,7 +69,8 @@ def initialize():
6969
class _DataManager:
7070
_cacheLatestFilename: str = "_cachedLatestAddons.json"
7171
_cacheCompatibleFilename: str = "_cachedCompatibleAddons.json"
72-
_downloadsPendingInstall: Set[Tuple["AddonListItemVM", os.PathLike]] = set()
72+
_downloadsPendingInstall: Set[Tuple["AddonListItemVM[_AddonStoreModel]", os.PathLike]] = set()
73+
_downloadsPendingCompletion: Set["AddonListItemVM[_AddonStoreModel]"] = set()
7374

7475
def __init__(self):
7576
self._lang = languageHandler.getLanguage()
@@ -123,7 +124,7 @@ def _getCacheHash(self) -> Optional[str]:
123124
cacheHash = response.json()
124125
return cacheHash
125126

126-
def _cacheCompatibleAddons(self, addonData: str, cacheHash: str):
127+
def _cacheCompatibleAddons(self, addonData: str, cacheHash: Optional[str]):
127128
if not NVDAState.shouldWriteToDisk():
128129
return
129130
if not addonData or not cacheHash:
@@ -137,7 +138,7 @@ def _cacheCompatibleAddons(self, addonData: str, cacheHash: str):
137138
with open(self._cacheCompatibleFile, 'w', encoding='utf-8') as cacheFile:
138139
json.dump(cacheData, cacheFile, ensure_ascii=False)
139140

140-
def _cacheLatestAddons(self, addonData: str, cacheHash: str):
141+
def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]):
141142
if not NVDAState.shouldWriteToDisk():
142143
return
143144
if not addonData or not cacheHash:

source/_addonStore/models/addon.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def listItemVMId(self) -> str:
9898
return f"{self.addonId}-{self.channel}"
9999

100100
def asdict(self) -> Dict[str, Any]:
101+
assert dataclasses.is_dataclass(self)
101102
jsonData = dataclasses.asdict(self)
102103
for field in jsonData:
103104
# dataclasses.asdict parses NamedTuples to JSON arrays,
@@ -110,6 +111,15 @@ def asdict(self) -> Dict[str, Any]:
110111

111112

112113
class _AddonStoreModel(_AddonGUIModel):
114+
addonId: str
115+
displayName: str
116+
description: str
117+
addonVersionName: str
118+
channel: Channel
119+
homepage: Optional[str]
120+
minNVDAVersion: MajorMinorPatch
121+
lastTestedVersion: MajorMinorPatch
122+
legacy: bool
113123
publisher: str
114124
license: str
115125
licenseURL: Optional[str]
@@ -145,6 +155,7 @@ def cachedDownloadPath(self) -> str:
145155
def isPendingInstall(self) -> bool:
146156
"""True if this addon has not yet been fully installed."""
147157
from ..dataManager import addonDataManager
158+
assert addonDataManager
148159
nameInDownloadsPendingInstall = filter(
149160
lambda m: m[0].model.name == self.name,
150161
# add-ons which have been downloaded but
@@ -240,6 +251,7 @@ class InstalledAddonStoreModel(_AddonManifestModel, _AddonStoreModel):
240251
@property
241252
def manifest(self) -> "AddonManifest":
242253
from ..dataManager import addonDataManager
254+
assert addonDataManager
243255
return addonDataManager._installedAddonsCache.installedAddons[self.name].manifest
244256

245257

@@ -273,7 +285,7 @@ class AddonStoreModel(_AddonStoreModel):
273285
@dataclasses.dataclass
274286
class CachedAddonsModel:
275287
cachedAddonData: "AddonGUICollectionT"
276-
cacheHash: str
288+
cacheHash: Optional[str]
277289
cachedLanguage: str
278290
# AddonApiVersionT or the string .network._LATEST_API_VER
279291
nvdaAPIVersion: Union[addonAPIVersion.AddonApiVersionT, str]

source/_addonStore/models/status.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from pathlib import Path
99
from typing import (
1010
Dict,
11-
Optional,
1211
OrderedDict,
1312
Set,
1413
TYPE_CHECKING,
@@ -51,6 +50,7 @@ class AvailableAddonStatus(DisplayStringEnum):
5150
""" Values to represent the status of add-ons within the NVDA add-on store.
5251
Although related, these are independent of the states in L{addonHandler}
5352
"""
53+
UNKNOWN = enum.auto()
5454
PENDING_REMOVE = enum.auto()
5555
AVAILABLE = enum.auto()
5656
UPDATE = enum.auto()
@@ -120,6 +120,8 @@ def _displayStringLabels(self) -> Dict["AvailableAddonStatus", str]:
120120
self.ENABLED: pgettext("addonStore", "Enabled"),
121121
# Translators: Status for addons shown in the add-on store dialog
122122
self.RUNNING: pgettext("addonStore", "Enabled"),
123+
# Translators: Status for addons shown in the add-on store dialog
124+
self.UNKNOWN: pgettext("addonStore", "Unknown status"),
123125
}
124126

125127

@@ -144,7 +146,7 @@ class AddonStateCategory(str, enum.Enum):
144146
"""Add-ons that are blocked from running because they are incompatible"""
145147

146148

147-
def getStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]:
149+
def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus:
148150
from addonHandler import (
149151
state as addonHandlerState,
150152
)
@@ -213,8 +215,8 @@ def getStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]:
213215
if addonHandlerModel.isEnabled:
214216
return AvailableAddonStatus.ENABLED
215217

216-
log.debugWarning(f"Add-on in unknown state: {model.addonId}")
217-
return None
218+
log.error(f"Add-on in unknown state: {model.addonId}")
219+
return AvailableAddonStatus.UNKNOWN
218220

219221

220222
_addonStoreStateToAddonHandlerState: OrderedDict[
@@ -341,6 +343,7 @@ def displayStringWithAccelerator(self) -> str:
341343
AvailableAddonStatus.PENDING_INCOMPATIBLE_ENABLED,
342344
AvailableAddonStatus.INCOMPATIBLE_DISABLED,
343345
AvailableAddonStatus.INCOMPATIBLE_ENABLED,
346+
AvailableAddonStatus.UNKNOWN,
344347
},
345348
})
346349
"""A dictionary where the keys are a status to filter by,

source/_addonStore/network.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
# This file is covered by the GNU General Public License.
44
# See the file COPYING for more details.
55

6+
# Needed for type hinting Future
7+
# Can be removed in a future version of python (3.8+)
8+
from __future__ import annotations
9+
610
from concurrent.futures import (
711
Future,
812
ThreadPoolExecutor,
@@ -28,7 +32,6 @@
2832
from utils.security import sha256_checksum
2933

3034
from .models.addon import (
31-
AddonStoreModel,
3235
_AddonGUIModel,
3336
_AddonStoreModel,
3437
)
@@ -37,6 +40,7 @@
3740

3841
if TYPE_CHECKING:
3942
from gui.message import DisplayableError
43+
from gui._addonStoreGui.viewModels.addonList import AddonListItemVM
4044

4145

4246
_BASE_URL = "https://nvaccess.org/addonStore"
@@ -61,21 +65,28 @@ def _getCacheHashURL() -> str:
6165

6266

6367
class AddonFileDownloader:
64-
OnCompleteT = Callable[[AddonStoreModel, Optional[os.PathLike]], None]
68+
OnCompleteT = Callable[
69+
["AddonListItemVM[_AddonStoreModel]", Optional[os.PathLike]],
70+
None
71+
]
6572

6673
def __init__(self):
67-
self.progress: Dict[AddonStoreModel, int] = {} # Number of chunks received
74+
self.progress: Dict["AddonListItemVM[_AddonStoreModel]", int] = {} # Number of chunks received
6875
self._pending: Dict[
69-
Future,
76+
Future[Optional[os.PathLike]],
7077
Tuple[
71-
AddonStoreModel,
78+
"AddonListItemVM[_AddonStoreModel]",
7279
AddonFileDownloader.OnCompleteT,
7380
"DisplayableError.OnDisplayableErrorT"
7481
]
7582
] = {}
76-
self.complete: Dict[AddonStoreModel, os.PathLike] = {} # Path to downloaded file
83+
self.complete: Dict[
84+
"AddonListItemVM[_AddonStoreModel]",
85+
# Path to downloaded file
86+
Optional[os.PathLike]
87+
] = {}
7788
self._executor = ThreadPoolExecutor(
78-
max_workers=1,
89+
max_workers=10,
7990
thread_name_prefix="AddonDownloader",
8091
)
8192

@@ -85,20 +96,21 @@ def __init__(self):
8596

8697
def download(
8798
self,
88-
addonData: AddonStoreModel,
99+
addonData: "AddonListItemVM[_AddonStoreModel]",
89100
onComplete: OnCompleteT,
90101
onDisplayableError: "DisplayableError.OnDisplayableErrorT",
91102
):
92103
self.progress[addonData] = 0
93-
f: Future = self._executor.submit(
104+
assert self._executor
105+
f: Future[Optional[os.PathLike]] = self._executor.submit(
94106
self._download, addonData,
95107
)
96108
self._pending[f] = addonData, onComplete, onDisplayableError
97109
f.add_done_callback(self._done)
98110

99-
def _done(self, downloadAddonFuture: Future):
111+
def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]):
100112
isCancelled = downloadAddonFuture not in self._pending
101-
addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].addonId
113+
addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].model.addonId
102114
log.debug(f"Done called for {addonId}")
103115
if isCancelled:
104116
log.debug("Download was cancelled, not calling onComplete")
@@ -108,6 +120,7 @@ def _done(self, downloadAddonFuture: Future):
108120
return
109121
addonData, onComplete, onDisplayableError = self._pending[downloadAddonFuture]
110122
downloadAddonFutureException = downloadAddonFuture.exception()
123+
cacheFilePath: Optional[os.PathLike]
111124
if downloadAddonFutureException:
112125
cacheFilePath = None
113126
from gui.message import DisplayableError
@@ -120,31 +133,38 @@ def _done(self, downloadAddonFuture: Future):
120133
displayableError=downloadAddonFutureException
121134
)
122135
else:
123-
cacheFilePath: Optional[os.PathLike] = downloadAddonFuture.result()
136+
cacheFilePath = downloadAddonFuture.result()
124137

125-
del self._pending[downloadAddonFuture]
126-
del self.progress[addonData]
138+
# If canceled after our previous isCancelled check,
139+
# then _pending and progress will be empty.
140+
self._pending.pop(downloadAddonFuture, None)
141+
self.progress.pop(addonData, None)
127142
self.complete[addonData] = cacheFilePath
128143
onComplete(addonData, cacheFilePath)
129144

130145
def cancelAll(self):
131146
log.debug("Cancelling all")
132147
for f in self._pending.keys():
133148
f.cancel()
149+
assert self._executor
134150
self._executor.shutdown(wait=False)
135151
self._executor = None
136152
self.progress.clear()
137153
self._pending.clear()
138154

139-
def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str) -> bool:
155+
def _downloadAddonToPath(
156+
self,
157+
addonData: "AddonListItemVM[_AddonStoreModel]",
158+
downloadFilePath: str
159+
) -> bool:
140160
"""
141161
@return: True if the add-on is downloaded successfully,
142162
False if the download is cancelled
143163
"""
144164
if not NVDAState.shouldWriteToDisk():
145165
return False
146166

147-
with requests.get(addonData.URL, stream=True) as r:
167+
with requests.get(addonData.model.URL, stream=True) as r:
148168
with open(downloadFilePath, 'wb') as fd:
149169
# Most add-ons are small. This value was chosen quite arbitrarily, but with the intention to allow
150170
# interrupting the download. This is particularly important on a slow connection, to provide
@@ -159,27 +179,28 @@ def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str
159179
if addonData in self.progress: # Removed when the download should be cancelled.
160180
self.progress[addonData] += 1
161181
else:
162-
log.debug(f"Cancelled download: {addonData.addonId}")
182+
log.debug(f"Cancelled download: {addonData.model.addonId}")
163183
return False # The download was cancelled
164184
return True
165185

166-
def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]:
186+
def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[os.PathLike]:
167187
from gui.message import DisplayableError
168188
# Translators: A title for a dialog notifying a user of an add-on download failure.
169189
_addonDownloadFailureMessageTitle = pgettext("addonStore", "Add-on download failure")
170190

191+
addonData = listItem.model
171192
log.debug(f"starting download: {addonData.addonId}")
172193
cacheFilePath = addonData.cachedDownloadPath
173194
if os.path.exists(cacheFilePath):
174195
log.debug(f"Cache file already exists, deleting {cacheFilePath}")
175196
os.remove(cacheFilePath)
176197

177198
inProgressFilePath = addonData.tempDownloadPath
178-
if addonData not in self.progress:
199+
if listItem not in self.progress:
179200
log.debug("the download was cancelled before it started.")
180201
return None # The download was cancelled
181202
try:
182-
if not self._downloadAddonToPath(addonData, inProgressFilePath):
203+
if not self._downloadAddonToPath(listItem, inProgressFilePath):
183204
return None # The download was cancelled
184205
except requests.exceptions.RequestException as e:
185206
log.debugWarning(f"Unable to download addon file: {e}")
@@ -218,7 +239,7 @@ def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]:
218239
return cast(os.PathLike, cacheFilePath)
219240

220241
@staticmethod
221-
def _checkChecksum(addonFilePath: str, addonData: _AddonStoreModel) -> Optional[os.PathLike]:
242+
def _checkChecksum(addonFilePath: str, addonData: _AddonStoreModel) -> bool:
222243
with open(addonFilePath, "rb") as f:
223244
sha256Addon = sha256_checksum(f)
224245
return sha256Addon.casefold() == addonData.sha256.casefold()

source/gui/_addonStoreGui/viewModels/addonList.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from locale import strxfrm
1313
from typing import (
1414
FrozenSet,
15+
Generic,
1516
List,
1617
Optional,
1718
TYPE_CHECKING,
19+
TypeVar,
1820
)
1921

2022
from requests.structures import CaseInsensitiveDict
@@ -92,18 +94,21 @@ class AddonListField(_AddonListFieldData, Enum):
9294
)
9395

9496

95-
class AddonListItemVM:
97+
_AddonModelT = TypeVar("_AddonModelT", bound=_AddonGUIModel)
98+
99+
100+
class AddonListItemVM(Generic[_AddonModelT]):
96101
def __init__(
97102
self,
98-
model: _AddonGUIModel,
103+
model: _AddonModelT,
99104
status: AvailableAddonStatus = AvailableAddonStatus.AVAILABLE
100105
):
101-
self._model: _AddonGUIModel = model # read-only
106+
self._model: _AddonModelT = model # read-only
102107
self._status: AvailableAddonStatus = status # modifications triggers L{updated.notify}
103108
self.updated = extensionPoints.Action() # Notify of changes to VM, argument: addonListItemVM
104109

105110
@property
106-
def model(self) -> _AddonGUIModel:
111+
def model(self) -> _AddonModelT:
107112
return self._model
108113

109114
@property

0 commit comments

Comments
 (0)