src/eric7/MicroPython/CircuitPythonUpdater/CircuitPythonUpdaterInterface.py

branch
eric7
changeset 9740
90072e10ae9b
child 9741
901caff48307
equal deleted inserted replaced
9739:d527cfe919ae 9740:90072e10ae9b
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing an interface to the 'circup' package.
8 """
9
10 import importlib
11 import logging
12 import os
13 import re
14 import shutil
15
16 import requests
17
18 from PyQt6.QtCore import QObject, pyqtSlot
19 from PyQt6.QtWidgets import QDialog, QInputDialog, QLineEdit, QMenu
20
21 from eric7 import Preferences
22 from eric7.EricGui.EricOverrideCursor import EricOverrideCursor
23 from eric7.EricWidgets import EricFileDialog, EricMessageBox
24 from eric7.EricWidgets.EricApplication import ericApp
25 from eric7.EricWidgets.EricListSelectionDialog import EricListSelectionDialog
26 from eric7.SystemUtilities import PythonUtilities
27
28 try:
29 import circup
30
31 circup.logger.setLevel(logging.WARNING)
32 except ImportError:
33 circup = None
34
35
36 class CircuitPythonUpdaterInterface(QObject):
37 """
38 Class implementing an interface to the 'circup' package.
39 """
40
41 def __init__(self, device, parent=None):
42 """
43 Constructor
44
45 @param device reference to the CircuitPython device interface
46 @type CircuitPythonDevice
47 @param parent reference to the parent object (defaults to None)
48 @type QObject (optional)
49 """
50 super().__init__(parent)
51
52 self.__device = device
53
54 self.__installMenu = QMenu(self.tr("Install Modules"))
55 self.__installMenu.setTearOffEnabled(True)
56 self.__installMenu.addAction(
57 self.tr("Select from Available Modules"), self.__installFromAvailable
58 )
59 self.__installMenu.addAction(
60 self.tr("Install Requirements"), self.__installRequirements
61 )
62 self.__installMenu.addAction(
63 self.tr("Install based on 'code.py'"), self.__installFromCode
64 )
65 self.__installMenu.addSeparator()
66 self.__installPyAct = self.__installMenu.addAction(
67 self.tr("Install Python Source")
68 )
69 self.__installPyAct.setCheckable(True)
70 self.__installPyAct.setChecked(False)
71 # kind of hack to make this action not hide the menu
72 # Note: parent menus are hidden nevertheless
73 self.__installPyAct.toggled.connect(self.__installMenu.show)
74
75 def populateMenu(self, menu):
76 """
77 Public method to populate the 'circup' menu.
78
79 @param menu reference to the menu to be populated
80 @type QMenu
81 """
82 from .CircupFunctions import patch_circup
83
84 patch_circup()
85
86 act = menu.addAction(self.tr("circup"), self.__aboutCircup)
87 font = act.font()
88 font.setBold(True)
89 act.setFont(font)
90 menu.addSeparator()
91 menu.addAction(self.tr("List Outdated Modules"), self.__listOutdatedModules)
92 menu.addAction(self.tr("Update Modules"), self.__updateModules)
93 menu.addAction(self.tr("Update All Modules"), self.__updateAllModules)
94 menu.addSeparator()
95 menu.addAction(self.tr("Show Available Modules"), self.__showAvailableModules)
96 menu.addAction(self.tr("Show Installed Modules"), self.__showInstalledModules)
97 menu.addMenu(self.__installMenu)
98 menu.addAction(self.tr("Uninstall Modules"), self.__uninstallModules)
99 menu.addSeparator()
100 menu.addAction(
101 self.tr("Generate Requirements ..."), self.__generateRequirements
102 )
103 menu.addSeparator()
104 menu.addAction(self.tr("Show Bundles"), self.__showBundles)
105 menu.addAction(self.tr("Show Bundles with Modules"), self.__showBundlesModules)
106 menu.addSeparator()
107 menu.addAction(self.tr("Add Bundle"), self.__addBundle)
108 menu.addAction(self.tr("Remove Bundles"), self.__removeBundle)
109
110 @pyqtSlot()
111 def __aboutCircup(self):
112 """
113 Private slot to show some info about 'circup'.
114 """
115 version = circup.get_circup_version()
116 if version is None:
117 version = self.tr("unknown")
118
119 EricMessageBox.information(
120 None,
121 self.tr("About circup"),
122 self.tr(
123 """<p><b>circup Version {0}</b></p>"""
124 """<p><i>circup</i> is a tool to manage and update libraries on a"""
125 """ CircuitPython device.</p>""",
126 ).format(version),
127 )
128
129 @pyqtSlot()
130 def installCircup(self):
131 """
132 Public slot to install the 'circup' package via pip.
133 """
134 global circup
135
136 pip = ericApp().getObject("Pip")
137 pip.installPackages(
138 ["circup"], interpreter=PythonUtilities.getPythonExecutable()
139 )
140
141 circup = importlib.import_module("circup")
142 circup.logger.setLevel(logging.WARNING)
143
144 @pyqtSlot()
145 def __showBundles(self, withModules=False):
146 """
147 Private slot to show the available bundles (default and local).
148
149 @param withModules flag indicating to list the modules and their version
150 (defaults to False)
151 @type bool (optional)
152 """
153 from .ShowBundlesDialog import ShowBundlesDialog
154
155 with EricOverrideCursor():
156 dlg = ShowBundlesDialog(withModules=withModules)
157 dlg.exec()
158
159 @pyqtSlot()
160 def __showBundlesModules(self):
161 """
162 Private slot to show the available bundles (default and local) with their
163 modules.
164 """
165 self.__showBundles(withModules=True)
166
167 @pyqtSlot()
168 def __addBundle(self):
169 """
170 Private slot to add a bundle to the local bundles list, by "user/repo" github
171 string.
172 """
173 bundle, ok = QInputDialog.getText(
174 None,
175 self.tr("Add Bundle"),
176 self.tr("Enter Bundle by 'User/Repo' Github String:"),
177 QLineEdit.EchoMode.Normal,
178 )
179 if ok and bundle:
180 bundles = circup.get_bundles_local_dict()
181 modified = False
182
183 # do some cleanup
184 bundle = re.sub(r"https?://github.com/([^/]+/[^/]+)(/.*)?", r"\1", bundle)
185 if bundle in bundles:
186 EricMessageBox.information(
187 None,
188 self.tr("Add Bundle"),
189 self.tr(
190 """<p>The bundle <b>{0}</b> is already in the list.</p>"""
191 ).format(bundle),
192 )
193 return
194
195 try:
196 cBundle = circup.Bundle(bundle)
197 except ValueError:
198 EricMessageBox.critical(
199 None,
200 self.tr("Add Bundle"),
201 self.tr(
202 """<p>The bundle string is invalid, expecting github URL"""
203 """ or 'user/repository' string.</p>"""
204 ),
205 )
206 return
207
208 result = requests.head("https://github.com/" + bundle)
209 if result.status_code == requests.codes.NOT_FOUND:
210 EricMessageBox.critical(
211 None,
212 self.tr("Add Bundle"),
213 self.tr(
214 """<p>The bundle string is invalid. The repository doesn't"""
215 """ exist (error code 404).</p>"""
216 ),
217 )
218 return
219
220 if not cBundle.validate():
221 EricMessageBox.critical(
222 None,
223 self.tr("Add Bundle"),
224 self.tr(
225 """<p>The bundle string is invalid. Is the repository a valid"""
226 """circup bundle?</p>"""
227 ),
228 )
229 return
230
231 # Use the bundle string as the dictionary key for uniqueness
232 bundles[bundle] = bundle
233 modified = True
234 EricMessageBox.information(
235 None,
236 self.tr("Add Bundle"),
237 self.tr("""<p>Added bundle <b>{0}</b> ({1}).</p>""").format(
238 bundle, cBundle.url
239 ),
240 )
241
242 if modified:
243 # save the bundles list
244 circup.save_local_bundles(bundles)
245 # update and get the new bundle for the first time
246 circup.get_bundle_versions(circup.get_bundles_list())
247
248 @pyqtSlot()
249 def __removeBundle(self):
250 """
251 Private slot to remove one or more bundles from the local bundles list.
252 """
253 localBundles = circup.get_bundles_local_dict()
254 dlg = EricListSelectionDialog(
255 sorted(localBundles.keys()),
256 title=self.tr("Remove Bundles"),
257 message=self.tr("Select the bundles to be removed:"),
258 checkBoxSelection=True,
259 )
260 modified = False
261 if dlg.exec() == QDialog.DialogCode.Accepted:
262 bundles = dlg.getSelection()
263 for bundle in bundles:
264 del localBundles[bundle]
265 modified = True
266
267 if modified:
268 circup.save_local_bundles(localBundles)
269 EricMessageBox.information(
270 None,
271 self.tr("Remove Bundles"),
272 self.tr(
273 """<p>These bundles were removed from the local bundles list.{0}"""
274 """</p>"""
275 ).format("""<ul><li>{0}</li></ul>""".format("</li><li>".join(bundles))),
276 )
277
278 @pyqtSlot()
279 def __listOutdatedModules(self):
280 """
281 Private slot to list the outdated modules of the connected device.
282 """
283 from .ShowOutdatedDialog import ShowOutdatedDialog
284
285 devicePath = self.__device.getWorkspace()
286
287 cpyVersion, board_id = circup.get_circuitpython_version(devicePath)
288 circup.CPY_VERSION = cpyVersion
289
290 with EricOverrideCursor():
291 dlg = ShowOutdatedDialog(devicePath=devicePath)
292 dlg.exec()
293
294 @pyqtSlot()
295 def __updateModules(self):
296 """
297 Private slot to update the modules of the connected device.
298 """
299 from .ShowOutdatedDialog import ShowOutdatedDialog
300
301 devicePath = self.__device.getWorkspace()
302
303 cpyVersion, board_id = circup.get_circuitpython_version(devicePath)
304 circup.CPY_VERSION = cpyVersion
305
306 with EricOverrideCursor():
307 dlg = ShowOutdatedDialog(devicePath=devicePath, selectionMode=True)
308 if dlg.exec() == QDialog.DialogCode.Accepted:
309 modules = dlg.getSelection()
310 self.__doUpdateModules(modules)
311
312 @pyqtSlot()
313 def __updateAllModules(self):
314 """
315 Private slot to update all modules of the connected device.
316 """
317 devicePath = self.__device.getWorkspace()
318
319 cpyVersion, board_id = circup.get_circuitpython_version(devicePath)
320 circup.CPY_VERSION = cpyVersion
321
322 with EricOverrideCursor():
323 modules = [
324 m
325 for m in circup.find_modules(devicePath, circup.get_bundles_list())
326 if m.outofdate
327 ]
328 if modules:
329 self.__doUpdateModules(modules)
330 else:
331 EricMessageBox.information(
332 None,
333 self.tr("Update Modules"),
334 self.tr("All modules are already up-to-date."),
335 )
336
337 def __doUpdateModules(self, modules):
338 """
339 Private method to perform the update of a list of modules.
340
341 @param modules list of modules to be updated
342 @type circup.Module
343 """
344 updatedModules = []
345 for module in modules:
346 try:
347 module.update()
348 updatedModules.append(module.name)
349 except Exception as ex:
350 EricMessageBox.critical(
351 None,
352 self.tr("Update Modules"),
353 self.tr(
354 """<p>There was an error updating <b>{0}</b>.</p>"""
355 """<p>Error: {1}</p>"""
356 ).format(module.name, str(ex)),
357 )
358
359 if updatedModules:
360 EricMessageBox.information(
361 None,
362 self.tr("Update Modules"),
363 self.tr(
364 """<p>These modules were updated on the connected device.{0}</p>"""
365 ).format(
366 """<ul><li>{0}</li></ul>""".format("</li><li>".join(updatedModules))
367 ),
368 )
369 else:
370 EricMessageBox.information(
371 None,
372 self.tr("Update Modules"),
373 self.tr("No modules could be updated."),
374 )
375
376 @pyqtSlot()
377 def __showAvailableModules(self):
378 """
379 Private slot to show the available modules.
380
381 These are modules which could be installed on the device.
382 """
383 from .ShowModulesDialog import ShowModulesDialog
384
385 with EricOverrideCursor():
386 dlg = ShowModulesDialog()
387 dlg.exec()
388
389 @pyqtSlot()
390 def __showInstalledModules(self):
391 """
392 Private slot to show the modules installed on the connected device.
393 """
394 from .ShowInstalledDialog import ShowInstalledDialog
395
396 devicePath = self.__device.getWorkspace()
397
398 with EricOverrideCursor():
399 dlg = ShowInstalledDialog(devicePath=devicePath)
400 dlg.exec()
401
402 @pyqtSlot()
403 def __installFromAvailable(self):
404 """
405 Private slot to install modules onto the connected device.
406 """
407 from .ShowModulesDialog import ShowModulesDialog
408
409 with EricOverrideCursor():
410 dlg = ShowModulesDialog(selectionMode=True)
411 if dlg.exec() == QDialog.DialogCode.Accepted:
412 modules = dlg.getSelection()
413 self.__installModules(modules)
414
415 @pyqtSlot()
416 def __installRequirements(self):
417 """
418 Private slot to install modules determined by a requirements file.
419 """
420 homeDir = (
421 Preferences.getMicroPython("MpyWorkspace")
422 or Preferences.getMultiProject("Workspace")
423 or os.path.expanduser("~")
424 )
425 reqFile = EricFileDialog.getOpenFileName(
426 None,
427 self.tr("Install Modules"),
428 homeDir,
429 self.tr("Text Files (*.txt);;All Files (*)"),
430 )
431 if reqFile:
432 if os.path.exists(reqFile):
433 with open(reqFile, "r") as fp:
434 requirementsText = fp.read()
435 modules = circup.libraries_from_requirements(requirementsText)
436 if modules:
437 self.__installModules(modules)
438 else:
439 EricMessageBox.critical(
440 None,
441 self.tr("Install Modules"),
442 self.tr(
443 """<p>The given requirements file <b>{0}</b> does not"""
444 """ contain valid modules.</p>"""
445 ).format(reqFile),
446 )
447 else:
448 EricMessageBox.critical(
449 None,
450 self.tr("Install Modules"),
451 self.tr(
452 """<p>The given requirements file <b>{0}</b> does not exist."""
453 """</p>"""
454 ).format(reqFile),
455 )
456
457 @pyqtSlot()
458 def __installFromCode(self):
459 """
460 Private slot to install modules based on the 'code.py' file of the
461 connected device.
462 """
463 devicePath = self.__device.getWorkspace()
464
465 codeFile = EricFileDialog.getOpenFileName(
466 None,
467 self.tr("Install Modules"),
468 os.path.join(devicePath, "code.py"),
469 self.tr("Python Files (*.py);;All Files (*)"),
470 )
471 if codeFile:
472 if os.path.exists(codeFile):
473
474 with EricOverrideCursor():
475 availableModules = circup.get_bundle_versions(
476 circup.get_bundles_list()
477 )
478 moduleNames = {}
479 for module, metadata in availableModules.items():
480 moduleNames[module.replace(".py", "")] = metadata
481
482 modules = circup.libraries_from_imports(codeFile, moduleNames)
483 if modules:
484 self.__installModules(modules)
485 else:
486 EricMessageBox.critical(
487 None,
488 self.tr("Install Modules"),
489 self.tr(
490 """<p>The given code file <b>{0}</b> does not"""
491 """ contain valid import statements or does not import"""
492 """ external modules.</p>"""
493 ).format(codeFile),
494 )
495 else:
496 EricMessageBox.critical(
497 None,
498 self.tr("Install Modules"),
499 self.tr(
500 """<p>The given code file <b>{0}</b> does not exist.</p>"""
501 ).format(codeFile),
502 )
503
504 def __installModules(self, installs):
505 """
506 Private method to install the given list of modules.
507
508 @param installs list of module names to be installed
509 @type list of str
510 """
511 devicePath = self.__device.getWorkspace()
512
513 cpyVersion, board_id = circup.get_circuitpython_version(devicePath)
514 circup.CPY_VERSION = cpyVersion
515
516 with EricOverrideCursor():
517 availableModules = circup.get_bundle_versions(circup.get_bundles_list())
518 moduleNames = {}
519 for module, metadata in availableModules.items():
520 moduleNames[module.replace(".py", "")] = metadata
521 toBeInstalled = circup.get_dependencies(installs, mod_names=moduleNames)
522 deviceModules = circup.get_device_versions(devicePath)
523 if toBeInstalled is not None:
524 dependencies = [m for m in toBeInstalled if m not in installs]
525 ok = EricMessageBox.yesNo(
526 None,
527 self.tr("Install Modules"),
528 self.tr("""<p>Ready to install these modules?{0}{1}</p>""").format(
529 """<ul><li>{0}</li></ul>""".format(
530 "</li><li>".join(sorted(installs))
531 ),
532 self.tr("Dependencies:")
533 + """<ul><li>{0}</li></ul>""".format(
534 "</li><li>".join(sorted(dependencies))
535 )
536 if dependencies
537 else "",
538 ),
539 yesDefault=True,
540 )
541 if ok:
542 installedModules = []
543 with EricOverrideCursor():
544 for library in toBeInstalled:
545 success = circup.install_module(
546 devicePath,
547 deviceModules,
548 library,
549 self.__installPyAct.isChecked(),
550 moduleNames,
551 )
552 if success:
553 installedModules.append(library)
554
555 if installedModules:
556 EricMessageBox.information(
557 None,
558 self.tr("Install Modules"),
559 self.tr(
560 "<p>Installation complete. These modules were installed"
561 " successfully.{0}</p>"
562 ).format(
563 """<ul><li>{0}</li></ul>""".format(
564 "</li><li>".join(sorted(installedModules))
565 ),
566 ),
567 )
568 else:
569 EricMessageBox.information(
570 None,
571 self.tr("Install Modules"),
572 self.tr(
573 "<p>Installation complete. No modules were installed.</p>"
574 ),
575 )
576 else:
577 EricMessageBox.information(
578 None,
579 self.tr("Install Modules"),
580 self.tr("<p>No modules installation is required.</p>"),
581 )
582
583 @pyqtSlot()
584 def __uninstallModules(self):
585 """
586 Private slot to uninstall modules from the connected device.
587 """
588 devicePath = self.__device.getWorkspace()
589 libraryPath = os.path.join(devicePath, "lib")
590
591 with EricOverrideCursor():
592 deviceModules = circup.get_device_versions(devicePath)
593 modNames = {}
594 for moduleItem, metadata in deviceModules.items():
595 modNames[moduleItem.replace(".py", "").lower()] = metadata
596
597 dlg = EricListSelectionDialog(
598 sorted(modNames.keys()),
599 title=self.tr("Uninstall Modules"),
600 message=self.tr("Select the modules/packages to be uninstalled:"),
601 checkBoxSelection=True,
602 )
603 if dlg.exec() == QDialog.DialogCode.Accepted:
604 names = dlg.getSelection()
605 for name in names:
606 modulePath = modNames[name]["path"]
607 if os.path.isdir(modulePath):
608 target = os.path.basename(os.path.dirname(modulePath))
609 targetPath = os.path.join(libraryPath, target)
610 # Remove the package directory.
611 shutil.rmtree(targetPath)
612 else:
613 target = os.path.basename(modulePath)
614 targetPath = os.path.join(libraryPath, target)
615 # Remove the module file
616 os.remove(targetPath)
617
618 EricMessageBox.information(
619 None,
620 self.tr("Uninstall Modules"),
621 self.tr(
622 """<p>These modules/packages were uninstalled from the connected"""
623 """ device.{0}</p>"""
624 ).format("""<ul><li>{0}</li></ul>""".format("</li><li>".join(names))),
625 )
626
627 @pyqtSlot()
628 def __generateRequirements(self):
629 """
630 Private slot to generate requirements for the connected device.
631 """
632 from .RequirementsDialog import RequirementsDialog
633
634 devicePath = self.__device.getWorkspace()
635
636 cpyVersion, board_id = circup.get_circuitpython_version(devicePath)
637 circup.CPY_VERSION = cpyVersion
638
639 dlg = RequirementsDialog(devicePath=devicePath)
640 dlg.exec()
641
642
643 def isCircupAvailable():
644 """
645 Function to check for the availability of 'circup'.
646
647 @return flag indicating the availability of 'circup'
648 @rtype bool
649 """
650 global circup
651
652 return circup is not None

eric ide

mercurial