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+
610from concurrent .futures import (
711 Future ,
812 ThreadPoolExecutor ,
2832from utils .security import sha256_checksum
2933
3034from .models .addon import (
31- AddonStoreModel ,
3235 _AddonGUIModel ,
3336 _AddonStoreModel ,
3437)
3740
3841if 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
6367class 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 ()
0 commit comments