|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a MicroPython package installer for devices missing the onboard |
|
8 'mip' package. |
|
9 """ |
|
10 |
|
11 import json |
|
12 |
|
13 from PyQt6.QtCore import QEventLoop, QObject, QUrl |
|
14 from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest |
|
15 |
|
16 from eric7.EricNetwork.EricNetworkProxyFactory import proxyAuthenticationRequired |
|
17 |
|
18 MicroPythonPackageIndex = "https://micropython.org/pi/v2" |
|
19 |
|
20 |
|
21 class MipLocalInstaller(QObject): |
|
22 """ |
|
23 Class implementing a MicroPython package installer ('mip' replacement). |
|
24 """ |
|
25 |
|
26 def __init__(self, device, parent=None): |
|
27 """ |
|
28 Constructor |
|
29 |
|
30 @param device reference to the connected device |
|
31 @type BaseDevice |
|
32 @param parent reference to the parent object (defaults to None) |
|
33 @type QObject (optional) |
|
34 """ |
|
35 super().__init__(parent) |
|
36 |
|
37 self.__device = device |
|
38 self.__error = "" |
|
39 |
|
40 self.__networkManager = QNetworkAccessManager(self) |
|
41 self.__networkManager.proxyAuthenticationRequired.connect( |
|
42 proxyAuthenticationRequired |
|
43 ) |
|
44 |
|
45 self.__loop = QEventLoop() |
|
46 self.__networkManager.finished.connect(self.__loop.quit) |
|
47 |
|
48 def __rewriteUrl(self, url, branch=None): |
|
49 """ |
|
50 Private method to rewrite the given URL in case of a Github URL. |
|
51 |
|
52 @param url URL to be checked and potentially changed |
|
53 @type str |
|
54 @param branch branch name (defaults to None) |
|
55 @type str (optional) |
|
56 @return rewritten URL |
|
57 @rtype str |
|
58 """ |
|
59 if url.startswith("github:"): |
|
60 urlList = url[7:].split("/") |
|
61 if branch is None: |
|
62 branch = "HEAD" |
|
63 url = ( |
|
64 "https://raw.githubusercontent.com/" |
|
65 + urlList[0] |
|
66 + "/" |
|
67 + urlList[1] |
|
68 + "/" |
|
69 + branch |
|
70 + "/" |
|
71 + "/".join(urlList[2:]) |
|
72 ) |
|
73 |
|
74 return url |
|
75 |
|
76 def __getFile(self, fileUrl): |
|
77 """ |
|
78 Private method to download the requested file. |
|
79 |
|
80 @param fileUrl URL of the requested file |
|
81 @type QUrl |
|
82 @return package data or an error message and a success flag |
|
83 @rtype tuple of (bytes or str, bool) |
|
84 """ |
|
85 request = QNetworkRequest(fileUrl) |
|
86 reply = self.__networkManager.get(request) |
|
87 if not self.__loop.isRunning(): |
|
88 self.__loop.exec() |
|
89 if reply.error() != QNetworkReply.NetworkError.NoError: |
|
90 return reply.errorString(), False |
|
91 else: |
|
92 return bytes(reply.readAll()), True |
|
93 |
|
94 def __installFile(self, fileUrl, targetDir, targetFile): |
|
95 """ |
|
96 Private method to download a file and copy the data to the given target |
|
97 directory. |
|
98 |
|
99 @param fileUrl URL of the file to be downloaded and installed |
|
100 @type str |
|
101 @param targetDir target directory on the device |
|
102 @type str |
|
103 @param targetFile file name on the device |
|
104 @type str |
|
105 @return flag indicating success |
|
106 @rtype bool |
|
107 """ |
|
108 fileData, ok = self.__getFile(fileUrl) |
|
109 if not ok: |
|
110 self.__error = fileData |
|
111 return False |
|
112 |
|
113 try: |
|
114 targetFilePath = "{0}/{1}".format(targetDir, targetFile) |
|
115 self.__device.ensurePath(targetFilePath.rsplit("/", 1)[0]) |
|
116 self.__device.putData(targetFilePath, fileData) |
|
117 except OSError as err: |
|
118 self.__error = err |
|
119 return False |
|
120 |
|
121 return True |
|
122 |
|
123 def __installJson(self, packageJson, version, mpy, target, index): |
|
124 """ |
|
125 Private method to install a package and its dependencies as defined by the |
|
126 package JSON file. |
|
127 |
|
128 @param packageJson dictionary containing the package data |
|
129 @type dict |
|
130 @param version package version |
|
131 @type str |
|
132 @param mpy flag indicating to install as '.mpy' file |
|
133 @type bool |
|
134 @param target target directory on the device |
|
135 @type str |
|
136 @param index URL of the package index to be used |
|
137 @type str |
|
138 @return flag indicating success |
|
139 @rtype bool |
|
140 """ |
|
141 for targetFile, shortHash in packageJson.get("hashes", ()): |
|
142 fileUrl = QUrl("{0}/file/{1}/{2}".format(index, shortHash[:2], shortHash)) |
|
143 if not self.__installFile(fileUrl, target, targetFile): |
|
144 return False |
|
145 |
|
146 for targetFile, url in packageJson.get("urls", ()): |
|
147 if not self.__installFile( |
|
148 self.__rewriteUrl(url, branch=version), target, targetFile |
|
149 ): |
|
150 return False |
|
151 |
|
152 for dependency, dependencyVersion in packageJson.get("deps", ()): |
|
153 self.installPackage(dependency, dependencyVersion, mpy, target=target) |
|
154 |
|
155 return True |
|
156 |
|
157 def installPackage(self, package, index=None, target=None, version=None, mpy=True): |
|
158 """ |
|
159 Public method to install a MicroPython package. |
|
160 |
|
161 @param package package name |
|
162 @type str |
|
163 @param index URL of the package index to be used (defaults to None) |
|
164 @type str (optional) |
|
165 @param target target directory on the device (defaults to None) |
|
166 @type str (optional) |
|
167 @param version package version (defaults to None) |
|
168 @type str (optional) |
|
169 @param mpy flag indicating to install as '.mpy' file (defaults to True) |
|
170 @type bool (optional) |
|
171 @return flag indicating success |
|
172 @rtype bool |
|
173 """ |
|
174 self.__error = "" |
|
175 |
|
176 if not bool(index): |
|
177 index = MicroPythonPackageIndex |
|
178 index = index.rstrip("/") |
|
179 |
|
180 if not target: |
|
181 libPaths = self.__device.getLibPaths() |
|
182 if libPaths and libPaths[0]: |
|
183 target = libPaths[0] |
|
184 else: |
|
185 self.__error = self.tr( |
|
186 "Unable to find 'lib' in sys.path. Please enter a target." |
|
187 ) |
|
188 return False |
|
189 |
|
190 if package.startswith(("http://", "https://", "github:")): |
|
191 if package.endswith(".py") or package.endswith(".mpy"): |
|
192 return self.__installFile( |
|
193 self.__rewriteUrl(package, version), |
|
194 target, |
|
195 package.rsplit("/", 1)[-1], |
|
196 ) |
|
197 else: |
|
198 if not package.endswith(".json"): |
|
199 if not package.endswith("/"): |
|
200 package += "/" |
|
201 package += "package.json" |
|
202 else: |
|
203 if not version: |
|
204 version = "latest" |
|
205 |
|
206 mpyVersion = "py" |
|
207 if mpy and self.__device.getDeviceData("mpy_file_version") > 0: |
|
208 mpyVersion = self.__device.getDeviceData("mpy_file_version") |
|
209 |
|
210 packageJsonUrl = QUrl( |
|
211 "{0}/package/{1}/{2}/{3}.json".format( |
|
212 index, mpyVersion, package, version |
|
213 ) |
|
214 ) |
|
215 |
|
216 jsonData, ok = self.__getFile(packageJsonUrl) |
|
217 if not ok: |
|
218 self.__error = jsonData |
|
219 return False |
|
220 |
|
221 try: |
|
222 packageJson = json.loads(jsonData.decode("utf-8")) |
|
223 except json.JSONDecodeError as err: |
|
224 self.__error = str(err) |
|
225 return False |
|
226 |
|
227 ok = self.__installJson(packageJson, version, mpy, target, index) |
|
228 if not ok: |
|
229 self.__error += self.tr("\n\nPackage may be partially installed.") |
|
230 |
|
231 return ok |
|
232 |
|
233 def errorString(self): |
|
234 """ |
|
235 Public method to get the last error as a string. |
|
236 |
|
237 @return latest error |
|
238 @rtype str |
|
239 """ |
|
240 return self.__error |