|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2004 - 2009 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to compare two files. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import sys |
|
12 import time |
|
13 |
|
14 from PyQt4.QtCore import * |
|
15 from PyQt4.QtGui import * |
|
16 |
|
17 from E4Gui.E4Completers import E4FileCompleter |
|
18 |
|
19 from Ui_DiffDialog import Ui_DiffDialog |
|
20 import Utilities |
|
21 |
|
22 from difflib import SequenceMatcher |
|
23 |
|
24 # This function is copied from python 2.3 and slightly modified. |
|
25 # The header lines contain a tab after the filename. |
|
26 def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', |
|
27 tofiledate='', n=3, lineterm='\n'): |
|
28 """ |
|
29 Compare two sequences of lines; generate the delta as a unified diff. |
|
30 |
|
31 Unified diffs are a compact way of showing line changes and a few |
|
32 lines of context. The number of context lines is set by 'n' which |
|
33 defaults to three. |
|
34 |
|
35 By default, the diff control lines (those with ---, +++, or @@) are |
|
36 created with a trailing newline. This is helpful so that inputs |
|
37 created from file.readlines() result in diffs that are suitable for |
|
38 file.writelines() since both the inputs and outputs have trailing |
|
39 newlines. |
|
40 |
|
41 For inputs that do not have trailing newlines, set the lineterm |
|
42 argument to "" so that the output will be uniformly newline free. |
|
43 |
|
44 The unidiff format normally has a header for filenames and modification |
|
45 times. Any or all of these may be specified using strings for |
|
46 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'. The modification |
|
47 times are normally expressed in the format returned by time.ctime(). |
|
48 |
|
49 Example: |
|
50 |
|
51 <pre> |
|
52 >>> for line in unified_diff('one two three four'.split(), |
|
53 ... 'zero one tree four'.split(), 'Original', 'Current', |
|
54 ... 'Sat Jan 26 23:30:50 1991', 'Fri Jun 06 10:20:52 2003', |
|
55 ... lineterm=''): |
|
56 ... print line |
|
57 --- Original Sat Jan 26 23:30:50 1991 |
|
58 +++ Current Fri Jun 06 10:20:52 2003 |
|
59 @@ -1,4 +1,4 @@ |
|
60 +zero |
|
61 one |
|
62 -two |
|
63 -three |
|
64 +tree |
|
65 four |
|
66 </pre> |
|
67 |
|
68 @param a first sequence of lines (list of strings) |
|
69 @param b second sequence of lines (list of strings) |
|
70 @param fromfile filename of the first file (string) |
|
71 @param tofile filename of the second file (string) |
|
72 @param fromfiledate modification time of the first file (string) |
|
73 @param tofiledate modification time of the second file (string) |
|
74 @param n number of lines of context (integer) |
|
75 @param lineterm line termination string (string) |
|
76 @return a generator yielding lines of differences |
|
77 """ |
|
78 started = False |
|
79 for group in SequenceMatcher(None,a,b).get_grouped_opcodes(n): |
|
80 if not started: |
|
81 yield '--- %s\t%s%s' % (fromfile, fromfiledate, lineterm) |
|
82 yield '+++ %s\t%s%s' % (tofile, tofiledate, lineterm) |
|
83 started = True |
|
84 i1, i2, j1, j2 = group[0][1], group[-1][2], group[0][3], group[-1][4] |
|
85 yield "@@ -%d,%d +%d,%d @@%s" % (i1+1, i2-i1, j1+1, j2-j1, lineterm) |
|
86 for tag, i1, i2, j1, j2 in group: |
|
87 if tag == 'equal': |
|
88 for line in a[i1:i2]: |
|
89 yield ' ' + line |
|
90 continue |
|
91 if tag == 'replace' or tag == 'delete': |
|
92 for line in a[i1:i2]: |
|
93 yield '-' + line |
|
94 if tag == 'replace' or tag == 'insert': |
|
95 for line in b[j1:j2]: |
|
96 yield '+' + line |
|
97 |
|
98 # This function is copied from python 2.3 and slightly modified. |
|
99 # The header lines contain a tab after the filename. |
|
100 def context_diff(a, b, fromfile='', tofile='', |
|
101 fromfiledate='', tofiledate='', n=3, lineterm='\n'): |
|
102 """ |
|
103 Compare two sequences of lines; generate the delta as a context diff. |
|
104 |
|
105 Context diffs are a compact way of showing line changes and a few |
|
106 lines of context. The number of context lines is set by 'n' which |
|
107 defaults to three. |
|
108 |
|
109 By default, the diff control lines (those with *** or ---) are |
|
110 created with a trailing newline. This is helpful so that inputs |
|
111 created from file.readlines() result in diffs that are suitable for |
|
112 file.writelines() since both the inputs and outputs have trailing |
|
113 newlines. |
|
114 |
|
115 For inputs that do not have trailing newlines, set the lineterm |
|
116 argument to "" so that the output will be uniformly newline free. |
|
117 |
|
118 The context diff format normally has a header for filenames and |
|
119 modification times. Any or all of these may be specified using |
|
120 strings for 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'. |
|
121 The modification times are normally expressed in the format returned |
|
122 by time.ctime(). If not specified, the strings default to blanks. |
|
123 |
|
124 Example: |
|
125 |
|
126 <pre> |
|
127 >>> print ''.join(context_diff('one\ntwo\nthree\nfour\n'.splitlines(1), |
|
128 ... 'zero\none\ntree\nfour\n'.splitlines(1), 'Original', 'Current', |
|
129 ... 'Sat Jan 26 23:30:50 1991', 'Fri Jun 06 10:22:46 2003')), |
|
130 *** Original Sat Jan 26 23:30:50 1991 |
|
131 --- Current Fri Jun 06 10:22:46 2003 |
|
132 *************** |
|
133 *** 1,4 **** |
|
134 one |
|
135 ! two |
|
136 ! three |
|
137 four |
|
138 --- 1,4 ---- |
|
139 + zero |
|
140 one |
|
141 ! tree |
|
142 four |
|
143 </pre> |
|
144 |
|
145 @param a first sequence of lines (list of strings) |
|
146 @param b second sequence of lines (list of strings) |
|
147 @param fromfile filename of the first file (string) |
|
148 @param tofile filename of the second file (string) |
|
149 @param fromfiledate modification time of the first file (string) |
|
150 @param tofiledate modification time of the second file (string) |
|
151 @param n number of lines of context (integer) |
|
152 @param lineterm line termination string (string) |
|
153 @return a generator yielding lines of differences |
|
154 """ |
|
155 |
|
156 started = False |
|
157 prefixmap = {'insert':'+ ', 'delete':'- ', 'replace':'! ', 'equal':' '} |
|
158 for group in SequenceMatcher(None,a,b).get_grouped_opcodes(n): |
|
159 if not started: |
|
160 yield '*** %s\t%s%s' % (fromfile, fromfiledate, lineterm) |
|
161 yield '--- %s\t%s%s' % (tofile, tofiledate, lineterm) |
|
162 started = True |
|
163 |
|
164 yield '***************%s' % (lineterm,) |
|
165 if group[-1][2] - group[0][1] >= 2: |
|
166 yield '*** %d,%d ****%s' % (group[0][1]+1, group[-1][2], lineterm) |
|
167 else: |
|
168 yield '*** %d ****%s' % (group[-1][2], lineterm) |
|
169 visiblechanges = [e for e in group if e[0] in ('replace', 'delete')] |
|
170 if visiblechanges: |
|
171 for tag, i1, i2, _, _ in group: |
|
172 if tag != 'insert': |
|
173 for line in a[i1:i2]: |
|
174 yield prefixmap[tag] + line |
|
175 |
|
176 if group[-1][4] - group[0][3] >= 2: |
|
177 yield '--- %d,%d ----%s' % (group[0][3]+1, group[-1][4], lineterm) |
|
178 else: |
|
179 yield '--- %d ----%s' % (group[-1][4], lineterm) |
|
180 visiblechanges = [e for e in group if e[0] in ('replace', 'insert')] |
|
181 if visiblechanges: |
|
182 for tag, _, _, j1, j2 in group: |
|
183 if tag != 'delete': |
|
184 for line in b[j1:j2]: |
|
185 yield prefixmap[tag] + line |
|
186 |
|
187 class DiffDialog(QWidget, Ui_DiffDialog): |
|
188 """ |
|
189 Class implementing a dialog to compare two files. |
|
190 """ |
|
191 def __init__(self,parent = None): |
|
192 """ |
|
193 Constructor |
|
194 """ |
|
195 QWidget.__init__(self,parent) |
|
196 self.setupUi(self) |
|
197 |
|
198 self.file1Completer = E4FileCompleter(self.file1Edit) |
|
199 self.file2Completer = E4FileCompleter(self.file2Edit) |
|
200 |
|
201 self.diffButton = \ |
|
202 self.buttonBox.addButton(self.trUtf8("Compare"), QDialogButtonBox.ActionRole) |
|
203 self.diffButton.setToolTip(\ |
|
204 self.trUtf8("Press to perform the comparison of the two files")) |
|
205 self.saveButton = \ |
|
206 self.buttonBox.addButton(self.trUtf8("Save"), QDialogButtonBox.ActionRole) |
|
207 self.saveButton.setToolTip(self.trUtf8("Save the output to a patch file")) |
|
208 self.diffButton.setEnabled(False) |
|
209 self.saveButton.setEnabled(False) |
|
210 self.diffButton.setDefault(True) |
|
211 |
|
212 self.filename1 = '' |
|
213 self.filename2 = '' |
|
214 |
|
215 self.updateInterval = 20 # update every 20 lines |
|
216 |
|
217 if Utilities.isWindowsPlatform(): |
|
218 self.contents.setFontFamily("Lucida Console") |
|
219 else: |
|
220 self.contents.setFontFamily("Monospace") |
|
221 |
|
222 self.cNormalFormat = self.contents.currentCharFormat() |
|
223 self.cAddedFormat = self.contents.currentCharFormat() |
|
224 self.cAddedFormat.setBackground(QBrush(QColor(190, 237, 190))) |
|
225 self.cRemovedFormat = self.contents.currentCharFormat() |
|
226 self.cRemovedFormat.setBackground(QBrush(QColor(237, 190, 190))) |
|
227 self.cReplacedFormat = self.contents.currentCharFormat() |
|
228 self.cReplacedFormat.setBackground(QBrush(QColor(190, 190, 237))) |
|
229 self.cLineNoFormat = self.contents.currentCharFormat() |
|
230 self.cLineNoFormat.setBackground(QBrush(QColor(255, 220, 168))) |
|
231 |
|
232 # connect some of our widgets explicitly |
|
233 self.connect(self.file1Edit, SIGNAL("textChanged(const QString &)"), |
|
234 self.__fileChanged) |
|
235 self.connect(self.file2Edit, SIGNAL("textChanged(const QString &)"), |
|
236 self.__fileChanged) |
|
237 |
|
238 def show(self, filename = None): |
|
239 """ |
|
240 Public slot to show the dialog. |
|
241 |
|
242 @param filename name of a file to use as the first file (string) |
|
243 """ |
|
244 if filename: |
|
245 self.file1Edit.setText(filename) |
|
246 QWidget.show(self) |
|
247 |
|
248 def on_buttonBox_clicked(self, button): |
|
249 """ |
|
250 Private slot called by a button of the button box clicked. |
|
251 |
|
252 @param button button that was clicked (QAbstractButton) |
|
253 """ |
|
254 if button == self.diffButton: |
|
255 self.on_diffButton_clicked() |
|
256 elif button == self.saveButton: |
|
257 self.on_saveButton_clicked() |
|
258 |
|
259 @pyqtSlot() |
|
260 def on_saveButton_clicked(self): |
|
261 """ |
|
262 Private slot to handle the Save button press. |
|
263 |
|
264 It saves the diff shown in the dialog to a file in the local |
|
265 filesystem. |
|
266 """ |
|
267 dname, fname = Utilities.splitPath(self.filename2) |
|
268 if fname != '.': |
|
269 fname = "%s.diff" % self.filename2 |
|
270 else: |
|
271 fname = dname |
|
272 |
|
273 fname, selectedFilter = QFileDialog.getSaveFileNameAndFilter( |
|
274 self, |
|
275 self.trUtf8("Save Diff"), |
|
276 fname, |
|
277 self.trUtf8("Patch Files (*.diff)"), |
|
278 None, |
|
279 QFileDialog.Options(QFileDialog.DontConfirmOverwrite)) |
|
280 |
|
281 if not fname: |
|
282 return |
|
283 |
|
284 ext = QFileInfo(fname).suffix() |
|
285 if not ext: |
|
286 ex = selectedFilter.split("(*")[1].split(")")[0] |
|
287 if ex: |
|
288 fileName += ex |
|
289 if QFileInfo(fname).exists(): |
|
290 res = QMessageBox.warning(self, |
|
291 self.trUtf8("Save Diff"), |
|
292 self.trUtf8("<p>The patch file <b>{0}</b> already exists.</p>") |
|
293 .format(fname), |
|
294 QMessageBox.StandardButtons(\ |
|
295 QMessageBox.Abort | \ |
|
296 QMessageBox.Save), |
|
297 QMessageBox.Abort) |
|
298 if res != QMessageBox.Save: |
|
299 return |
|
300 fname = Utilities.toNativeSeparators(fname) |
|
301 |
|
302 try: |
|
303 f = open(fname, "wb") |
|
304 txt = self.contents.toPlainText() |
|
305 try: |
|
306 f.write(txt) |
|
307 except UnicodeError: |
|
308 pass |
|
309 f.close() |
|
310 except IOError, why: |
|
311 QMessageBox.critical(self, self.trUtf8('Save Diff'), |
|
312 self.trUtf8('<p>The patch file <b>{0}</b> could not be saved.<br />' |
|
313 'Reason: {1}</p>') |
|
314 .format(fname, unicode(why))) |
|
315 |
|
316 @pyqtSlot() |
|
317 def on_diffButton_clicked(self): |
|
318 """ |
|
319 Private slot to handle the Compare button press. |
|
320 """ |
|
321 self.filename1 = Utilities.toNativeSeparators(self.file1Edit.text()) |
|
322 try: |
|
323 filemtime1 = time.ctime(os.stat(self.filename1).st_mtime) |
|
324 except IOError: |
|
325 filemtime1 = "" |
|
326 try: |
|
327 f1 = open(self.filename1, "rb") |
|
328 lines1 = f1.readlines() |
|
329 f1.close() |
|
330 except IOError: |
|
331 QMessageBox.critical(self, |
|
332 self.trUtf8("Compare Files"), |
|
333 self.trUtf8("""<p>The file <b>{0}</b> could not be read.</p>""") |
|
334 .format(self.filename1)) |
|
335 return |
|
336 |
|
337 self.filename2 = Utilities.toNativeSeparators(self.file2Edit.text()) |
|
338 try: |
|
339 filemtime2 = time.ctime(os.stat(self.filename2).st_mtime) |
|
340 except IOError: |
|
341 filemtime2 = "" |
|
342 try: |
|
343 f2 = open(self.filename2, "rb") |
|
344 lines2 = f2.readlines() |
|
345 f2.close() |
|
346 except IOError: |
|
347 QMessageBox.critical(self, |
|
348 self.trUtf8("Compare Files"), |
|
349 self.trUtf8("""<p>The file <b>{0}</b> could not be read.</p>""") |
|
350 .format(self.filename2)) |
|
351 return |
|
352 |
|
353 self.contents.clear() |
|
354 self.saveButton.setEnabled(False) |
|
355 |
|
356 if self.unifiedRadioButton.isChecked(): |
|
357 self.__generateUnifiedDiff(lines1, lines2, self.filename1, self.filename2, |
|
358 filemtime1, filemtime2) |
|
359 else: |
|
360 self.__generateContextDiff(lines1, lines2, self.filename1, self.filename2, |
|
361 filemtime1, filemtime2) |
|
362 |
|
363 tc = self.contents.textCursor() |
|
364 tc.movePosition(QTextCursor.Start) |
|
365 self.contents.setTextCursor(tc) |
|
366 self.contents.ensureCursorVisible() |
|
367 |
|
368 self.saveButton.setEnabled(True) |
|
369 |
|
370 def __appendText(self, txt, format): |
|
371 """ |
|
372 Private method to append text to the end of the contents pane. |
|
373 |
|
374 @param txt text to insert (string) |
|
375 @param format text format to be used (QTextCharFormat) |
|
376 """ |
|
377 tc = self.contents.textCursor() |
|
378 tc.movePosition(QTextCursor.End) |
|
379 self.contents.setTextCursor(tc) |
|
380 self.contents.setCurrentCharFormat(format) |
|
381 self.contents.insertPlainText(txt) |
|
382 |
|
383 def __generateUnifiedDiff(self, a, b, fromfile, tofile, |
|
384 fromfiledate, tofiledate): |
|
385 """ |
|
386 Private slot to generate a unified diff output. |
|
387 |
|
388 @param a first sequence of lines (list of strings) |
|
389 @param b second sequence of lines (list of strings) |
|
390 @param fromfile filename of the first file (string) |
|
391 @param tofile filename of the second file (string) |
|
392 @param fromfiledate modification time of the first file (string) |
|
393 @param tofiledate modification time of the second file (string) |
|
394 """ |
|
395 paras = 0 |
|
396 for line in unified_diff(a, b, fromfile, tofile, |
|
397 fromfiledate, tofiledate): |
|
398 if line.startswith('+') or line.startswith('>'): |
|
399 format = self.cAddedFormat |
|
400 elif line.startswith('-') or line.startswith('<'): |
|
401 format = self.cRemovedFormat |
|
402 elif line.startswith('@@'): |
|
403 format = self.cLineNoFormat |
|
404 else: |
|
405 format = self.cNormalFormat |
|
406 self.__appendText(line, format) |
|
407 paras += 1 |
|
408 if not (paras % self.updateInterval): |
|
409 QApplication.processEvents() |
|
410 |
|
411 if paras == 0: |
|
412 self.__appendText(self.trUtf8('There is no difference.'), self.cNormalFormat) |
|
413 |
|
414 def __generateContextDiff(self, a, b, fromfile, tofile, |
|
415 fromfiledate, tofiledate): |
|
416 """ |
|
417 Private slot to generate a context diff output. |
|
418 |
|
419 @param a first sequence of lines (list of strings) |
|
420 @param b second sequence of lines (list of strings) |
|
421 @param fromfile filename of the first file (string) |
|
422 @param tofile filename of the second file (string) |
|
423 @param fromfiledate modification time of the first file (string) |
|
424 @param tofiledate modification time of the second file (string) |
|
425 """ |
|
426 paras = 0 |
|
427 for line in context_diff(a, b, fromfile, tofile, |
|
428 fromfiledate, tofiledate): |
|
429 if line.startswith('+ '): |
|
430 format = self.cAddedFormat |
|
431 elif line.startswith('- '): |
|
432 format = self.cRemovedFormat |
|
433 elif line.startswith('! '): |
|
434 format = self.cReplacedFormat |
|
435 elif (line.startswith('*** ') or line.startswith('--- ')) and paras > 1: |
|
436 format = self.cLineNoFormat |
|
437 else: |
|
438 format = self.cNormalFormat |
|
439 self.__appendText(line, format) |
|
440 paras += 1 |
|
441 if not (paras % self.updateInterval): |
|
442 QApplication.processEvents() |
|
443 |
|
444 if paras == 0: |
|
445 self.__appendText(self.trUtf8('There is no difference.'), self.cNormalFormat) |
|
446 |
|
447 def __fileChanged(self): |
|
448 """ |
|
449 Private slot to enable/disable the Compare button. |
|
450 """ |
|
451 if not self.file1Edit.text() or \ |
|
452 not self.file2Edit.text(): |
|
453 self.diffButton.setEnabled(False) |
|
454 else: |
|
455 self.diffButton.setEnabled(True) |
|
456 |
|
457 def __selectFile(self, lineEdit): |
|
458 """ |
|
459 Private slot to display a file selection dialog. |
|
460 |
|
461 @param lineEdit field for the display of the selected filename |
|
462 (QLineEdit) |
|
463 """ |
|
464 filename = QFileDialog.getOpenFileName( |
|
465 self, |
|
466 self.trUtf8("Select file to compare"), |
|
467 lineEdit.text(), |
|
468 "") |
|
469 |
|
470 if filename: |
|
471 lineEdit.setText(Utilities.toNativeSeparators(filename)) |
|
472 |
|
473 @pyqtSlot() |
|
474 def on_file1Button_clicked(self): |
|
475 """ |
|
476 Private slot to handle the file 1 file selection button press. |
|
477 """ |
|
478 self.__selectFile(self.file1Edit) |
|
479 |
|
480 @pyqtSlot("") |
|
481 def on_file2Button_clicked(self): |
|
482 """ |
|
483 Private slot to handle the file 2 file selection button press. |
|
484 """ |
|
485 self.__selectFile(self.file2Edit) |
|
486 |
|
487 class DiffWindow(QMainWindow): |
|
488 """ |
|
489 Main window class for the standalone dialog. |
|
490 """ |
|
491 def __init__(self, parent = None): |
|
492 """ |
|
493 Constructor |
|
494 |
|
495 @param parent reference to the parent widget (QWidget) |
|
496 """ |
|
497 QMainWindow.__init__(self, parent) |
|
498 self.cw = DiffDialog(self) |
|
499 self.cw.installEventFilter(self) |
|
500 size = self.cw.size() |
|
501 self.setCentralWidget(self.cw) |
|
502 self.resize(size) |
|
503 |
|
504 def eventFilter(self, obj, event): |
|
505 """ |
|
506 Public method to filter events. |
|
507 |
|
508 @param obj reference to the object the event is meant for (QObject) |
|
509 @param event reference to the event object (QEvent) |
|
510 @return flag indicating, whether the event was handled (boolean) |
|
511 """ |
|
512 if event.type() == QEvent.Close: |
|
513 QApplication.exit() |
|
514 return True |
|
515 |
|
516 return False |