diff-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diff-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: File and text differ with multiple output formats
|
|
5
|
+
Project-URL: Homepage, https://github.com/marcusbuildsthings-droid/diff-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/marcusbuildsthings-droid/diff-cli
|
|
7
|
+
Author-email: Marcus <marcus.builds.things@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,compare,diff,side-by-side,text,unified
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Text Processing :: General
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# diff-cli
|
|
27
|
+
|
|
28
|
+
File and text differ with multiple output formats.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install diff-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Compare files
|
|
40
|
+
diff-cli files file1.txt file2.txt
|
|
41
|
+
|
|
42
|
+
# Compare text strings
|
|
43
|
+
diff-cli text "hello world" "hello there"
|
|
44
|
+
|
|
45
|
+
# Compare directories
|
|
46
|
+
diff-cli dirs dir1/ dir2/
|
|
47
|
+
|
|
48
|
+
# Output formats
|
|
49
|
+
diff-cli files a.txt b.txt --unified # Default
|
|
50
|
+
diff-cli files a.txt b.txt --side-by-side # Two-column
|
|
51
|
+
diff-cli files a.txt b.txt --context # Context format
|
|
52
|
+
diff-cli files a.txt b.txt --minimal # Changes only
|
|
53
|
+
diff-cli files a.txt b.txt --json # JSON for automation
|
|
54
|
+
|
|
55
|
+
# Options
|
|
56
|
+
diff-cli files a.txt b.txt -n 5 # 5 context lines
|
|
57
|
+
diff-cli files a.txt b.txt --ignore-whitespace
|
|
58
|
+
diff-cli files a.txt b.txt --ignore-case
|
|
59
|
+
diff-cli files a.txt b.txt --ignore-blank-lines
|
|
60
|
+
|
|
61
|
+
# Watch mode
|
|
62
|
+
diff-cli watch file1.txt file2.txt
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Exit Codes
|
|
66
|
+
|
|
67
|
+
| Code | Meaning |
|
|
68
|
+
|------|---------|
|
|
69
|
+
| 0 | Files are identical |
|
|
70
|
+
| 1 | Files differ |
|
|
71
|
+
| 2 | Error (file not found, etc.) |
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
diffcli/__init__.py,sha256=iLGalpEvIHOEaUKrqInA-4WTvvoKanHxP50mI6vf9VQ,89
|
|
2
|
+
diffcli/cli.py,sha256=9f2w6U-Fqol0Cr6aDZEWbPxRCb53LrGvtU-Ci4C8oPc,18218
|
|
3
|
+
diff_cli-0.1.0.dist-info/METADATA,sha256=xZ3D6WNa7a2t3M4XjlbWgfi4RSG6AOzIfKC_Ma5f5RQ,2043
|
|
4
|
+
diff_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
diff_cli-0.1.0.dist-info/entry_points.txt,sha256=s6vMqHwxJ90b7Q68i8Sp8F-G_A9MhITztu7JOEESAEA,45
|
|
6
|
+
diff_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9tNBpWq8KGbuJqmeComp40OiNnbvpvsKn1YP26PUtck,1063
|
|
7
|
+
diff_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
diffcli/__init__.py
ADDED
diffcli/cli.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""diff-cli: File and text differ with multiple output formats."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import difflib
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, List, Dict, Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def colorize_diff_line(line: str, color_enabled: bool = True) -> str:
|
|
17
|
+
"""Add color to diff lines if color is enabled."""
|
|
18
|
+
if not color_enabled or not sys.stdout.isatty():
|
|
19
|
+
return line
|
|
20
|
+
|
|
21
|
+
if line.startswith('+++') or line.startswith('---'):
|
|
22
|
+
return click.style(line, fg='white', bold=True)
|
|
23
|
+
elif line.startswith('@@'):
|
|
24
|
+
return click.style(line, fg='cyan', bold=True)
|
|
25
|
+
elif line.startswith('+'):
|
|
26
|
+
return click.style(line, fg='green')
|
|
27
|
+
elif line.startswith('-'):
|
|
28
|
+
return click.style(line, fg='red')
|
|
29
|
+
elif line.startswith('?'):
|
|
30
|
+
return click.style(line, fg='yellow')
|
|
31
|
+
else:
|
|
32
|
+
return line
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_text(text: str, ignore_whitespace: bool = False,
|
|
36
|
+
ignore_case: bool = False, ignore_blank_lines: bool = False) -> str:
|
|
37
|
+
"""Normalize text according to ignore options."""
|
|
38
|
+
if ignore_case:
|
|
39
|
+
text = text.lower()
|
|
40
|
+
|
|
41
|
+
if ignore_blank_lines:
|
|
42
|
+
lines = [line for line in text.split('\n') if line.strip()]
|
|
43
|
+
text = '\n'.join(lines)
|
|
44
|
+
|
|
45
|
+
if ignore_whitespace:
|
|
46
|
+
# Replace multiple whitespace with single space, strip line ends
|
|
47
|
+
lines = [' '.join(line.split()) for line in text.split('\n')]
|
|
48
|
+
text = '\n'.join(lines)
|
|
49
|
+
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def files_are_identical(file1: Path, file2: Path, ignore_whitespace: bool = False,
|
|
54
|
+
ignore_case: bool = False, ignore_blank_lines: bool = False) -> bool:
|
|
55
|
+
"""Check if two files are identical after normalization."""
|
|
56
|
+
try:
|
|
57
|
+
with open(file1, 'r', encoding='utf-8') as f1, open(file2, 'r', encoding='utf-8') as f2:
|
|
58
|
+
text1 = normalize_text(f1.read(), ignore_whitespace, ignore_case, ignore_blank_lines)
|
|
59
|
+
text2 = normalize_text(f2.read(), ignore_whitespace, ignore_case, ignore_blank_lines)
|
|
60
|
+
return text1 == text2
|
|
61
|
+
except UnicodeDecodeError:
|
|
62
|
+
# Binary comparison for non-text files
|
|
63
|
+
with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
|
|
64
|
+
return f1.read() == f2.read()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unified_diff(text1: str, text2: str, filename1: str = 'file1',
|
|
68
|
+
filename2: str = 'file2', context: int = 3) -> List[str]:
|
|
69
|
+
"""Generate unified diff between two texts."""
|
|
70
|
+
lines1 = text1.splitlines(keepends=True)
|
|
71
|
+
lines2 = text2.splitlines(keepends=True)
|
|
72
|
+
|
|
73
|
+
return list(difflib.unified_diff(
|
|
74
|
+
lines1, lines2,
|
|
75
|
+
fromfile=filename1,
|
|
76
|
+
tofile=filename2,
|
|
77
|
+
n=context
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def side_by_side_diff(text1: str, text2: str, width: int = 80) -> List[str]:
|
|
82
|
+
"""Generate side-by-side diff between two texts."""
|
|
83
|
+
lines1 = text1.splitlines()
|
|
84
|
+
lines2 = text2.splitlines()
|
|
85
|
+
|
|
86
|
+
max_len = max(len(lines1), len(lines2))
|
|
87
|
+
col_width = width // 2 - 2
|
|
88
|
+
|
|
89
|
+
result = []
|
|
90
|
+
result.append(f"{'File 1':<{col_width}} | {'File 2':<{col_width}}")
|
|
91
|
+
result.append(f"{'-' * col_width} | {'-' * col_width}")
|
|
92
|
+
|
|
93
|
+
for i in range(max_len):
|
|
94
|
+
left = lines1[i] if i < len(lines1) else ""
|
|
95
|
+
right = lines2[i] if i < len(lines2) else ""
|
|
96
|
+
|
|
97
|
+
# Truncate long lines
|
|
98
|
+
if len(left) > col_width:
|
|
99
|
+
left = left[:col_width-3] + "..."
|
|
100
|
+
if len(right) > col_width:
|
|
101
|
+
right = right[:col_width-3] + "..."
|
|
102
|
+
|
|
103
|
+
# Determine line status
|
|
104
|
+
if i >= len(lines1):
|
|
105
|
+
marker = "+"
|
|
106
|
+
elif i >= len(lines2):
|
|
107
|
+
marker = "-"
|
|
108
|
+
elif left != right:
|
|
109
|
+
marker = "!"
|
|
110
|
+
else:
|
|
111
|
+
marker = " "
|
|
112
|
+
|
|
113
|
+
result.append(f"{left:<{col_width}} {marker} {right:<{col_width}}")
|
|
114
|
+
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def context_diff(text1: str, text2: str, filename1: str = 'file1',
|
|
119
|
+
filename2: str = 'file2', context: int = 3) -> List[str]:
|
|
120
|
+
"""Generate context diff between two texts."""
|
|
121
|
+
lines1 = text1.splitlines(keepends=True)
|
|
122
|
+
lines2 = text2.splitlines(keepends=True)
|
|
123
|
+
|
|
124
|
+
return list(difflib.context_diff(
|
|
125
|
+
lines1, lines2,
|
|
126
|
+
fromfile=filename1,
|
|
127
|
+
tofile=filename2,
|
|
128
|
+
n=context
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def minimal_diff(text1: str, text2: str) -> Dict[str, Any]:
|
|
133
|
+
"""Generate minimal diff summary."""
|
|
134
|
+
lines1 = text1.splitlines()
|
|
135
|
+
lines2 = text2.splitlines()
|
|
136
|
+
|
|
137
|
+
if lines1 == lines2:
|
|
138
|
+
return {
|
|
139
|
+
"identical": True,
|
|
140
|
+
"changes": 0,
|
|
141
|
+
"additions": 0,
|
|
142
|
+
"deletions": 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Use difflib to get a basic comparison
|
|
146
|
+
diff = list(difflib.unified_diff(lines1, lines2, n=0))
|
|
147
|
+
|
|
148
|
+
additions = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
|
|
149
|
+
deletions = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"identical": False,
|
|
153
|
+
"changes": additions + deletions,
|
|
154
|
+
"additions": additions,
|
|
155
|
+
"deletions": deletions,
|
|
156
|
+
"total_lines_1": len(lines1),
|
|
157
|
+
"total_lines_2": len(lines2)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def scan_directory(directory: Path, recursive: bool = True) -> List[Path]:
|
|
162
|
+
"""Scan directory for files."""
|
|
163
|
+
files = []
|
|
164
|
+
if recursive:
|
|
165
|
+
for root, dirs, filenames in os.walk(directory):
|
|
166
|
+
for filename in filenames:
|
|
167
|
+
files.append(Path(root) / filename)
|
|
168
|
+
else:
|
|
169
|
+
for item in directory.iterdir():
|
|
170
|
+
if item.is_file():
|
|
171
|
+
files.append(item)
|
|
172
|
+
return sorted(files)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compare_directories(dir1: Path, dir2: Path, recursive: bool = True) -> Dict[str, Any]:
|
|
176
|
+
"""Compare two directories."""
|
|
177
|
+
files1 = {f.relative_to(dir1): f for f in scan_directory(dir1, recursive)}
|
|
178
|
+
files2 = {f.relative_to(dir2): f for f in scan_directory(dir2, recursive)}
|
|
179
|
+
|
|
180
|
+
all_files = set(files1.keys()) | set(files2.keys())
|
|
181
|
+
only_in_1 = set(files1.keys()) - set(files2.keys())
|
|
182
|
+
only_in_2 = set(files2.keys()) - set(files1.keys())
|
|
183
|
+
common_files = set(files1.keys()) & set(files2.keys())
|
|
184
|
+
|
|
185
|
+
different_files = []
|
|
186
|
+
identical_files = []
|
|
187
|
+
|
|
188
|
+
for rel_path in common_files:
|
|
189
|
+
file1 = files1[rel_path]
|
|
190
|
+
file2 = files2[rel_path]
|
|
191
|
+
|
|
192
|
+
if files_are_identical(file1, file2):
|
|
193
|
+
identical_files.append(str(rel_path))
|
|
194
|
+
else:
|
|
195
|
+
different_files.append(str(rel_path))
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"total_files": len(all_files),
|
|
199
|
+
"only_in_first": sorted([str(f) for f in only_in_1]),
|
|
200
|
+
"only_in_second": sorted([str(f) for f in only_in_2]),
|
|
201
|
+
"different": sorted(different_files),
|
|
202
|
+
"identical": sorted(identical_files),
|
|
203
|
+
"summary": {
|
|
204
|
+
"only_in_first": len(only_in_1),
|
|
205
|
+
"only_in_second": len(only_in_2),
|
|
206
|
+
"different": len(different_files),
|
|
207
|
+
"identical": len(identical_files)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@click.group()
|
|
213
|
+
@click.version_option(version="0.1.0", prog_name="diff-cli")
|
|
214
|
+
def cli():
|
|
215
|
+
"""diff-cli: File and text differ with multiple output formats."""
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@cli.command()
|
|
220
|
+
@click.argument('file1', type=click.Path(exists=True, path_type=Path))
|
|
221
|
+
@click.argument('file2', type=click.Path(exists=True, path_type=Path))
|
|
222
|
+
@click.option('--format', '-f', type=click.Choice(['unified', 'side-by-side', 'context', 'minimal']),
|
|
223
|
+
default='unified', help='Diff output format')
|
|
224
|
+
@click.option('--context', '-n', default=3, help='Number of context lines')
|
|
225
|
+
@click.option('--ignore-whitespace', '-w', is_flag=True, help='Ignore whitespace differences')
|
|
226
|
+
@click.option('--ignore-case', '-i', is_flag=True, help='Ignore case differences')
|
|
227
|
+
@click.option('--ignore-blank-lines', '-B', is_flag=True, help='Ignore blank lines')
|
|
228
|
+
@click.option('--no-color', is_flag=True, help='Disable colored output')
|
|
229
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output in JSON format')
|
|
230
|
+
def files(file1: Path, file2: Path, format: str, context: int,
|
|
231
|
+
ignore_whitespace: bool, ignore_case: bool, ignore_blank_lines: bool,
|
|
232
|
+
no_color: bool, output_json: bool):
|
|
233
|
+
"""Compare two files."""
|
|
234
|
+
try:
|
|
235
|
+
with open(file1, 'r', encoding='utf-8') as f1, open(file2, 'r', encoding='utf-8') as f2:
|
|
236
|
+
text1 = normalize_text(f1.read(), ignore_whitespace, ignore_case, ignore_blank_lines)
|
|
237
|
+
text2 = normalize_text(f2.read(), ignore_whitespace, ignore_case, ignore_blank_lines)
|
|
238
|
+
except UnicodeDecodeError:
|
|
239
|
+
if output_json:
|
|
240
|
+
click.echo(json.dumps({"error": "Binary files cannot be compared in text mode", "exit_code": 2}))
|
|
241
|
+
else:
|
|
242
|
+
click.echo("Error: Binary files cannot be compared in text mode", err=True)
|
|
243
|
+
sys.exit(2)
|
|
244
|
+
|
|
245
|
+
# Check if files are identical
|
|
246
|
+
identical = text1 == text2
|
|
247
|
+
|
|
248
|
+
if output_json:
|
|
249
|
+
result = {
|
|
250
|
+
"files": [str(file1), str(file2)],
|
|
251
|
+
"identical": identical,
|
|
252
|
+
"format": format
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if format == 'minimal':
|
|
256
|
+
result.update(minimal_diff(text1, text2))
|
|
257
|
+
else:
|
|
258
|
+
if format == 'unified':
|
|
259
|
+
diff_lines = unified_diff(text1, text2, str(file1), str(file2), context)
|
|
260
|
+
elif format == 'side-by-side':
|
|
261
|
+
diff_lines = side_by_side_diff(text1, text2)
|
|
262
|
+
elif format == 'context':
|
|
263
|
+
diff_lines = context_diff(text1, text2, str(file1), str(file2), context)
|
|
264
|
+
|
|
265
|
+
result["diff"] = diff_lines
|
|
266
|
+
|
|
267
|
+
click.echo(json.dumps(result, indent=2))
|
|
268
|
+
else:
|
|
269
|
+
if identical:
|
|
270
|
+
click.echo("Files are identical")
|
|
271
|
+
else:
|
|
272
|
+
if format == 'minimal':
|
|
273
|
+
summary = minimal_diff(text1, text2)
|
|
274
|
+
click.echo(f"Files differ: {summary['changes']} changes ({summary['additions']} additions, {summary['deletions']} deletions)")
|
|
275
|
+
else:
|
|
276
|
+
if format == 'unified':
|
|
277
|
+
diff_lines = unified_diff(text1, text2, str(file1), str(file2), context)
|
|
278
|
+
elif format == 'side-by-side':
|
|
279
|
+
diff_lines = side_by_side_diff(text1, text2)
|
|
280
|
+
elif format == 'context':
|
|
281
|
+
diff_lines = context_diff(text1, text2, str(file1), str(file2), context)
|
|
282
|
+
|
|
283
|
+
color_enabled = not no_color
|
|
284
|
+
for line in diff_lines:
|
|
285
|
+
click.echo(colorize_diff_line(line.rstrip(), color_enabled))
|
|
286
|
+
|
|
287
|
+
sys.exit(0 if identical else 1)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@cli.command()
|
|
291
|
+
@click.argument('text1')
|
|
292
|
+
@click.argument('text2')
|
|
293
|
+
@click.option('--format', '-f', type=click.Choice(['unified', 'side-by-side', 'context', 'minimal']),
|
|
294
|
+
default='unified', help='Diff output format')
|
|
295
|
+
@click.option('--context', '-n', default=3, help='Number of context lines')
|
|
296
|
+
@click.option('--ignore-whitespace', '-w', is_flag=True, help='Ignore whitespace differences')
|
|
297
|
+
@click.option('--ignore-case', '-i', is_flag=True, help='Ignore case differences')
|
|
298
|
+
@click.option('--ignore-blank-lines', '-B', is_flag=True, help='Ignore blank lines')
|
|
299
|
+
@click.option('--no-color', is_flag=True, help='Disable colored output')
|
|
300
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output in JSON format')
|
|
301
|
+
def text(text1: str, text2: str, format: str, context: int,
|
|
302
|
+
ignore_whitespace: bool, ignore_case: bool, ignore_blank_lines: bool,
|
|
303
|
+
no_color: bool, output_json: bool):
|
|
304
|
+
"""Compare two text strings."""
|
|
305
|
+
normalized_text1 = normalize_text(text1, ignore_whitespace, ignore_case, ignore_blank_lines)
|
|
306
|
+
normalized_text2 = normalize_text(text2, ignore_whitespace, ignore_case, ignore_blank_lines)
|
|
307
|
+
|
|
308
|
+
identical = normalized_text1 == normalized_text2
|
|
309
|
+
|
|
310
|
+
if output_json:
|
|
311
|
+
result = {
|
|
312
|
+
"texts": [text1, text2],
|
|
313
|
+
"identical": identical,
|
|
314
|
+
"format": format
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if format == 'minimal':
|
|
318
|
+
result.update(minimal_diff(normalized_text1, normalized_text2))
|
|
319
|
+
else:
|
|
320
|
+
if format == 'unified':
|
|
321
|
+
diff_lines = unified_diff(normalized_text1, normalized_text2, "text1", "text2", context)
|
|
322
|
+
elif format == 'side-by-side':
|
|
323
|
+
diff_lines = side_by_side_diff(normalized_text1, normalized_text2)
|
|
324
|
+
elif format == 'context':
|
|
325
|
+
diff_lines = context_diff(normalized_text1, normalized_text2, "text1", "text2", context)
|
|
326
|
+
|
|
327
|
+
result["diff"] = diff_lines
|
|
328
|
+
|
|
329
|
+
click.echo(json.dumps(result, indent=2))
|
|
330
|
+
else:
|
|
331
|
+
if identical:
|
|
332
|
+
click.echo("Texts are identical")
|
|
333
|
+
else:
|
|
334
|
+
if format == 'minimal':
|
|
335
|
+
summary = minimal_diff(normalized_text1, normalized_text2)
|
|
336
|
+
click.echo(f"Texts differ: {summary['changes']} changes ({summary['additions']} additions, {summary['deletions']} deletions)")
|
|
337
|
+
else:
|
|
338
|
+
if format == 'unified':
|
|
339
|
+
diff_lines = unified_diff(normalized_text1, normalized_text2, "text1", "text2", context)
|
|
340
|
+
elif format == 'side-by-side':
|
|
341
|
+
diff_lines = side_by_side_diff(normalized_text1, normalized_text2)
|
|
342
|
+
elif format == 'context':
|
|
343
|
+
diff_lines = context_diff(normalized_text1, normalized_text2, "text1", "text2", context)
|
|
344
|
+
|
|
345
|
+
color_enabled = not no_color
|
|
346
|
+
for line in diff_lines:
|
|
347
|
+
click.echo(colorize_diff_line(line.rstrip(), color_enabled))
|
|
348
|
+
|
|
349
|
+
sys.exit(0 if identical else 1)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@cli.command()
|
|
353
|
+
@click.argument('dir1', type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
354
|
+
@click.argument('dir2', type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
355
|
+
@click.option('--recursive', '-r', is_flag=True, default=True, help='Compare directories recursively')
|
|
356
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output in JSON format')
|
|
357
|
+
def dirs(dir1: Path, dir2: Path, recursive: bool, output_json: bool):
|
|
358
|
+
"""Compare two directories."""
|
|
359
|
+
result = compare_directories(dir1, dir2, recursive)
|
|
360
|
+
|
|
361
|
+
if output_json:
|
|
362
|
+
click.echo(json.dumps(result, indent=2))
|
|
363
|
+
else:
|
|
364
|
+
summary = result["summary"]
|
|
365
|
+
click.echo(f"Directory comparison: {dir1} vs {dir2}")
|
|
366
|
+
click.echo(f"Total files: {result['total_files']}")
|
|
367
|
+
click.echo(f"Only in first: {summary['only_in_first']}")
|
|
368
|
+
click.echo(f"Only in second: {summary['only_in_second']}")
|
|
369
|
+
click.echo(f"Different: {summary['different']}")
|
|
370
|
+
click.echo(f"Identical: {summary['identical']}")
|
|
371
|
+
|
|
372
|
+
if result['only_in_first']:
|
|
373
|
+
click.echo(f"\nFiles only in {dir1}:")
|
|
374
|
+
for f in result['only_in_first']:
|
|
375
|
+
click.echo(f" - {f}")
|
|
376
|
+
|
|
377
|
+
if result['only_in_second']:
|
|
378
|
+
click.echo(f"\nFiles only in {dir2}:")
|
|
379
|
+
for f in result['only_in_second']:
|
|
380
|
+
click.echo(f" + {f}")
|
|
381
|
+
|
|
382
|
+
if result['different']:
|
|
383
|
+
click.echo(f"\nDifferent files:")
|
|
384
|
+
for f in result['different']:
|
|
385
|
+
click.echo(f" ! {f}")
|
|
386
|
+
|
|
387
|
+
# Exit with 1 if there are any differences
|
|
388
|
+
has_differences = (summary['only_in_first'] > 0 or
|
|
389
|
+
summary['only_in_second'] > 0 or
|
|
390
|
+
summary['different'] > 0)
|
|
391
|
+
sys.exit(1 if has_differences else 0)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@cli.command()
|
|
395
|
+
@click.argument('file1', type=click.Path(exists=True, path_type=Path))
|
|
396
|
+
@click.argument('file2', type=click.Path(exists=True, path_type=Path))
|
|
397
|
+
@click.option('--interval', '-i', default=1.0, help='Check interval in seconds')
|
|
398
|
+
@click.option('--format', '-f', type=click.Choice(['unified', 'side-by-side', 'context', 'minimal']),
|
|
399
|
+
default='unified', help='Diff output format')
|
|
400
|
+
@click.option('--no-color', is_flag=True, help='Disable colored output')
|
|
401
|
+
def watch(file1: Path, file2: Path, interval: float, format: str, no_color: bool):
|
|
402
|
+
"""Watch two files for changes and show diff when they change."""
|
|
403
|
+
click.echo(f"Watching {file1} and {file2} for changes (Ctrl+C to stop)...")
|
|
404
|
+
|
|
405
|
+
last_mtime1 = file1.stat().st_mtime if file1.exists() else 0
|
|
406
|
+
last_mtime2 = file2.stat().st_mtime if file2.exists() else 0
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
while True:
|
|
410
|
+
time.sleep(interval)
|
|
411
|
+
|
|
412
|
+
current_mtime1 = file1.stat().st_mtime if file1.exists() else 0
|
|
413
|
+
current_mtime2 = file2.stat().st_mtime if file2.exists() else 0
|
|
414
|
+
|
|
415
|
+
if current_mtime1 != last_mtime1 or current_mtime2 != last_mtime2:
|
|
416
|
+
click.echo(f"\n--- Change detected at {time.strftime('%Y-%m-%d %H:%M:%S')} ---")
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
with open(file1, 'r', encoding='utf-8') as f1, open(file2, 'r', encoding='utf-8') as f2:
|
|
420
|
+
text1 = f1.read()
|
|
421
|
+
text2 = f2.read()
|
|
422
|
+
|
|
423
|
+
if text1 == text2:
|
|
424
|
+
click.echo("Files are now identical")
|
|
425
|
+
else:
|
|
426
|
+
if format == 'minimal':
|
|
427
|
+
summary = minimal_diff(text1, text2)
|
|
428
|
+
click.echo(f"Files differ: {summary['changes']} changes")
|
|
429
|
+
else:
|
|
430
|
+
if format == 'unified':
|
|
431
|
+
diff_lines = unified_diff(text1, text2, str(file1), str(file2))
|
|
432
|
+
elif format == 'side-by-side':
|
|
433
|
+
diff_lines = side_by_side_diff(text1, text2)
|
|
434
|
+
elif format == 'context':
|
|
435
|
+
diff_lines = context_diff(text1, text2, str(file1), str(file2))
|
|
436
|
+
|
|
437
|
+
color_enabled = not no_color
|
|
438
|
+
for line in diff_lines:
|
|
439
|
+
click.echo(colorize_diff_line(line.rstrip(), color_enabled))
|
|
440
|
+
|
|
441
|
+
except Exception as e:
|
|
442
|
+
click.echo(f"Error reading files: {e}", err=True)
|
|
443
|
+
|
|
444
|
+
last_mtime1 = current_mtime1
|
|
445
|
+
last_mtime2 = current_mtime2
|
|
446
|
+
|
|
447
|
+
except KeyboardInterrupt:
|
|
448
|
+
click.echo("\nStopped watching.")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if __name__ == '__main__':
|
|
452
|
+
cli()
|