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