diff --git a/.gitignore b/.gitignore index b049422..1e894cc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__ comp_examples venv build -dist \ No newline at end of file +dist +mdiff.egg-info \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ff57da8..ee17772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### 0.10.0 +* Added GUI widget to present diff result +* Added standalone GUI application. +* Added `case-sensitive` parameter to diff. + ### 0.0.5 * Updated readme. * Simplified some argument names and changed docstrings. diff --git a/README.md b/README.md index 0467a60..9e85e21 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ For plain python package (no additional dependencies): pip install mdiff ``` -For additional CLI tool functionalities (uses external packages such as [colorama](https://github.com/tartley/colorama), +For additional CLI tool and GUI functionalities (uses external packages such as [colorama](https://github.com/tartley/colorama), or [Typer](https://github.com/tiangolo/typer)): ```console -pip install mdiff[cli] +pip install mdiff[tools] ``` ## Usage @@ -139,7 +139,7 @@ replace a_lines[4:5] --> b_lines[4:5] ['line5'] --> ['line6'] Indented tags shows in-line differences, in this case `line5` and `line6` strings have the only difference at last character. ## CLI Tool -mdiff also provides CLI tool (available only if installed using `pip install mdiff[cli]`). For more information +mdiff also provides CLI tool (available only if installed using `pip install mdiff[tools]`). For more information type `mdiff --help` ```console @@ -178,10 +178,15 @@ Options: --cutoff FLOAT RANGE Line similarity ratio cutoff. If value exceeded then finds in-line differences in similar lines. [default: 0.75; 0.0<=x<=1.0] - --char-mode [utf8|ascii] Character set used when printing diff - result. [default: utf8] - --color-mode [fore|back] Color mode used when printing diff result. - [default: fore] + --gui / --no-gui Open GUI window with diff result. [default: + no-gui] + --case-sensitive / --no-case-sensitive + Whether diff is going to be case sensitive. + [default: case-sensitive] + --char-mode [utf8|ascii] Terminal character set used when printing + diff result. [default: utf8] + --color-mode [fore|back] Terminal color mode used when printing diff + result. [default: fore] --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the specified shell. --show-completion [bash|zsh|fish|powershell|pwsh] @@ -191,4 +196,13 @@ Options: ``` ### Example Sample output for `mdiff a.txt b.txt` command: -![](https://github.com/m-matelski/mdiff/raw/master/resources/readme/mdiff_cli1.png) \ No newline at end of file + +![](https://github.com/m-matelski/mdiff/raw/master/resources/readme/mdiff_cli1.png) + +Showing result in GUI `mdiff a.txt b.txt --gui`: + +![](https://github.com/m-matelski/mdiff/raw/master/resources/readme/mdiff_gui1.png) + + +## GUI +Use `mdiff-gui` command to launch GUI application. \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/mdiff/cli.py b/mdiff/cli.py index 417f29a..c61955f 100644 --- a/mdiff/cli.py +++ b/mdiff/cli.py @@ -1,26 +1,13 @@ -from enum import Enum from pathlib import Path import typer -from mdiff.seqmatch.utils import seq_matcher_factory -from mdiff.text_diff import diff_lines_with_similarities -import mdiff.visualisation.terminal as cli_vis -from mdiff.utils import read_file +from mdiff.differ import ConsoleTextDiffer, TkinterGuiDiffer +from mdiff.utils import read_file, StringEnumChoice sm_valid_names = ('standard', 'heckel', 'displacement') -class StringEnumChoice(str, Enum): - """ - Base class for typer choice enum strings. Predefined __str__ method fixes the bug where - default showed StringEnumChoice.value instead of value - """ - - def __str__(self): - return self.value - - class SequenceMatcherName(StringEnumChoice): STANDARD = 'standard' HECKEL = 'heckel' @@ -49,12 +36,14 @@ def cli_diff(source_file: Path = typer.Argument(..., help="Source file path to c 0.75, min=0.0, max=1.0, help='Line similarity ratio cutoff. If value exceeded then finds in-line differences in similar lines.' ), + gui: bool = typer.Option(False, help='Open GUI window with diff result.'), + case_sensitive: bool = typer.Option(True, help='Whether diff is going to be case sensitive.'), char_mode: CharacterMode = typer.Option( CharacterMode.UTF8, - help='Character set used when printing diff result.'), + help='Terminal character set used when printing diff result.'), color_mode: ColorMode = typer.Option( ColorMode.FORE, - help='Color mode used when printing diff result.' + help='Terminal color mode used when printing diff result.' )): """ Reads 2 files from provided paths, compares their content and prints diff. @@ -72,18 +61,15 @@ def cli_diff(source_file: Path = typer.Argument(..., help="Source file path to c """ source = read_file(source_file) target = read_file(target_file) - line_sm_instance = seq_matcher_factory(line_sm.value)() - inline_sm_instance = seq_matcher_factory(inline_sm.value)() - console_characters = cli_vis.get_console_characters(char_mode.value) - console_colors = cli_vis.get_console_colors(color_mode.value) - - a_lines, b_lines, opcodes = diff_lines_with_similarities( - a=source, b=target, cutoff=cutoff, line_sm=line_sm_instance, inline_sm=inline_sm_instance) - - printer = cli_vis.LineDiffConsolePrinter(a=a_lines, b=b_lines, seq=opcodes, - characters=console_characters, - colors=console_colors, line_margin=3, equal_context=-1) - printer.print() + if not gui: + differ = ConsoleTextDiffer(a=source, b=target, line_sm=line_sm, inline_sm=inline_sm, cutoff=cutoff, + color_mode=color_mode.value, character_mode=char_mode.value, + case_sensitive=case_sensitive) + differ.run() + else: + differ = TkinterGuiDiffer(a=source, b=target, line_sm=line_sm, inline_sm=inline_sm, cutoff=cutoff, + case_sensitive=case_sensitive) + differ.run() def main(): diff --git a/mdiff/differ.py b/mdiff/differ.py new file mode 100644 index 0000000..835c5dd --- /dev/null +++ b/mdiff/differ.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +import tkinter as tk + +from mdiff import diff_lines_with_similarities +from mdiff.seqmatch.utils import SequenceMatcherName, seq_matcher_factory + +import mdiff.visualisation.terminal as cli_vis +from mdiff.visualisation.gui_tkinter.diff_result import DiffResult, DiffResultWindowBuilder + + +class TextDiffer(ABC): + def __init__(self, a: str, b: str, line_sm: SequenceMatcherName, inline_sm: SequenceMatcherName, cutoff: float, + case_sensitive: bool): + self.a = a + self.b = b + self.line_sm = line_sm + self.inline_sm = inline_sm + self.cutoff = cutoff + self.case_sensitive = case_sensitive + if not 0.0 <= cutoff <= 1.0: + raise ValueError('cutoff must be in range: 0.0 <= cutoff <= 1.0') + + self.line_sm_instance = seq_matcher_factory(SequenceMatcherName(line_sm))() + self.inline_sm_instance = seq_matcher_factory(SequenceMatcherName(inline_sm))() + + @abstractmethod + def run(self): + pass + + +class ConsoleTextDiffer(TextDiffer): + def __init__(self, a: str, b: str, line_sm: SequenceMatcherName, inline_sm: SequenceMatcherName, cutoff: float, + case_sensitive: bool, color_mode: str, character_mode: str): + super().__init__(a, b, line_sm, inline_sm, cutoff, case_sensitive) + self.color_mode = color_mode + self.character_mode = character_mode + self.console_characters = cli_vis.get_console_characters(character_mode) + self.console_colors = cli_vis.get_console_colors(color_mode) + + def run(self): + a_lines, b_lines, opcodes = diff_lines_with_similarities( + a=self.a, b=self.b, cutoff=self.cutoff, line_sm=self.line_sm_instance, inline_sm=self.inline_sm_instance, + keepends=False, case_sensitive=self.case_sensitive) + + printer = cli_vis.LineDiffConsolePrinter(a=a_lines, b=b_lines, seq=opcodes, + characters=self.console_characters, + colors=self.console_colors, line_margin=3, equal_context=-1) + printer.print() + + +class TkinterGuiDiffer(TextDiffer): + + def run(self): + root = tk.Tk() + root.title('Diff Result') + + diff_result = DiffResult(root) + window = DiffResultWindowBuilder(root, diff_result) + diff_result.set_diff_params(a=self.a, b=self.b, line_sm_name=self.line_sm, inline_sm_name=self.inline_sm, + cutoff=self.cutoff, case_sensitive=self.case_sensitive) + diff_result.generate_diff() + root.mainloop() diff --git a/mdiff/seqmatch/utils.py b/mdiff/seqmatch/utils.py index ef640d2..9e904cc 100644 --- a/mdiff/seqmatch/utils.py +++ b/mdiff/seqmatch/utils.py @@ -1,11 +1,12 @@ from difflib import SequenceMatcher -from typing import Literal, Type +from enum import Enum +from typing import Type from mdiff.seqmatch.heckel import HeckelSequenceMatcher, DisplacementSequenceMatcher from mdiff.utils import SequenceMatcherBase -class SequenceMatcherName: +class SequenceMatcherName(str, Enum): STANDARD = 'standard' HECKEL = 'heckel' DISPLACEMENT = 'displacement' @@ -18,7 +19,7 @@ class SequenceMatcherName: } -def seq_matcher_factory(seq_matcher_type: str) -> Type[SequenceMatcherBase]: +def seq_matcher_factory(seq_matcher_type: SequenceMatcherName) -> Type[SequenceMatcherBase]: values = SequenceMatcherName.__dict__.values() if seq_matcher_type not in values: raise ValueError(f'seq_matcher_type must be in: {values}') diff --git a/mdiff/text_diff.py b/mdiff/text_diff.py index 7ea1b4e..4982b38 100644 --- a/mdiff/text_diff.py +++ b/mdiff/text_diff.py @@ -32,6 +32,8 @@ def find_best_similar_match(i1: int, i2: int, j1: int, j2: int, a: Sequence, b: for i in range(i1, i2): sm.set_seq1(a[i]) for j in range(j1, j2): + if a[i] == b[j]: + continue sm.set_seq2(b[j]) if sm.real_quick_ratio() > best_ratio and sm.quick_ratio() > best_ratio and sm.ratio() > best_ratio: best_i = i @@ -69,9 +71,7 @@ def extract_replace_similarities(tag: str, i1: int, i2: int, j1: int, j2: int, a sm = SequenceMatcher() match_i, match_j, match_ratio = find_best_similar_match(i1, i2, j1, j2, a, b) - if match_ratio >= 1.0: - yield CompositeOpCode('equal', i1, i2, j1, j2) - elif match_ratio > cutoff: + if match_ratio > cutoff: # left yield from extract_replace_similarities(tag, i1, match_i, j1, match_j, a, b, cutoff, sm) @@ -124,7 +124,9 @@ def extract_similarities(opcodes: OpCodesType, a: Sequence, b: Sequence, cutoff: def diff_lines_with_similarities(a: str, b: str, cutoff=0.75, line_sm: SequenceMatcherBase = None, - inline_sm: SequenceMatcherBase = None) \ + inline_sm: SequenceMatcherBase = None, + keepends=False, + case_sensitive=True) \ -> Tuple[List[str], List[str], List[CompositeOpCode]]: """ Takes input strings "a" and "b", splits them by newline characters and generates line diff opcodes. @@ -139,6 +141,7 @@ def diff_lines_with_similarities(a: str, b: str, cutoff=0.75, if sub opcodes for similar lines should be generated. :param line_sm: SequenceMatcher object used to generate diff tags between input texts lines. :param inline_sm: SequenceMatcher object used to generate diff tags between characters in similar lines. + :param keepends: whether to keep newline characters when splitting input sequences. :return: (a_lines, b_lines, opcodes) where: a_lines: is "a" input text split by newline characters. @@ -166,9 +169,15 @@ def diff_lines_with_similarities(a: str, b: str, cutoff=0.75, if inline_sm is None: inline_sm = SequenceMatcher() - a_lines = a.splitlines(keepends=False) - b_lines = b.splitlines(keepends=False) - line_sm.set_seqs(a_lines, b_lines) + a_lines = a.splitlines(keepends=keepends) + b_lines = b.splitlines(keepends=keepends) + if case_sensitive: + sm_a_lines = a_lines + sm_b_lines = b_lines + else: + sm_a_lines = [i.lower() for i in a_lines] + sm_b_lines = [i.lower() for i in b_lines] + line_sm.set_seqs(sm_a_lines, sm_b_lines) line_opcodes = line_sm.get_opcodes() - line_opcodes_with_similarities = extract_similarities(line_opcodes, a_lines, b_lines, cutoff, inline_sm) + line_opcodes_with_similarities = extract_similarities(line_opcodes, sm_a_lines, sm_b_lines, cutoff, inline_sm) return a_lines, b_lines, list(line_opcodes_with_similarities) diff --git a/mdiff/utils.py b/mdiff/utils.py index 4138e89..a9991b7 100644 --- a/mdiff/utils.py +++ b/mdiff/utils.py @@ -3,8 +3,10 @@ """ import math +from enum import Enum +from functools import lru_cache from pathlib import Path -from typing import NamedTuple, Any, Tuple, List, Sequence, Union, Protocol +from typing import NamedTuple, Any, Tuple, List, Sequence, Union, Protocol, Type class OpCode: @@ -209,23 +211,104 @@ def len_or_1(a): return 1 -def pair_iteration(seq: Sequence, n=2): - seq_l = list(seq) - i = 0 - while i < len(seq_l): - yield tuple(seq_l[i:i + n]) - i += n +def read_file(p: Path): + with open(p) as file: + content = file.read() + return content + + +class AttributeSequenceHandler: + def __init__(self, seq): + self.seq = seq + + def __call__(self, *args, **kwargs): + return [i(*args, **kwargs) for i in self.seq] + + +class CompositeDelegationMixin: + """ + This class provides auto delegation super class methods to children instances. + It allows to treat composite object as single instance, also direct inheritance allows IDE to generate hints. + """ + + def __init__(self): + self.__children__ = [] + + def __getattribute__(self, item): + # Do not delegate, until children defined (avoids error when instance uses self in init process) + try: + children = object.__getattribute__(self, '__children__') + except AttributeError: + children = [] + if not children: + return object.__getattribute__(self, item) + + if item == '__children__': + return object.__getattribute__(self, item) + children_items = [object.__getattribute__(i, item) for i in self.__children__] + if callable(object.__getattribute__(self, item)): + # Assumption: if base class attribute is callable, then all that children attributes are callable + return AttributeSequenceHandler(children_items) + else: + return children_items + + +class StringEnumChoice(str, Enum): + """ + Base class for typer choice enum strings. Predefined __str__ method fixes the bug where + default showed StringEnumChoice.value instead of value + """ + + def __str__(self): + return self.value + +def get_enum_values(enum: Type[Enum]): + return [e.value for e in enum] -def get_composite_layer(ob, n, children_getter): - if n == 0: - yield ob + +def pop_default(lst: List, index=-1, default=None): + try: + return lst.pop(index) + except IndexError: + return default + + +def sort_seq_by_other_seq_indexes(seq: Sequence, other: Sequence) -> List[Tuple[int, Any]]: + b_hash = dict() + seq = enumerate(seq) + for i, item in enumerate(other): + b_hash.setdefault(item, []).append(i) + + result = sorted(seq, key=lambda x: pop_default(b_hash.get(x[1], []), 0, math.inf)) + return result + + +def sort_seq_by_other_seq(seq: Sequence, other: Sequence) -> List: + return [i[1] for i in sort_seq_by_other_seq_indexes(seq, other)] + + +def sort_string_seq_by_other(seq: Sequence[str], other: Sequence[str], case_sensitive=True) -> List[str]: + if case_sensitive: + a_seq = seq + b_seq = other else: - for i in children_getter(ob): - yield from get_composite_layer(i, n - 1, children_getter) + a_seq = [i.lower() for i in seq] + b_seq = [i.lower() for i in other] + idx_sort, _ = zip(*sort_seq_by_other_seq_indexes(a_seq, b_seq)) + result = [seq[i] for i in idx_sort] + return result -def read_file(p: Path): - with open(p) as file: - content = file.read() - return content \ No newline at end of file + +def sort_string_seq(seq: Sequence[str], case_sensitive=True) -> List[str]: + if case_sensitive: + return sorted(seq) + else: + return sorted(seq, key=lambda x: x.lower()) + + +@lru_cache +def get_app_version() -> str: + version = read_file(Path('../VERSION')) + return version diff --git a/mdiff/visualisation/gui_tkinter.py b/mdiff/visualisation/gui_tkinter.py deleted file mode 100644 index f533ea7..0000000 --- a/mdiff/visualisation/gui_tkinter.py +++ /dev/null @@ -1,211 +0,0 @@ -# # nothing interesting here, just some old code as reference -# -# from tkinter import ttk -# import tkinter as tk -# -# class ScrolledText(tk.Text): -# def __init__(self, master=None, **kw): -# self.frame = tk.Frame(master) -# -# self.vbar = tk.Scrollbar(self.frame) -# self.vbar.pack(side=tk.RIGHT, fill=tk.Y) -# -# self.hbar = tk.Scrollbar(self.frame, orient=tk.HORIZONTAL) -# self.hbar.pack(side=tk.BOTTOM, fill=tk.X) -# -# kw.update({'yscrollcommand': self._yscrollcommand}) -# kw.update({'xscrollcommand': self._xscrollcommand}) -# -# -# tk.Text.__init__(self, self.frame, **kw) -# -# self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) -# -# self.vbar['command'] = self.yview -# self.hbar['command'] = self.xview -# -# # Copy geometry methods of self.frame without overriding Text -# # methods -- hack! -# text_meths = vars(tk.Text).keys() -# methods = vars(tk.Pack).keys() | vars(tk.Grid).keys() | vars(tk.Place).keys() -# methods = methods.difference(text_meths) -# -# for m in methods: -# if m[0] != '_' and m != 'config' and m != 'configure': -# setattr(self, m, getattr(self.frame, m)) -# -# self.on_yscrollcommand = lambda x, y: None -# self.on_xscrollcommand = lambda x, y: None -# -# -# def _yscrollcommand(self, first, last): -# self.vbar.set(first, last) -# self.on_yscrollcommand(first, last) -# -# def _xscrollcommand(self, first, last): -# self.hbar.set(first, last) -# self.on_xscrollcommand(first, last) -# -# def __str__(self): -# return str(self.frame) -# -# -# class ResultText(ScrolledText): -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs) -# -# -# -# -# class SqlDiffResult(tk.Toplevel): -# -# TAG_DIFF_MATCH = 'TAG_DIFF_MATCH' -# TAG_DIFF_ORDER_DONT_MATCH = 'TAG_DIFF_ORDER_DONT_MATCH' -# TAG_DIFF_TYPE_DONT_MATCH = 'TAG_DIFF_TYPE_DONT_MATCH' -# TAG_DIFF_NO_MATCH_AND_ORDER = 'TAG_DIFF_NO_MATCH_AND_ORDER' -# TAG_DIFF_TYPE_DONT_EXIST = 'TAG_DIFF_TYPE_DONT_EXIST' -# -# DIFF_MODE_BOTH = 'Source/Target' -# DIFF_MODE_SOURCE = 'Source' -# DIFF_MODE_TARGET = 'Target' -# DIFF_MODES = (DIFF_MODE_BOTH, DIFF_MODE_SOURCE, DIFF_MODE_TARGET) -# -# -# def __init__(self, a, b, opcodes, *args, **kwargs): -# # Window -# super().__init__(*args, **kwargs) -# -# self.a = a -# self.b = b -# self.opcodes = opcodes -# -# self.text_a = ResultText(self, padx=5, pady=2, wrap='none') -# self.text_b = ResultText(self, padx=5, pady=2, wrap='none') -# self.text_a.on_xscrollcommand = self.on_xscrollcommand_source -# self.text_a.on_yscrollcommand = self.on_yscrollcommand_source -# self.text_b.on_xscrollcommand = self.on_xscrollcommand_target -# self.text_b.on_yscrollcommand = self.on_yscrollcommand_target -# -# for sql_text in (self.text_a, self.text_b): -# sql_text.tag_config(self.TAG_DIFF_MATCH, background="#adffc3") -# sql_text.tag_config(self.TAG_DIFF_ORDER_DONT_MATCH, background="#abe7ff") -# sql_text.tag_config(self.TAG_DIFF_TYPE_DONT_MATCH, background="#fffbc4") -# sql_text.tag_config(self.TAG_DIFF_NO_MATCH_AND_ORDER, background="#ffb7dd") -# sql_text.tag_config(self.TAG_DIFF_TYPE_DONT_EXIST, background="#ff9a9a") -# -# self.frame_top = tk.Frame(self) -# self.frame_top.grid(column=0, row=0, sticky='', columnspan=2, padx=3, pady=3) -# # self.frame_top.config(bd=1, relief=tk.SOLID) -# self.frame_top.grid_columnconfigure(0, weight=1) -# self.frame_top.grid_columnconfigure(1, weight=1) -# self.frame_top.grid_rowconfigure(0, weight=1) -# -# # self.lbl_diff_mode = tk.Label(self.frame_top, text='Diff mode:') -# # self.lbl_diff_mode.grid(column=0, row=0, sticky='nw', padx=5) -# -# # self.combo_diff_mode = ttk.Combobox(self.frame_top) -# # self.combo_diff_mode['values'] = self.DIFF_MODES -# # self.combo_diff_mode.state(['readonly']) -# # self.combo_diff_mode.bind('<>', self.diff_mode_selection) -# # self.combo_diff_mode.grid(column=1, row=0, sticky='nw') -# # self.combo_diff_mode.current(0) -# -# self.text_a.grid(column=0, row=1, sticky='nwes') -# self.text_b.grid(column=1, row=1, sticky='nwes') -# -# self.grid_columnconfigure(0, weight=1) -# self.grid_columnconfigure(1, weight=1) -# self.grid_rowconfigure(1, weight=10000) -# self.grid_rowconfigure(0, weight=1) -# -# self.load_comparison_result() -# -# # def diff_mode_selection(self, event=None): -# # self.combo_diff_mode.selection_clear() -# # self.load_comparison_result() -# -# def load_comparison_result(self): -# -# self.set_sql_text_state('normal') -# self.sql_text_clear() -# diff_mode = self.combo_diff_mode.get() -# -# -# comparison_result = self.comparison_result -# if diff_mode==self.DIFF_MODE_SOURCE: -# comparison_result = self.comparison_result.order_by_source() -# elif diff_mode==self.DIFF_MODE_TARGET: -# comparison_result = self.comparison_result.order_by_target() -# -# # print(self.comparison_result) -# # for i in range(100): -# # self.sql_text_source.insert(tk.END, f"field{i} DECIMAL(15,5)\n") -# # self.sql_text_target.insert(tk.END, f"field{i} DECIMAL(15,5)\n") -# for cr in comparison_result: -# src_verse = (str(cr.key.source) if cr.key.source else '') + '\n' -# tgt_verse = (str(cr.key.target) if cr.key.target else '') + '\n' -# self.text_a.insert('end', src_verse) -# self.text_b.insert('end', tgt_verse) -# -# for i in (self.text_a, self.text_b): -# i.put_styled_text() -# -# # set backgrounds -# # self.sql_text_source.tag_add('warning', '1.2', '5.0') -# for i, cr in enumerate(comparison_result, start=1): -# start = str(i) + '.0' -# end = str(i+1) + '.0' -# tag = self.get_tag(cr) -# self.text_a.tag_add(tag, start, end) -# self.text_b.tag_add(tag, start, end) -# -# self.set_sql_text_state('disabled') -# -# def get_tag(self, result: KeyColumnComparisonResult): -# type_match = result.type_name_match and result.precision_match and result.sacle_match -# if result.key.match and result.key.order_match and type_match: -# return self.TAG_DIFF_MATCH -# elif result.key.match and not result.key.order_match and type_match: -# return self.TAG_DIFF_ORDER_DONT_MATCH -# elif result.key.match and result.key.order_match and not type_match: -# return self.TAG_DIFF_TYPE_DONT_MATCH -# elif result.key.match and not result.key.order_match and not type_match: -# print('nomatch order') -# return self.TAG_DIFF_NO_MATCH_AND_ORDER -# elif not result.key.match: -# print('dontexists') -# return self.TAG_DIFF_TYPE_DONT_EXIST -# return '' -# -# def sql_text_clear(self): -# for i in (self.text_a, self.text_b): -# i.delete('1.0', tk.END) -# -# def set_sql_text_state(self, state): -# for i in (self.text_a, self.text_b): -# # i.put_styled_text() -# i.configure(state=state) -# -# def on_yscrollcommand_source(self, first, last): -# if not self.has_text_scrollbars_the_same_position(): -# view_pos = self.text_a.yview() -# self.text_b.yview_moveto(view_pos[0]) -# -# def on_xscrollcommand_source(self, first, last): -# if not self.has_text_scrollbars_the_same_position(): -# view_pos = self.text_a.xview() -# self.text_b.xview_moveto(view_pos[0]) -# -# def on_yscrollcommand_target(self, first, last): -# if not self.has_text_scrollbars_the_same_position(): -# view_pos = self.text_b.yview() -# self.text_a.yview_moveto(view_pos[0]) -# -# def on_xscrollcommand_target(self, first, last): -# if not self.has_text_scrollbars_the_same_position(): -# view_pos = self.text_b.xview() -# self.text_a.xview_moveto(view_pos[0]) -# -# def has_text_scrollbars_the_same_position(self): -# return self.text_a.hbar.get() == self.text_b.hbar.get() and \ -# self.text_a.vbar.get() == self.text_b.vbar.get() diff --git a/mdiff/visualisation/gui_tkinter/__init__.py b/mdiff/visualisation/gui_tkinter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mdiff/visualisation/gui_tkinter/diff_input.py b/mdiff/visualisation/gui_tkinter/diff_input.py new file mode 100644 index 0000000..cfbcaee --- /dev/null +++ b/mdiff/visualisation/gui_tkinter/diff_input.py @@ -0,0 +1,100 @@ +import tkinter as tk +from typing import Union + +from mdiff.visualisation.gui_tkinter.diff_result import DiffResultWindowBuilder, DiffResult +from mdiff.visualisation.gui_tkinter.utils import ScrolledText + + +def maximize_window(window: Union[tk.Tk, tk.Toplevel]): + try: + window.state('zoomed') + return + except tk.TclError: + pass + + try: + window.attributes('-zoomed', True) + return + except tk.TclError: + pass + + +class DiffInputWindow(tk.Tk): + """ + Main GUI window with 2 text inputs used to generate diff. + """ + + def __init__(self): + super().__init__() + + self.title('mdiff') + maximize_window(self) + + # widgets + self.frame_text = tk.Frame(self) + self.frame_text.grid(column=0, row=0, sticky='nsew', padx=0, pady=0) + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + + self.text_source = ScrolledText(self.frame_text) + self.text_target = ScrolledText(self.frame_text) + self.text_source.grid(column=0, row=0, sticky='nsew', padx=3, pady=3) + self.text_target.grid(column=1, row=0, sticky='nsew', padx=3, pady=3) + self.frame_text.columnconfigure(0, weight=1) + self.frame_text.columnconfigure(1, weight=1) + self.frame_text.rowconfigure(0, weight=1) + + # menu bar + self.option_add('*tearOff', tk.FALSE) + self.menu_bar = tk.Menu(self) + self['menu'] = self.menu_bar + + self.menu_file = tk.Menu(self.menu_bar) + self.menu_bar.add_cascade(menu=self.menu_file, label='File', underline=0) + self.menu_file.add_command(label='Close', command=lambda: self.destroy()) + + self.menu_edit = tk.Menu(self.menu_bar) + self.menu_bar.add_cascade(menu=self.menu_edit, label='Edit', underline=0) + self.menu_edit.add_command(label='Copy', accelerator='Ctrl+C', + command=lambda: self.focus_get().event_generate("<>")) + self.menu_edit.add_command(label='Cut', accelerator='Ctrl+X', + command=lambda: self.focus_get().event_generate("<>")) + self.menu_edit.add_command(label='Paste', accelerator='Ctrl+V', + command=lambda: self.focus_get().event_generate("<>")) + + self.menu_run = tk.Menu(self.menu_bar) + self.menu_bar.add_cascade(menu=self.menu_run, label='Run', underline=0) + self.menu_run.add_command(label='Generate Diff...', accelerator='F8', command=self.generate_diff) + + # context menu + self.menu_context = self.menu_edit + if self.tk.call('tk', 'windowingsystem') == 'aqua': + self.bind('<2>', lambda e: self.menu_context.post(e.x_root, e.y_root)) + self.bind('', lambda e: self.menu_context.post(e.x_root, e.y_root)) + else: + self.bind('<3>', lambda e: self.menu_context.post(e.x_root, e.y_root)) + + # Key binds + self.bind("", self.generate_diff) + + def generate_diff(self, *args, **kwargs): + # Define TopLevel window + diff_result_window = tk.Toplevel(self) + diff_result_window.title('mdiff - Diff Result') + diff_result_window_w = int(self.winfo_width() * 0.95) + diff_result_window_h = height = int(self.winfo_height() * 0.95) + diff_result_window.geometry(f'{diff_result_window_w}x{diff_result_window_h}+20+20') + + # Define DiffResult frame + diff_result_content = DiffResult(diff_result_window) + diff_result_content.set_diff_params(a=self.text_source.get("1.0", tk.END), + b=self.text_target.get("1.0", tk.END)) + + # build and show window + diff_result_content.generate_diff() + window = DiffResultWindowBuilder(diff_result_window, diff_result_content) + # diff_result_window.transient(self) + diff_result_window.focus_force() + diff_result_window.wait_visibility() + diff_result_window.grab_set() + diff_result_window.wait_window() diff --git a/mdiff/visualisation/gui_tkinter/diff_result.py b/mdiff/visualisation/gui_tkinter/diff_result.py new file mode 100644 index 0000000..0b98176 --- /dev/null +++ b/mdiff/visualisation/gui_tkinter/diff_result.py @@ -0,0 +1,354 @@ +import tkinter as tk +from enum import Enum +from itertools import zip_longest +from tkinter import ttk +from typing import Protocol, Union + +from mdiff import diff_lines_with_similarities, CompositeOpCode +from mdiff.seqmatch.utils import SequenceMatcherName, seq_matcher_factory +from mdiff.utils import CompositeDelegationMixin, get_enum_values, sort_seq_by_other_seq, sort_string_seq, \ + sort_string_seq_by_other +from mdiff.visualisation.gui_tkinter.utils import ScrolledText, WindowBuilder + + +# Defining tags, colors and mappings for coloring background in Text widgets using diff result data +class TextDiffTag(str, Enum): + INSERT = 'INSERT' + DELETE = 'DELETE' + MOVE = 'MOVE' + MOVED = 'MOVED' + REPLACE = 'REPLACE' + EQUAL = 'EQUAL' + IL_INSERT = 'IL_INSERT' + IL_DELETE = 'IL_DELETE' + IL_MOVE = 'IL_MOVE' + IL_MOVED = 'IL_MOVED' + IL_REPLACE = 'IL_REPLACE' + IL_EQUAL = 'IL_EQUAL' + + +class TagConfigurator(Protocol): + def __call__(self, text: tk.Text) -> None: ... + + +def configure_tags(text: tk.Text): + text.tag_config(TextDiffTag.INSERT, background="#99ff99") + text.tag_config(TextDiffTag.DELETE, background="#ff9999") + text.tag_config(TextDiffTag.MOVE, background="#b3e6ff") + text.tag_config(TextDiffTag.MOVED, background="#b3e6ff") + text.tag_config(TextDiffTag.REPLACE, background="#ffd699") + text.tag_config(TextDiffTag.EQUAL, background="") + text.tag_config(TextDiffTag.IL_INSERT, background="#66ff66") + text.tag_config(TextDiffTag.IL_DELETE, background="#ff6666") + text.tag_config(TextDiffTag.IL_MOVE, background="#80d4ff") + text.tag_config(TextDiffTag.IL_MOVED, background="#80d4ff") + text.tag_config(TextDiffTag.IL_REPLACE, background="#ffad33") + text.tag_config(TextDiffTag.IL_EQUAL, background="#ffd699") + + +line_opcode_tag_to_text_tag = { + 'insert': TextDiffTag.INSERT, + 'delete': TextDiffTag.DELETE, + 'move': TextDiffTag.MOVE, + 'moved': TextDiffTag.MOVED, + 'replace': TextDiffTag.REPLACE, + 'equal': TextDiffTag.EQUAL +} +inline_opcode_tag_to_text_tag = { + 'insert': TextDiffTag.IL_INSERT, + 'delete': TextDiffTag.IL_DELETE, + 'move': TextDiffTag.IL_MOVE, + 'moved': TextDiffTag.IL_MOVED, + 'replace': TextDiffTag.IL_REPLACE, + 'equal': TextDiffTag.IL_EQUAL +} + + +class SortChoices(str, Enum): + NO = 'No' + BY_SOURCE = 'By source' + BY_TARGET = 'By target' + YES = 'Yes' + + +class SequenceMatcherChoices(str, Enum): + STANDARD = 'Standard' + HECKEL = 'Heckel' + DISPLACEMENT = 'Displacement' + + +sm_choice_to_factory_name = { + SequenceMatcherChoices.STANDARD: SequenceMatcherName.STANDARD, + SequenceMatcherChoices.HECKEL: SequenceMatcherName.HECKEL, + SequenceMatcherChoices.DISPLACEMENT: SequenceMatcherName.DISPLACEMENT +} +factory_name_to_sm_choice = {v: k for k, v in sm_choice_to_factory_name.items()} + + +class ResultText(ScrolledText): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class TextComposite(tk.Text, CompositeDelegationMixin): + """Allows to treat group of Text widgets as one component.""" + + def __init__(self, **kw): + CompositeDelegationMixin.__init__(self) + super().__init__() + + +class DiffResult(tk.Frame): + """ + Main diff result frame containing two Text widgets side by side for presenting diff result. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.a = '' + self.b = '' + + self.frame_bottom = tk.Frame(self) + self.frame_bottom.grid(column=0, row=1, sticky='nsew', padx=3, pady=1) + self.frame_bottom.columnconfigure(0, weight=1) + self.frame_bottom.columnconfigure(1, weight=1) + self.frame_bottom.rowconfigure(0, weight=1) + # ---GUI--- left and right text widgets + self.text_source = ResultText(self.frame_bottom, padx=5, pady=2, wrap='none', borderwidth=5) + self.text_target = ResultText(self.frame_bottom, padx=5, pady=2, wrap='none', borderwidth=5) + self.texts = TextComposite() + self.texts.__children__.extend([self.text_source, self.text_target]) + configure_tags(self.texts) + self.texts.tag_raise('sel') + # setup text widgets to scroll simultaneously + self.text_source.on_xscrollcommand = self.on_xscrollcommand_source + self.text_source.on_yscrollcommand = self.on_yscrollcommand_source + self.text_target.on_xscrollcommand = self.on_xscrollcommand_target + self.text_target.on_yscrollcommand = self.on_yscrollcommand_target + + # ---GUI--- top frame layout + self.frame_top = tk.Frame(self) + self.frame_top.grid(column=0, row=0, sticky='', padx=3, pady=1) + + # ---GUI--- line level sequence matcher: label + combobox + self.frame_line_sm = tk.Frame(self.frame_top) + self.frame_line_sm.grid(column=0, row=0, sticky='nw', padx=10) + self.lbl_line_sm = tk.Label(self.frame_line_sm, text='Line SM:') + self.lbl_line_sm.grid(column=0, row=0, sticky='nw') + self.combo_line_sm = ttk.Combobox(self.frame_line_sm) + self.combo_line_sm['values'] = get_enum_values(SequenceMatcherChoices) + self.combo_line_sm.state(['readonly']) + self.combo_line_sm.bind('<>', self.line_sm_selection) + self.combo_line_sm.grid(column=1, row=0, sticky='nw') + self.combo_line_sm.current(1) + + # ---GUI--- in-line level sequence matcher: label + combobox + self.frame_in_line_sm = tk.Frame(self.frame_top) + self.frame_in_line_sm.grid(column=1, row=0, sticky='nw', padx=10) + self.lbl_in_line_sm = tk.Label(self.frame_in_line_sm, text='Line SM:') + self.lbl_in_line_sm.grid(column=0, row=0, sticky='nw') + self.combo_in_line_sm = ttk.Combobox(self.frame_in_line_sm) + self.combo_in_line_sm['values'] = get_enum_values(SequenceMatcherChoices) + self.combo_in_line_sm.state(['readonly']) + self.combo_in_line_sm.bind('<>', self.in_line_sm_selection) + self.combo_in_line_sm.grid(column=1, row=0, sticky='nw') + self.combo_in_line_sm.current(1) + + # ---GUI--- cutoff label + slider + self.frame_cutoff = tk.Frame(self.frame_top) + self.frame_cutoff.grid(column=2, row=0, sticky='nw', padx=10) + self.lbl_cutoff = tk.Label(self.frame_cutoff, text='Cutoff:') + self.lbl_cutoff.grid(column=0, row=0, sticky='nw') + self.scale_cutoff_value = tk.DoubleVar() + self.scale_cutoff = tk.Scale(self.frame_cutoff, orient=tk.HORIZONTAL, length=200, from_=0.0, to=1.0, + resolution=0.01, showvalue=False, variable=self.scale_cutoff_value) + self.scale_cutoff.set(0.75) + self.scale_cutoff.grid(column=1, row=0, sticky='nw') + self.lbl_cutoff_value = tk.Label(self.frame_cutoff, textvariable=self.scale_cutoff_value) + self.lbl_cutoff_value.grid(column=2, row=0, sticky='nw') + + # ---GUI--- sort by: label + combobox + self.frame_sort_by = tk.Frame(self.frame_top) + self.frame_sort_by.grid(column=3, row=0, sticky='nw', padx=10) + self.lbl_sort_by = tk.Label(self.frame_sort_by, text='Sort content:') + self.lbl_sort_by.grid(column=0, row=0, sticky='nw') + self.combo_sort_by = ttk.Combobox(self.frame_sort_by) + self.combo_sort_by['values'] = get_enum_values(SortChoices) + self.combo_sort_by.state(['readonly']) + self.combo_sort_by.bind('<>', self.sort_by_selection) + self.combo_sort_by.grid(column=1, row=0, sticky='nw') + self.combo_sort_by.current(0) + + # ---GUI + self.case_sensitive = tk.BooleanVar(value=True) + self.check_case_sensitive = tk.Checkbutton(self.frame_top, text='Case sensitive', variable=self.case_sensitive) + self.check_case_sensitive.grid(column=4, row=0, sticky='nw', padx=10) + + # ---GUI--- generate + self.button_generate = tk.Button(self.frame_top, text='Generate Diff', command=self.generate_diff) + self.button_generate.grid(column=5, row=0, sticky='nw', padx=10) + + self.text_source.grid(column=0, row=0, sticky='nsew') + self.text_target.grid(column=1, row=0, sticky='nsew') + # + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=10000) + self.grid_rowconfigure(0, weight=1) + + def set_a(self, a: str): + """Set source text""" + self.a = a + + def set_b(self, b: str): + """Set target text""" + self.b = b + + def set_diff_params(self, a: str, b: str, + line_sm_name: SequenceMatcherName = SequenceMatcherName.HECKEL, + inline_sm_name: SequenceMatcherName = SequenceMatcherName.HECKEL, + cutoff: float = 0.75, case_sensitive: bool = False): + """ + Set diff parameters for a widget + """ + if not 0.0 <= cutoff <= 1.0: + raise ValueError('cutoff must be in range: 0.0 <= cutoff <= 1.0') + self.scale_cutoff_value.set(cutoff) + self.a = a + self.b = b + self.combo_line_sm.set(factory_name_to_sm_choice[line_sm_name.value]) + self.combo_in_line_sm.set(factory_name_to_sm_choice[inline_sm_name.value]) + self.case_sensitive.set(value=case_sensitive) + + def handle_sort(self): + """Sort source and target text lines""" + a_lines = self.a.splitlines(keepends=False) + b_lines = self.b.splitlines(keepends=False) + option = SortChoices(self.combo_sort_by.get()) + case_sensitive = self.case_sensitive.get() + if option == SortChoices.NO: + return self.a, self.b + elif option == SortChoices.YES: + return '\n'.join(sort_string_seq(a_lines, case_sensitive)), \ + '\n'.join(sort_string_seq(a_lines, case_sensitive)) + elif option == SortChoices.BY_SOURCE: + return self.a, '\n'.join(sort_string_seq_by_other(b_lines, a_lines, case_sensitive)) + elif option == SortChoices.BY_TARGET: + return '\n'.join(sort_string_seq_by_other(a_lines, b_lines, case_sensitive)), self.b + + def generate_diff(self): + """ + Takes parameters info from widgets and generates diff for intput texts. + """ + src_yview = self.text_source.yview() + tgt_yview = self.text_target.yview() + self.texts.configure(state='normal') + self.texts.delete('1.0', tk.END) + + a, b = self.handle_sort() + cutoff = self.scale_cutoff_value.get() + line_sm = seq_matcher_factory(sm_choice_to_factory_name[self.combo_line_sm.get()])() + inline_sm = seq_matcher_factory(sm_choice_to_factory_name[self.combo_in_line_sm.get()])() + a_lines, b_lines, opcodes = diff_lines_with_similarities( + a=a, b=b, cutoff=cutoff, line_sm=line_sm, inline_sm=inline_sm, keepends=True, + case_sensitive=self.case_sensitive.get()) + + for opcode in opcodes: + tag, i1, i2, j1, j2 = opcode + left_len = i2 - i1 + right_len = j2 - j1 + max_len = max(left_len, right_len) + + if tag == 'replace': + if isinstance(opcode, CompositeOpCode) and opcode.children_opcodes: + for inline_opcode in opcode.children_opcodes: + il_tag, il_i1, il_i2, il_j1, il_j2 = inline_opcode + il_text_tag = inline_opcode_tag_to_text_tag[il_tag].value + self.text_source.insert('end', a_lines[i1][il_i1:il_i2], (il_text_tag,)) + self.text_target.insert('end', b_lines[j1][il_j1:il_j2], (il_text_tag,)) + continue + + else: + delete_tag = line_opcode_tag_to_text_tag['delete'].value + insert_tag = line_opcode_tag_to_text_tag['insert'].value + left_tags = [delete_tag] * left_len + [None] * (max_len - left_len) + right_tags = [insert_tag] * right_len + [None] * (max_len - right_len) + else: + text_tag = line_opcode_tag_to_text_tag[tag].value + left_tags = [text_tag] * left_len + [None] * (max_len - left_len) + right_tags = [text_tag] * right_len + [None] * (max_len - right_len) + + for left_line, right_line, left_text_tag, right_text_tag in \ + zip_longest(a_lines[i1:i2], b_lines[j1:j2], left_tags, right_tags, fillvalue='\n'): + self.text_source.insert('end', left_line, (left_text_tag,)) + self.text_target.insert('end', right_line, (right_text_tag,)) + + self.texts.configure(state='disabled') + self.text_source.yview_moveto(src_yview[0]) + self.text_target.yview_moveto(tgt_yview[0]) + + def sort_by_selection(self, event=None): + """Triggered on Sort By: combo box selection""" + pass + + def line_sm_selection(self, event=None): + """Triggered on Line SM: combo box selection""" + pass + + def in_line_sm_selection(self, event=None): + """Triggered on InLine SM: combo box selection""" + pass + + def on_yscrollcommand_source(self, first, last): + if not self.has_text_scrollbars_the_same_position(): + view_pos = self.text_source.yview() + self.text_target.yview_moveto(view_pos[0]) + + def on_xscrollcommand_source(self, first, last): + if not self.has_text_scrollbars_the_same_position(): + view_pos = self.text_source.xview() + self.text_target.xview_moveto(view_pos[0]) + + def on_yscrollcommand_target(self, first, last): + if not self.has_text_scrollbars_the_same_position(): + view_pos = self.text_target.yview() + self.text_source.yview_moveto(view_pos[0]) + + def on_xscrollcommand_target(self, first, last): + if not self.has_text_scrollbars_the_same_position(): + view_pos = self.text_target.xview() + self.text_source.xview_moveto(view_pos[0]) + + def has_text_scrollbars_the_same_position(self): + return self.text_source.hbar.get() == self.text_target.hbar.get() and \ + self.text_source.vbar.get() == self.text_target.vbar.get() + + +class DiffResultWindowBuilder(WindowBuilder): + """ + Builds Diff Result window. It takes Tk or TopLevel window class as parent window, and content frame + (DiffResult) as parameters. + + Reason for wrapping is that Diff Result is sub window (TopLevel window) in main GUI application, but it's + main window (Tk - root window) if called from CLI. It allows to reduce code duplication if the same window + settings are used for both Tk and TopLevel. + """ + + def __init__(self, window: Union[tk.Tk, tk.Toplevel], content: tk.Frame): + super().__init__(window, content) + self.content.grid(column=0, row=0, sticky='nsew') + self.window.rowconfigure(0, weight=1) + self.window.columnconfigure(0, weight=1) + + self.window.option_add('*tearOff', tk.FALSE) + self.menu_bar = tk.Menu(self.window) + self.window['menu'] = self.menu_bar + + self.menu_file = tk.Menu(self.menu_bar) + self.menu_bar.add_cascade(menu=self.menu_file, label='File', underline=0) + self.menu_file.add_command(label='Close', command=lambda: self.window.destroy()) + + self.menu_edit = tk.Menu(self.menu_bar) + self.menu_bar.add_cascade(menu=self.menu_edit, label='Edit', underline=0) + self.menu_edit.add_command(label='Copy', accelerator='Ctrl+C', + command=lambda: self.window.focus_get().event_generate("<>")) diff --git a/mdiff/visualisation/gui_tkinter/main.py b/mdiff/visualisation/gui_tkinter/main.py new file mode 100644 index 0000000..cf16147 --- /dev/null +++ b/mdiff/visualisation/gui_tkinter/main.py @@ -0,0 +1,10 @@ +from mdiff.visualisation.gui_tkinter.diff_input import DiffInputWindow + + +def start_app(): + root = DiffInputWindow() + root.mainloop() + + +if __name__ == '__main__': + start_app() diff --git a/mdiff/visualisation/gui_tkinter/utils.py b/mdiff/visualisation/gui_tkinter/utils.py new file mode 100644 index 0000000..f5f641c --- /dev/null +++ b/mdiff/visualisation/gui_tkinter/utils.py @@ -0,0 +1,63 @@ +import tkinter as tk +from abc import ABC, abstractmethod +from typing import Union + + +class ScrolledText(tk.Text): + """ + Wraps tkinter Text widget with scrollbars. + """ + + def __init__(self, master=None, **kw): + self.frame = tk.Frame(master) + + self.vbar = tk.Scrollbar(self.frame) + self.vbar.pack(side=tk.RIGHT, fill=tk.Y) + + self.hbar = tk.Scrollbar(self.frame, orient=tk.HORIZONTAL) + self.hbar.pack(side=tk.BOTTOM, fill=tk.X) + + kw.update({'yscrollcommand': self._yscrollcommand}) + kw.update({'xscrollcommand': self._xscrollcommand}) + + tk.Text.__init__(self, self.frame, **kw) + + self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + self.vbar['command'] = self.yview + self.hbar['command'] = self.xview + + # Copy geometry methods of self.frame without overriding Text + # methods -- hack! + text_meths = vars(tk.Text).keys() + methods = vars(tk.Pack).keys() | vars(tk.Grid).keys() | vars(tk.Place).keys() + methods = methods.difference(text_meths) + + for m in methods: + if m[0] != '_' and m != 'config' and m != 'configure': + setattr(self, m, getattr(self.frame, m)) + + self.on_yscrollcommand = lambda x, y: None + self.on_xscrollcommand = lambda x, y: None + + def _yscrollcommand(self, first, last): + self.vbar.set(first, last) + self.on_yscrollcommand(first, last) + + def _xscrollcommand(self, first, last): + self.hbar.set(first, last) + self.on_xscrollcommand(first, last) + + def __str__(self): + return str(self.frame) + + +class WindowBuilder(ABC): + """ + Wrapper class for tkinter window. A window might be instance of Tk or TopLevel class. + It can be defined to build single content frame into any of those two window types + without duplicating code, as Tk and TopLevel has the same interfaces (for example: resizing, menu bars etc.) + """ + def __init__(self, window: Union[tk.Tk, tk.Toplevel], content: tk.Frame): + self.window = window + self.content = content diff --git a/mdiff/visualisation/terminal.py b/mdiff/visualisation/terminal.py index a1a6cc4..6065e09 100644 --- a/mdiff/visualisation/terminal.py +++ b/mdiff/visualisation/terminal.py @@ -7,7 +7,7 @@ import colorama -from mdiff.utils import len_or_1, OpCodesType, CompositeOpCode, OpCodeType, OpCode +from mdiff.utils import CompositeOpCode, OpCodeType, OpCode STYLE_RESET = colorama.Style.RESET_ALL + colorama.Fore.RESET + colorama.Back.RESET @@ -398,7 +398,7 @@ def print_opcode(self, opcode: OpCodeType): ) def print(self): - # colorama.init(autoreset=False, convert=True) + colorama.init(autoreset=False, convert=True) for opcode in self.seq: self.print_opcode(opcode) print(colorama.Fore.RESET + colorama.Back.RESET) diff --git a/pyinstaller_base.spec b/pyinstaller_base.spec new file mode 100644 index 0000000..7780843 --- /dev/null +++ b/pyinstaller_base.spec @@ -0,0 +1,57 @@ +# -*- mode: python ; coding: utf-8 -*- +import platform +from pathlib import Path + +block_cipher = None + +APP_MODULE_PATH = Path('mdiff/visualisation/gui_tkinter/main.py') + +with open('VERSION') as f: + VERSION = f.read() + +a = Analysis([str(APP_MODULE_PATH)], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='mdiff', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None) +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='mdiff') + +if platform.system() == 'Darwin': + print('Building for macos') + app = BUNDLE(coll, + version=VERSION, + name='mdiff.app', + icon=None, + bundle_identifier=None) diff --git a/pyinstaller_start.sh b/pyinstaller_start.sh new file mode 100755 index 0000000..328eda5 --- /dev/null +++ b/pyinstaller_start.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# launch script from project root directory + +pyinstaller \ + --clean \ + --noconfirm \ + --distpath build/dist \ + --workpath build/tmp \ + pyinstaller_base.spec \ No newline at end of file diff --git a/resources/readme/mdiff_gui1.png b/resources/readme/mdiff_gui1.png new file mode 100644 index 0000000..3cd8cf9 Binary files /dev/null and b/resources/readme/mdiff_gui1.png differ diff --git a/setup.py b/setup.py index 4f7820b..884742d 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,20 @@ import setuptools +from mdiff.utils import get_app_version + def readme(): with open('README.md') as f: return f.read() +with open('VERSION') as f: + VERSION = f.read() + setuptools.setup( name='mdiff', packages=setuptools.find_packages(), - version='0.0.5', + version=VERSION, license='MIT', description='Sequence matcher with displacement detection.', long_description=readme(), @@ -19,7 +24,10 @@ def readme(): url='https://github.com/m-matelski/mdiff', keywords=['sequence', 'diff', 'heckel', 'text'], entry_points={ - 'console_scripts': ['mdiff=mdiff.cli:main'], + 'console_scripts': [ + 'mdiff=mdiff.cli:main', + 'mdiff-gui=mdiff.visualisation.gui_tkinter.main:start_app' + ] }, extras_require={ 'cli': ['colorama==0.4.*', 'typer==0.4.*'] diff --git a/tests/resources/compares/comp4/a.txt b/tests/resources/compares/comp4/a.txt new file mode 100644 index 0000000..f1d7f87 --- /dev/null +++ b/tests/resources/compares/comp4/a.txt @@ -0,0 +1,5 @@ +line1 +Line3 +LINE2 +line4 +line5 \ No newline at end of file diff --git a/tests/resources/compares/comp4/b.txt b/tests/resources/compares/comp4/b.txt new file mode 100644 index 0000000..d646439 --- /dev/null +++ b/tests/resources/compares/comp4/b.txt @@ -0,0 +1,5 @@ +LINE1 +line2 +line3 +line4 +line5 \ No newline at end of file diff --git a/tests/resources/compares/comp5/a.txt b/tests/resources/compares/comp5/a.txt new file mode 100644 index 0000000..1af6ef3 --- /dev/null +++ b/tests/resources/compares/comp5/a.txt @@ -0,0 +1,35 @@ +# This is a basic workflow to help you get started with Actions + +name: Build Application Artifact Windows + +# Controls when the action will run. +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Build with pyinstaller + run: | + ./pyinstaller_start.sh + + - name: Upload Artifact + uses: actions/upload-artifact@v2 + with: + name: SqlDiff_artifact_ubuntu + path: dist/SqlDiff diff --git a/tests/resources/compares/comp5/b.txt b/tests/resources/compares/comp5/b.txt new file mode 100644 index 0000000..47498e1 --- /dev/null +++ b/tests/resources/compares/comp5/b.txt @@ -0,0 +1,60 @@ +name: Release Deploy + + +on: + release: + types: + - created + +jobs: + deploy: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + VERSION: $(cat VERSION) + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + + - name: Windows build + if: matrix.os == 'windows-latest' + run: | + bash pyinstaller_start.sh + 7z a -tzip SqlDiff-win-${{ env.VERSION }}.zip .\build\dist\* + + - name: MacOs build + if: matrix.os == 'macos-latest' + run: | + ./pyinstaller_start.sh + cp VERSION build/dist + cd build/dist + zip SqlDiff-macos-${{ env.VERSION }}.zip SqlDiff* -r + cd ../.. + cp -r build/dist/SqlDiff*.zip . + + - name: Ubuntu build + if: matrix.os == 'ubuntu-latest' + run: | + ./pyinstaller_start.sh + cp build/dist/SqlDiff -r . + zip SqlDiff-ubuntu-${{ env.VERSION }}.zip SqlDiff -r + + + - name: Upload asset + uses: softprops/action-gh-release@v1 + with: + files: SqlDiff*.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f25ed1..3ea41e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,13 +13,15 @@ class TestCLI(unittest.TestCase): def test_cli_run_using_arguments(self): """Test if diff works from cli level""" - cli_diff(source_file=Path('tests/resources/compares/comp1/a.txt'), - target_file=Path('tests/resources/compares/comp1/b.txt'), + cli_diff(source_file=Path('tests/resources/compares/comp4/a.txt'), + target_file=Path('tests/resources/compares/comp4/b.txt'), line_sm=SequenceMatcherName.HECKEL, inline_sm=SequenceMatcherName.STANDARD, - cutoff=0.75, + cutoff=0.5, char_mode=CharacterMode.UTF8, - color_mode=ColorMode.FORE + color_mode=ColorMode.FORE, + gui=False, + case_sensitive=True ) def test_cli_run(self): @@ -29,3 +31,4 @@ def test_cli_run(self): result = runner.invoke(app, ['tests/resources/compares/comp1/a.txt', 'tests/resources/compares/comp1/b.txt']) self.assertEqual(result.exit_code, 0) + diff --git a/tests/test_differ.py b/tests/test_differ.py new file mode 100644 index 0000000..8cb951c --- /dev/null +++ b/tests/test_differ.py @@ -0,0 +1,22 @@ +import unittest +from pathlib import Path + +from mdiff.differ import ConsoleTextDiffer +from mdiff.seqmatch.utils import SequenceMatcherName +from mdiff.utils import read_file + + +class TestConsoleTextDiffer(unittest.TestCase): + + def test_differ(self): + source = read_file(Path('tests/resources/compares/comp2/a.txt')) + target = read_file(Path('tests/resources/compares/comp2/b.txt')) + differ = ConsoleTextDiffer(a=source, + b=target, + line_sm=SequenceMatcherName.HECKEL, + inline_sm=SequenceMatcherName.HECKEL, + cutoff=0.75, + color_mode='back', + character_mode='ascii', + case_sensitive=True) + differ.run() diff --git a/tests/test_text_diff.py b/tests/test_text_diff.py index 5b431c8..4c4ed09 100644 --- a/tests/test_text_diff.py +++ b/tests/test_text_diff.py @@ -16,3 +16,10 @@ def test1(self): sm = SequenceMatcher(a=a_lines, b=b_lines) hopcodes = sm.get_opcodes() x = 1 + + def test2(self): + a = read_file(Path('tests/resources/compares/comp5/a.txt')) + b = read_file(Path('tests/resources/compares/comp5/b.txt')) + a_lines, b_lines, opcodes = diff_lines_with_similarities(a, b) + sm = SequenceMatcher(a=a_lines, b=b_lines) + hopcodes = sm.get_opcodes() \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..405af1f --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,91 @@ +import unittest +from enum import Enum + +from mdiff.utils import CompositeDelegationMixin, sort_seq_by_other_seq, sort_seq_by_other_seq_indexes, \ + sort_string_seq_by_other + + +class TestCompositeDelegationMixin(unittest.TestCase): + def test_attributes_and_method_delegation(self): + # base class + class MyClass: + def __init__(self, a): + self.a = a + + self.b = self.choose_b() + + def choose_b(self): + if self.a > 10: + return 'a>10' + return 'a<10' + + def set_a(self, new_a): + self.a = new_a + + def print_a(self): + print(self.a) + + def return_a(self): + return self.a + + # composite class from base + class MyClassComposite(MyClass, CompositeDelegationMixin): + def __init__(self, *args, **kwargs): + super().__init__(a=0) + CompositeDelegationMixin.__init__(self) + + mc1 = MyClass(1) + mc2 = MyClass(2) + mcc = MyClassComposite() + mcc.__children__.extend([mc1, mc2]) + + # call method on children + return_a = mcc.return_a() + self.assertEqual(return_a, [1, 2]) + + # set value for all children + mcc.set_a(3) + # get value directly by attribute + a_attribute = mcc.a + self.assertEqual(a_attribute, [3, 3]) + + with self.assertRaises(AttributeError): + mcc.non_existing_attribute + + with self.assertRaises(AttributeError): + mcc.non_existing_method() + + +class TestSortSequenceByOther(unittest.TestCase): + def test_sort_sequence_on_unique_values(self): + a = [6, 5, 4, 2, 3] + b = [1, 2, 3, 4] + result = sort_seq_by_other_seq(a, b) + expected = [2, 3, 4, 6, 5] + self.assertEqual(expected, result) + + def test_sort_sequence(self): + a = [5, 4, 3, 5, 1, 2] + b = [1, 3, 1, 5, 6, 1, 4] + result = sort_seq_by_other_seq(a, b) + expected = [1, 3, 5, 4, 5, 2] + self.assertEqual(expected, result) + + def test_sort_sequence_indexes(self): + a = ['F', 'E', 'D', 'E'] + b = ['A', 'B', 'E', 'D'] + result = sort_seq_by_other_seq_indexes(a, b) + expected = [(1, 'E'), (2, 'D'), (0, 'F'), (3, 'E')] + self.assertEqual(expected, result) + + def test_sort_str_sequence_case_sensitive(self): + a = ['a', 'C', 'b', 'A'] + b = ['A', 'B', 'E', 'D'] + + result = sort_string_seq_by_other(a, b, case_sensitive=True) + expected = ['A', 'a', 'C', 'b'] + self.assertEqual(expected, result) + + result = sort_string_seq_by_other(a, b, case_sensitive=False) + expected = ['a', 'b', 'C', 'A'] + self.assertEqual(expected, result)