source: orange/Orange/orng/updateOrange.py @ 10674:ac200cc97b30

Revision 10674:ac200cc97b30, 24.0 KB checked in by Lan Zagar <lan.zagar@…>, 2 years ago (diff)

Replaced split('\n') with splitlines().

Line 
1#import orngOrangeFoldersQt4
2from PyQt4.QtCore import *
3from PyQt4.QtGui import *
4import os, re, urllib, sys
5import md5, cPickle
6
7# This is Orange Update program. It can check on the web if there are any updates available and download them.
8# User can select a list of folders that he wants to update and a list of folders that he wants to ignore.
9# In case that a file was locally changed and a new version of the same file is available, the program offers the user
10# to update to the new file or to keep the old one.
11#
12
13defaultIcon = ['16 13 5 1', '. c #040404', '# c #808304', 'a c None', 'b c #f3f704', 'c c #f3f7f3',  'aaaaaaaaa...aaaa',  'aaaaaaaa.aaa.a.a',  'aaaaaaaaaaaaa..a',
14    'a...aaaaaaaa...a', '.bcb.......aaaaa', '.cbcbcbcbc.aaaaa', '.bcbcbcbcb.aaaaa', '.cbcb...........', '.bcb.#########.a', '.cb.#########.aa', '.b.#########.aaa', '..#########.aaaa', '...........aaaaa']
15
16CONFLICT_ASK = 0
17CONFLICT_OVERWRITE = 1
18CONFLICT_KEEP = 2
19
20def splitDirs(path):
21    dirs, filename = os.path.split(path)
22    listOfDirs = []
23    while dirs != "":
24        dirs, dir = os.path.split(dirs)
25        listOfDirs.insert(0, dir)
26    return listOfDirs
27
28class OptionsDlg(QDialog):
29    def __init__(self, settings):
30        QDialog.__init__(self, None)
31        self.setWindowTitle("Update Options")
32
33        self.setLayout(QVBoxLayout())
34
35        self.groupBox = QGroupBox("Updating Options", self)
36        self.layout().addWidget(self.groupBox)
37        self.check1 = QCheckBox("Update scripts", self.groupBox)
38        self.check2 = QCheckBox("Update binary files", self.groupBox)
39        self.check3 = QCheckBox("Download new files", self.groupBox)
40        self.groupBox.setLayout(QVBoxLayout())
41        for c in [self.check1, self.check2, self.check3]:
42            self.groupBox.layout().addWidget(c)
43
44        self.groupBox2 = QGroupBox("Solving Conflicts", self)
45        self.groupBox2.setLayout(QVBoxLayout())
46        self.layout().addWidget(self.groupBox2)
47        label = QLabel("When your local file was edited\nand a newer version is available...", self.groupBox2)
48        self.groupBox2.layout().addWidget(label)
49        self.combo = QComboBox(self.groupBox2)
50        for s in ["Ask what to do", "Overwrite your local copy with new file", "Keep your local file"]:
51            self.combo.addItem(s)
52        self.groupBox2.layout().addWidget(self.combo)
53
54        self.check1.setChecked(settings["scripts"])
55        self.check2.setChecked(settings["binary"])
56        self.check3.setChecked(settings["new"])
57        self.combo.setCurrentIndex(settings["conflicts"])
58
59        widget = QWidget(self)
60        self.layout().addWidget(widget)
61        widget.setLayout(QHBoxLayout())
62        widget.layout().addStretch(1)
63        okButton = QPushButton('OK', widget)
64        widget.layout().addWidget(okButton)
65##        self.topLayout.addWidget(okButton)
66        self.connect(okButton, SIGNAL('clicked()'),self,SLOT('accept()'))
67        cancelButton = QPushButton('Cancel', widget)
68        widget.layout().addWidget(cancelButton)
69##        self.topLayout.addWidget(cancelButton)
70        self.connect(cancelButton, SIGNAL('clicked()'),self,SLOT('reject()'))
71
72    def accept(self):
73        self.settings = {"scripts": self.check1.isChecked(), "binary": self.check2.isChecked(), "new": self.check3.isChecked(), "conflicts": self.combo.currentIndex()}
74        QDialog.accept(self)
75
76
77
78class FoldersDlg(QDialog):
79    def __init__(self, caption):
80        QDialog.__init__(self, None)
81
82        self.setLayout(QVBoxLayout())
83
84        self.groupBox = QGroupBox(self)
85        self.layout().addWidget(self.groupBox)
86        self.groupBox.setTitle(" " + caption.strip() + " ")
87        self.groupBox.setLayout(QVBoxLayout())
88        self.groupBoxLayout.setMargin(20)
89
90        self.setWindowCaption("Select Folders")
91        self.resize(300,100)
92
93        self.folders = []
94        self.checkBoxes = []
95
96    def addCategory(self, text, checked = 1, indent = 0):
97        widget = QWidget(self.groupBox)
98        self.groupBox.layout().addWidget(widget)
99        hboxLayout = QHBoxLayout()
100        widget.setLayout(hboxLayout)
101
102        if indent:
103            sep = QWidget(widget)
104            sep.setFixedSize(19, 8)
105            hboxLayout.addWidget(sep)
106        check = QCheckBox(text, widget)
107        hboxLayout.addWidget(check)
108
109        check.setChecked(checked)
110        self.checkBoxes.append(check)
111        self.folders.append(text)
112
113    def addLabel(self, text):
114        label = QLabel(text, self.groupBox)
115        self.groupBox.layout().addWidget(label)
116
117
118    def finishedAdding(self, ok = 1, cancel = 1):
119        widget = QWidget(self)
120        self.layout().addWidget(widget)
121        widgetLayout = QHBoxLayout(widget)
122        widget.setLayout(widgetLayout)
123        widgetLayout.addStretch(1)
124
125        if ok:
126            okButton = QPushButton('OK', widget)
127            widgetLayout.addWidget(okButton)
128            self.connect(okButton, SIGNAL('clicked()'),self,SLOT('accept()'))
129        if cancel:
130            cancelButton = QPushButton('Cancel', widget)
131            widgetLayout.addWidget(cancelButton)
132            self.connect(cancelButton, SIGNAL('clicked()'),self,SLOT('reject()'))
133
134class updateOrangeDlg(QMainWindow):
135    def __init__(self,*args):
136        QMainWindow.__init__(self, *args)
137        self.resize(600,600)
138        self.setWindowTitle("Orange Update")
139
140        self.toolbar = self.addToolBar("Toolbar")
141
142        self.text = QTextEdit(self)
143        self.text.setReadOnly(1)
144        self.text.zoomIn(2)
145        self.setCentralWidget(self.text)
146        self.statusBar = QStatusBar(self)
147        self.setStatusBar(self.statusBar)
148        self.statusBar.showMessage('Ready')
149
150        import updateOrange
151        self.orangeDir = os.path.split(os.path.abspath(updateOrange.__file__))[0]
152        os.chdir(self.orangeDir)        # we have to set the current dir to orange dir since we can call update also from orange canvas
153
154        self.settings = {"scripts":1, "binary":1, "new":1, "conflicts":0}
155        if os.path.exists("updateOrange.set"):
156            file = open("updateOrange.set", "r")
157            self.settings = cPickle.load(file)
158            file.close()
159
160        self.re_vLocalLine = re.compile(r'(?P<fname>.*)=(?P<version>[.0-9]*)(:?)(?P<md5>.*)')
161        self.re_vInternetLine = re.compile(r'(?P<fname>.*)=(?P<version>[.0-9]*)(:?)(?P<location>.*)')
162        self.re_widget = re.compile(r'(?P<category>.*)[/,\\].*')
163        self.re_documentation = re.compile(r'doc[/,\\].*')
164
165        self.downfile = os.path.join(self.orangeDir, "whatsdown.txt")
166
167        self.updateUrl = "http://orange.biolab.si/download/update/"
168        self.binaryUrl = "http://orange.biolab.si/download/binaries/%i%i/" % sys.version_info[:2]
169        self.whatsupUrl = "http://orange.biolab.si/download/whatsup.txt"
170
171        self.updateGroups = []
172        self.dontUpdateGroups = []
173        self.newGroups = []
174        self.downstuff = {}
175
176        # read updateGroups and dontUpdateGroups
177        self.addText("Welcome to the Orange update.")
178        try:
179            vf = open(self.downfile)
180            self.downstuff, self.updateGroups, self.dontUpdateGroups = self.readLocalVersionFile(vf.readlines(), updateGroups = 1)
181            vf.close()
182        except:
183            pass
184        self.addText("To download latest versions of files click the 'Update' button.", nobr = 0)
185
186        # create buttons
187        iconsDir = os.path.join(self.orangeDir, "OrangeCanvas/icons")
188        self.updateIcon = os.path.join(iconsDir, "update.png")
189        self.foldersIcon = os.path.join(iconsDir, "folders.png")
190        self.optionsIcon = os.path.join(iconsDir, "options.png")
191        if not os.path.exists(self.updateIcon): self.updateIcon = defaultIcon
192        if not os.path.exists(self.foldersIcon): self.foldersIcon = defaultIcon
193        if not os.path.exists(self.optionsIcon): self.optionsIcon = defaultIcon
194
195        def createButton(text, icon, callback, tooltip):
196            b = QToolButton(self.toolbar)
197            self.toolbar.layout().addWidget(b)
198            b.setIcon(icon)
199            b.setText(text)
200            self.connect(b, SIGNAL("clicked()"), callback)
201            b.setToolTip(tooltip)
202
203        self.toolUpdate  = self.toolbar.addAction(QIcon(self.updateIcon), "Update" , self.executeUpdate)
204        self.toolbar.addSeparator()
205        self.toolFolders = self.toolbar.addAction(QIcon(self.foldersIcon), "Folders" , self.showFolders)
206        self.toolOptions = self.toolbar.addAction(QIcon(self.optionsIcon), "Options" , self.showOptions)
207
208        self.setWindowIcon(QIcon(self.updateIcon))
209        self.move((qApp.desktop().width()-self.width())/2, (qApp.desktop().height()-self.height())/2)   # center the window
210        self.show()
211
212
213    # ####################################
214    # show the list of possible folders
215    def showFolders(self):
216        self.updateGroups = []
217        self.dontUpdateGroups = []
218        try:
219            vf = open(self.downfile)
220            self.downstuff, self.updateGroups, self.dontUpdateGroups = self.readLocalVersionFile(vf.readlines(), updateGroups = 1)
221            vf.close()
222        except:
223            self.addText("Failed to locate file 'whatsdown.txt'. There is no information on current versions of Orange files. By clicking 'Update files' you will download the latest versions of files.", nobr = 0)
224            return
225
226        groups = [(name, 1) for name in self.updateGroups] + [(name, 0) for name in self.dontUpdateGroups]
227        groups.sort()
228        groupDict = dict(groups)
229
230        dlg = FoldersDlg("Select Orange folders that you wish to update")
231        dlg.setWindowIcon(QIcon(self.foldersIcon))
232
233        dlg.addCategory("Orange Canvas", groupDict.get("Orange Canvas", 1))
234        dlg.addCategory("Documentation", groupDict.get("Documentation", 1))
235        dlg.addCategory("Orange Root", groupDict.get("Orange Root", 1))
236        dlg.addLabel("Orange Widgets:")
237        for (group, sel) in groups:
238            if group in ["Orange Canvas", "Documentation", "Orange Root"]: continue
239            dlg.addCategory(group, sel, indent = 1)
240
241        dlg.finishedAdding(cancel = 1)
242        dlg.move((qApp.desktop().width()-dlg.width())/2, (qApp.desktop().height()-400)/2)   # center dlg window
243
244        res = dlg.exec_()
245        if res == QDialog.Accepted:
246            self.updateGroups = []
247            self.dontUpdateGroups = []
248            for i in range(len(dlg.checkBoxes)):
249                if dlg.checkBoxes[i].isChecked(): self.updateGroups.append(dlg.folders[i])
250                else:                             self.dontUpdateGroups.append(dlg.folders[i])
251            self.writeVersionFile()
252        return
253
254    def showOptions(self):
255        dlg = OptionsDlg(self.settings)
256        dlg.setWindowIcon(QIcon(self.optionsIcon))
257        res = dlg.exec_()
258        if res == QDialog.Accepted:
259            self.settings = dlg.settings
260
261    def readLocalVersionFile(self, data, updateGroups = 1):
262        versions = {}
263        updateGroups = []; dontUpdateGroups = []
264        for line in data:
265            if not line: continue
266            line = line.replace("\r", "")   # replace \r in case of linux files
267            line = line.replace("\n", "")
268            if not line: continue
269
270            if line[0] == "+":
271                updateGroups.append(line[1:])
272            elif line[0] == "-":
273                dontUpdateGroups.append(line[1:])
274            else:
275                fnd = self.re_vLocalLine.match(line)
276                if fnd:
277                    fname, version, md = fnd.group("fname", "version", "md5")
278                    fname = fname.replace("\\", "/")
279                    versions[fname] = ([int(x) for x in version.split(".")], md)
280
281                    # add widget category if not already in updateGroups
282                    dirs = splitDirs(fname)
283                    if len(dirs) >= 2 and dirs[0].lower() == "orangewidgets" and dirs[1] not in updateGroups + dontUpdateGroups and dirs[1].lower() != "icons":
284                        updateGroups.append(dirs[1])
285                    if len(dirs) >= 1 and dirs[0].lower() == "doc" and "Documentation" not in updateGroups + dontUpdateGroups: updateGroups.append("Documentation")
286                    if len(dirs) >= 1 and dirs[0].lower() == "orangecanvas" and "Orange Canvas" not in updateGroups + dontUpdateGroups: updateGroups.append("Orange Canvas")
287                    if len(dirs) == 1 and "Orange Root" not in updateGroups + dontUpdateGroups: updateGroups.append("Orange Root")
288
289        return versions, updateGroups, dontUpdateGroups
290
291    def readInternetVersionFile(self, updateGroups = 1):
292        try:
293            f = urllib.urlopen(self.whatsupUrl)
294        except IOError:
295            self.addText('Unable to download current status file. Check your internet connection.')
296            return {}, [], []
297
298        data = f.read().splitlines()
299        versions = {}
300        updateGroups = []; dontUpdateGroups = []
301        for line in data:
302            if not line: continue
303            line = line.replace("\r", "")   # replace \r in case of linux files
304            line = line.replace("\n", "")
305            if not line: continue
306
307            if line[0] == "+":
308                updateGroups.append(line[1:])
309            elif line[0] == "-":
310                dontUpdateGroups.append(line[1:])
311            else:
312                fnd = self.re_vInternetLine.match(line)
313                if fnd:
314                    fname, version, location = fnd.group("fname", "version", "location")
315                    fname = fname.replace("\\", "/")
316                    versions[fname] = ([int(x) for x in version.split(".")], location)
317
318                    # add widget category if not already in updateGroups
319                    dirs = splitDirs(fname)
320                    if len(dirs) >= 2 and dirs[0].lower() == "orangewidgets" and dirs[1] not in updateGroups and dirs[1].lower() != "icons":
321                        updateGroups.append(dirs[1])
322
323        return versions, updateGroups, dontUpdateGroups
324
325    def writeVersionFile(self):
326        vf = open(self.downfile, "wt")
327        itms = self.downstuff.items()
328        itms.sort(lambda x,y:cmp(x[0], y[0]))
329
330        for g in self.dontUpdateGroups:
331            vf.write("-%s\n" % g)
332        for fname, (version, md) in itms:
333            vf.write("%s=%s:%s\n" % (fname, reduce(lambda x,y:x+"."+y, [`x` for x in version]), md))
334        vf.close()
335
336
337    def executeUpdate(self):
338        updatedFiles = 0
339        newFiles = 0
340
341        if self.settings["scripts"]:
342            self.addText("Reading file status from web server")
343
344            self.updateGroups = [];  self.dontUpdateGroups = []; self.newGroups = []
345            self.downstuff = {}
346
347            upstuff, upUpdateGroups, upDontUpdateGroups = self.readInternetVersionFile(updateGroups = 0)
348            if upstuff == {}: return
349            try:
350                vf = open(self.downfile)
351                self.addText("Reading local file status")
352                self.downstuff, self.updateGroups, self.dontUpdateGroups = self.readLocalVersionFile(vf.readlines(), updateGroups = 1)
353                vf.close()
354            except:
355                res = QMessageBox.information(self, 'Update Orange', "The 'whatsdown.txt' file if missing (most likely because you downloaded Orange from CVS).\nThis file contains information about versions of your local Orange files.\n\nIf you press 'Replace Local Files' you will not replace only updated files, but will \noverwrite all your local Orange files with the latest versions from the web.\n", 'Replace Local Files', "Cancel", "", 0, 1)
356                if res != 0: return
357
358            itms = upstuff.items()
359            itms.sort(lambda x,y:cmp(x[0], y[0]))
360
361            for category in upUpdateGroups: #+ upDontUpdateGroups:
362                if category not in self.updateGroups + self.dontUpdateGroups:
363                    self.newGroups.append(category)
364
365            # show dialog with new groups
366            if self.newGroups != []:
367                dlg = FoldersDlg("Select new categories you wish to download")
368                dlg.setWindowIcon(QIcon(self.foldersIcon))
369                for group in self.newGroups: dlg.addCategory(group)
370                dlg.finishedAdding(cancel = 0)
371
372                res = dlg.exec_()
373                for i in range(len(dlg.checkBoxes)):
374                    if dlg.checkBoxes[i].isChecked():
375                        self.updateGroups.append(dlg.folders[i])
376                    else:
377                        self.dontUpdateGroups.append(dlg.folders[i])
378                self.newGroups = []
379
380            # update new files
381            self.addText("Updating scripts...")
382            self.statusBar.showMessage("Updating scripts")
383
384            for fname, (version, location) in itms:
385                qApp.processEvents()
386
387                # check if it is a widget directory that we don't want to update
388                dirs = splitDirs(fname)
389                if len(dirs) >= 2 and dirs[0].lower() == "orangewidgets" and dirs[1] in self.dontUpdateGroups: continue
390                if len(dirs) >= 1 and dirs[0].lower() == "doc" and "Documentation" in self.dontUpdateGroups: continue
391                if len(dirs) >= 1 and dirs[0].lower() == "orangecanvas" and "Orange Canvas" in self.dontUpdateGroups: continue
392                if len(dirs) == 1 and "Orange Root" in self.dontUpdateGroups: continue
393
394                if os.path.exists(fname) and self.downstuff.has_key(fname) and self.downstuff[fname][0] < upstuff[fname][0]:      # there is a newer version
395                    updatedFiles += self.updatefile(self.updateUrl + fname, location, version, self.downstuff[fname][1], "Updating")
396                elif not os.path.exists(fname) or not self.downstuff.has_key(fname):
397                    if self.settings["new"]:
398                        updatedFiles += self.updatefile(self.updateUrl + fname, location, version, "", "Downloading new file")
399                    else:
400                        self.addText("Skipping new file %s" % (fname))
401            self.writeVersionFile()
402        else:
403            self.addText("Skipping updating scripts...")
404
405        if self.settings["binary"]:
406            self.addText("Updating binaries...")
407            updatedFiles += self.updatePyd()
408        else:
409            self.addText("Skipping updateing binaries...")
410
411        self.addText("Update finished. New files: <b>%d</b>. Updated files: <b>%d</b>\n" %(newFiles, updatedFiles))
412
413        self.statusBar.showMessage("Update finished.")
414
415    # update binary files
416    def updatePyd(self):
417        files = "orange", "corn", "statc", "orangeom", "orangene", "_orngCRS"
418
419        baseurl = "http://orange.biolab.si/download/binaries/%i%i/" % sys.version_info[:2]
420        repository_stamps = dict([tuple(x.split()) for x in urllib.urlopen(baseurl + "stamps_pyd.txt") if x.strip()])
421        updated = 0
422
423        for fle in files:
424            if not os.path.exists(fle+".pyd") or repository_stamps[fle+".pyd"] != md5.md5(file(fle+".pyd", "rb").read()).hexdigest().upper():
425                updated += self.updatefile(baseurl + fle + ".pyd", fle + ".pyd", "", "", "Updating")
426        return updated
427
428    # #########################################################
429    # get new file from the internet and overwrite the old file
430    # webName = complete path to the file on the web
431    # localName = path and name of the file on the local disk
432    # version = the newest file version
433    # md = hash value of the local file when it was downloaded from the internet - needed to compare if the user has changed the local version of the file
434    def updatefile(self, webName, localName, version, md, type = "Downloading"):
435        self.addText(type + " %s ... " % localName, addBreak = 0)
436        qApp.processEvents()
437
438        try:
439            urllib.urlretrieve(webName, localName + ".temp", self.updateDownloadStatus)
440        except IOError, inst:
441            self.addText('<font color="#FF0000">Failed</font> (%s)' % (inst[1]))
442            return 0
443
444        self.statusBar.showMessage("")
445        dname = os.path.dirname(localName)
446        if dname and not os.path.exists(dname):
447            os.makedirs(dname)
448
449        isBinaryFile = localName[-3:].lower() in ["pyd"]
450        if not isBinaryFile:
451            # read existing file
452            if md != "" and os.path.exists(localName):
453                currmd = self.computeFileMd(localName)
454                if currmd.hexdigest() != md:   # the local file has changed
455                    if self.settings["conflicts"] == CONFLICT_OVERWRITE:
456                        res = 0
457                    elif self.settings["conflicts"] == CONFLICT_KEEP:
458                        res = 1
459                    elif self.settings["conflicts"] == CONFLICT_ASK:
460                        res = QMessageBox.information(self,'Update Orange',"Your local file '%s' was edited, but a newer version of this file is available on the web.\nDo you wish to overwrite local copy with newest version (a backup of current file will be created) or keep your current file?" % (os.path.split(localName)[1]), 'Overwrite with newest', 'Keep current file')
461
462                    if res == 0:    # overwrite
463                        currmd = self.computeFileMd(localName+".temp")
464                        try:
465                            ext = ".bak"
466                            if os.path.exists(localName + ext):
467                                i = 1
468                                while os.path.exists(localName + ext + str(i)): i += 1
469                                ext = ext+str(i)
470                            os.rename(localName, localName + ext)  # create backup
471                        except OSError, inst:
472                            self.addText('<font color="#FF0000">Failed</font> (%s)' % (inst[1]))
473                            self.addText('Unable to update file <font color="#FF0000">%s</font>. Please close all programs that are using it.' % (os.path.split(localName)[1]))
474                            return 0
475                    elif res == 1:    # keep local
476                        self.addText('<font color="#0000FF">Skipping</font>')
477                        return 0
478            else:
479                currmd = self.computeFileMd(localName + ".temp")
480
481        try:
482            if os.path.exists(localName):
483                os.remove(localName)
484            os.rename(localName + ".temp", localName)
485            if not isBinaryFile:
486                self.downstuff[localName[2:]] = (version, currmd.hexdigest())       # remove "./" from localName
487            self.addText('<font color="#0000FF">OK</font>')
488            return 1
489        except OSError, inst:
490            self.addText('<font color="#FF0000">Failed</font> (%s)' % (inst[1]))
491            self.addText('Unable to update file <font color="#FF0000">%s</font>. Please close all programs that are using it.' % (os.path.split(localName)[1]))
492            return 0
493
494    # show percent of finished download
495    def updateDownloadStatus(self, blk_cnt, blk_size, tot_size):
496        self.statusBar.showMessage("Downloaded %.1f%%" % (100*min(tot_size, blk_cnt*blk_size) / (tot_size or 1)))
497
498    def computeFileMd(self, fname):
499        f = open(fname, "rb")
500        md = md5.new()
501        md.update(f.read())
502        f.close()
503        return md
504
505    def addText(self, text, nobr = 1, addBreak = 1):
506        cursor = QTextCursor(self.text.textCursor())                # clear the current text selection so that
507        cursor.movePosition(QTextCursor.End, QTextCursor.MoveAnchor)      # the text will be appended to the end of the
508        self.text.setTextCursor(cursor)                             # existing text
509               
510        if nobr: self.text.insertHtml('<nobr>' + text + '</nobr>')
511        else:    self.text.insertHtml(text)
512       
513        cursor.movePosition(QTextCursor.End, QTextCursor.MoveAnchor)      # and then scroll down to the end of the text
514        self.text.setTextCursor(cursor)
515        if addBreak: self.text.insertHtml("<br>")
516        self.text.verticalScrollBar().setValue(self.text.verticalScrollBar().maximum())
517
518    def keyPressEvent(self, e):
519        if e.key() == Qt.Key_Escape:
520            self.close()
521        else: QMainWindow.keyPressEvent(self, e)
522
523    def closeEvent(self, e):
524        f = open("updateOrange.set", "wt")
525        cPickle.dump(self.settings, f)
526        f.close()
527        QMainWindow.closeEvent(self, e)
528
529
530# show application dlg
531if __name__ == "__main__":
532    app = QApplication(sys.argv)
533    dlg = updateOrangeDlg()
534    dlg.show()
535    app.exec_()
Note: See TracBrowser for help on using the repository browser.