src/eric7/MicroPython/Devices/CircuitPythonUpdater/CircuitPythonUpdaterInterface.py

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

eric ide

mercurial