diff-cli 0.1.0__tar.gz

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,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
diff_cli-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,50 @@
1
+ # diff-cli
2
+
3
+ File and text differ with multiple output formats.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install diff-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Compare files
15
+ diff-cli files file1.txt file2.txt
16
+
17
+ # Compare text strings
18
+ diff-cli text "hello world" "hello there"
19
+
20
+ # Compare directories
21
+ diff-cli dirs dir1/ dir2/
22
+
23
+ # Output formats
24
+ diff-cli files a.txt b.txt --unified # Default
25
+ diff-cli files a.txt b.txt --side-by-side # Two-column
26
+ diff-cli files a.txt b.txt --context # Context format
27
+ diff-cli files a.txt b.txt --minimal # Changes only
28
+ diff-cli files a.txt b.txt --json # JSON for automation
29
+
30
+ # Options
31
+ diff-cli files a.txt b.txt -n 5 # 5 context lines
32
+ diff-cli files a.txt b.txt --ignore-whitespace
33
+ diff-cli files a.txt b.txt --ignore-case
34
+ diff-cli files a.txt b.txt --ignore-blank-lines
35
+
36
+ # Watch mode
37
+ diff-cli watch file1.txt file2.txt
38
+ ```
39
+
40
+ ## Exit Codes
41
+
42
+ | Code | Meaning |
43
+ |------|---------|
44
+ | 0 | Files are identical |
45
+ | 1 | Files differ |
46
+ | 2 | Error (file not found, etc.) |
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,262 @@
1
+ # diff-cli - Agent Skill
2
+
3
+ File and text differ with multiple output formats. Compare files, text strings, and directories with various diff formats and ignore options.
4
+
5
+ ## Quick Command Reference
6
+
7
+ | Command | Purpose | Key Options |
8
+ |---------|---------|-------------|
9
+ | `diff-cli files <file1> <file2>` | Compare two files | `--format`, `--ignore-*`, `--json` |
10
+ | `diff-cli text "<text1>" "<text2>"` | Compare text strings | `--format`, `--ignore-*`, `--json` |
11
+ | `diff-cli dirs <dir1> <dir2>` | Compare directories | `--recursive`, `--json` |
12
+ | `diff-cli watch <file1> <file2>` | Monitor files for changes | `--interval`, `--format` |
13
+
14
+ ## Command Details
15
+
16
+ ### File Comparison
17
+ ```bash
18
+ # Basic comparison with different formats
19
+ diff-cli files old.txt new.txt # Unified diff (default)
20
+ diff-cli files old.txt new.txt --format side-by-side
21
+ diff-cli files old.txt new.txt --format context
22
+ diff-cli files old.txt new.txt --format minimal
23
+
24
+ # Ignore options
25
+ diff-cli files file1.txt file2.txt --ignore-whitespace
26
+ diff-cli files FILE1.txt file2.txt --ignore-case
27
+ diff-cli files log1.txt log2.txt --ignore-blank-lines
28
+
29
+ # JSON output
30
+ diff-cli files config.json config.json.bak --json
31
+ ```
32
+
33
+ ### Text String Comparison
34
+ ```bash
35
+ # Direct text comparison
36
+ diff-cli text "hello world" "hello universe"
37
+ diff-cli text "line1\nline2" "line1\nmodified" --format unified
38
+ diff-cli text "TEXT" "text" --ignore-case --json
39
+ ```
40
+
41
+ ### Directory Comparison
42
+ ```bash
43
+ # Compare directory contents
44
+ diff-cli dirs project/ backup/
45
+ diff-cli dirs src/ dist/ --json
46
+ diff-cli dirs folder1/ folder2/ --recursive
47
+ ```
48
+
49
+ ### Watch Mode
50
+ ```bash
51
+ # Monitor files for changes
52
+ diff-cli watch config.ini config.ini.tmp
53
+ diff-cli watch log.txt rotated.log --interval 0.5 --format minimal
54
+ ```
55
+
56
+ ## Exit Codes
57
+
58
+ - **0**: Files/texts are identical
59
+ - **1**: Files/texts are different
60
+ - **2**: Error occurred (file not found, binary file, permission denied)
61
+
62
+ Always check exit codes when using in scripts:
63
+ ```bash
64
+ if diff-cli files file1.txt file2.txt --format minimal; then
65
+ echo "Files are identical"
66
+ else
67
+ echo "Files differ (exit code: $?)"
68
+ fi
69
+ ```
70
+
71
+ ## JSON Output Schemas
72
+
73
+ ### File/Text Comparison (`--json`)
74
+ ```json
75
+ {
76
+ "files": ["file1.txt", "file2.txt"], // Input files
77
+ "identical": false, // Boolean comparison result
78
+ "format": "unified", // Format used
79
+ "diff": [ // Diff lines (if not minimal)
80
+ "--- file1.txt",
81
+ "+++ file2.txt",
82
+ "@@ -1,1 +1,1 @@",
83
+ "-old line",
84
+ "+new line"
85
+ ]
86
+ }
87
+ ```
88
+
89
+ ### Minimal Format (`--format minimal --json`)
90
+ ```json
91
+ {
92
+ "files": ["file1.txt", "file2.txt"],
93
+ "identical": false,
94
+ "format": "minimal",
95
+ "changes": 2, // Total changes
96
+ "additions": 1, // Lines added
97
+ "deletions": 1, // Lines removed
98
+ "total_lines_1": 10, // Lines in file1
99
+ "total_lines_2": 10 // Lines in file2
100
+ }
101
+ ```
102
+
103
+ ### Directory Comparison (`--json`)
104
+ ```json
105
+ {
106
+ "total_files": 15,
107
+ "only_in_first": ["old_file.txt"], // Files only in dir1
108
+ "only_in_second": ["new_file.txt"], // Files only in dir2
109
+ "different": ["config.json", "readme.md"], // Files that differ
110
+ "identical": ["license.txt"], // Identical files
111
+ "summary": {
112
+ "only_in_first": 1,
113
+ "only_in_second": 1,
114
+ "different": 2,
115
+ "identical": 1
116
+ }
117
+ }
118
+ ```
119
+
120
+ ## Common Automation Patterns
121
+
122
+ ### Check if files are identical
123
+ ```bash
124
+ # Simple check
125
+ diff-cli files file1.txt file2.txt --format minimal >/dev/null
126
+ identical=$?
127
+
128
+ # With details
129
+ result=$(diff-cli files file1.txt file2.txt --format minimal --json)
130
+ identical=$(echo "$result" | jq -r '.identical')
131
+ ```
132
+
133
+ ### Get change statistics
134
+ ```bash
135
+ # Count changes
136
+ stats=$(diff-cli files old.txt new.txt --format minimal --json)
137
+ changes=$(echo "$stats" | jq -r '.changes')
138
+ additions=$(echo "$stats" | jq -r '.additions')
139
+ deletions=$(echo "$stats" | jq -r '.deletions')
140
+
141
+ echo "Changes: $changes (+$additions -$deletions)"
142
+ ```
143
+
144
+ ### Directory sync validation
145
+ ```bash
146
+ # Check if backup is complete
147
+ result=$(diff-cli dirs source/ backup/ --json)
148
+ different=$(echo "$result" | jq -r '.summary.different')
149
+ only_in_source=$(echo "$result" | jq -r '.summary.only_in_first')
150
+
151
+ if [ "$different" -eq 0 ] && [ "$only_in_source" -eq 0 ]; then
152
+ echo "Backup is complete and up-to-date"
153
+ fi
154
+ ```
155
+
156
+ ### Monitor configuration changes
157
+ ```bash
158
+ # Watch for config changes with logging
159
+ diff-cli watch config.ini config.ini.new --format minimal | while IFS= read -r line; do
160
+ echo "$(date): $line" >> config_changes.log
161
+ done
162
+ ```
163
+
164
+ ## Error Handling
165
+
166
+ Common error conditions:
167
+ - **File not found**: Exit code 2, stderr message
168
+ - **Permission denied**: Exit code 2, stderr message
169
+ - **Binary files**: Exit code 2, "Binary files cannot be compared" message
170
+ - **Directory not found**: Exit code 2, stderr message
171
+
172
+ Error output in JSON mode:
173
+ ```json
174
+ {
175
+ "error": "Binary files cannot be compared in text mode",
176
+ "exit_code": 2
177
+ }
178
+ ```
179
+
180
+ ## Integration Examples
181
+
182
+ ### With jq for processing
183
+ ```bash
184
+ # Extract only the added lines
185
+ diff-cli files old.txt new.txt --json | jq -r '.diff[] | select(startswith("+") and (startswith("+++") | not))'
186
+
187
+ # Get summary statistics
188
+ diff-cli dirs project/ backup/ --json | jq '.summary'
189
+ ```
190
+
191
+ ### In shell scripts
192
+ ```bash
193
+ #!/bin/bash
194
+ # Backup validation script
195
+
196
+ source_dir="$1"
197
+ backup_dir="$2"
198
+
199
+ result=$(diff-cli dirs "$source_dir" "$backup_dir" --json)
200
+ exit_code=$?
201
+
202
+ if [ $exit_code -eq 0 ]; then
203
+ echo "✓ Backup is identical to source"
204
+ elif [ $exit_code -eq 1 ]; then
205
+ different=$(echo "$result" | jq -r '.summary.different')
206
+ missing=$(echo "$result" | jq -r '.summary.only_in_first')
207
+ echo "⚠ Backup differs: $different files different, $missing files missing"
208
+ else
209
+ echo "✗ Error occurred during comparison"
210
+ fi
211
+
212
+ exit $exit_code
213
+ ```
214
+
215
+ ### In Python (subprocess)
216
+ ```python
217
+ import subprocess
218
+ import json
219
+
220
+ def compare_files(file1, file2, format='minimal'):
221
+ """Compare two files and return results."""
222
+ try:
223
+ result = subprocess.run([
224
+ 'diff-cli', 'files', file1, file2,
225
+ '--format', format, '--json'
226
+ ], capture_output=True, text=True, check=False)
227
+
228
+ if result.returncode in [0, 1]: # Success or diff found
229
+ return json.loads(result.stdout)
230
+ else: # Error occurred
231
+ return {"error": result.stderr, "exit_code": result.returncode}
232
+ except subprocess.SubprocessError as e:
233
+ return {"error": str(e), "exit_code": -1}
234
+
235
+ # Usage
236
+ diff_result = compare_files('file1.txt', 'file2.txt')
237
+ if diff_result.get('identical'):
238
+ print("Files are identical")
239
+ else:
240
+ print(f"Files differ: {diff_result.get('changes', 'unknown')} changes")
241
+ ```
242
+
243
+ ## Prerequisites
244
+
245
+ - Python 3.9+
246
+ - Files must be readable (check permissions)
247
+ - For directory comparison, directories must be accessible
248
+ - Text files only (binary files return error)
249
+
250
+ ## Performance Notes
251
+
252
+ - Large files: Use `--format minimal` for fastest comparison
253
+ - Directory comparison: Recursive mode can be slow on large trees
254
+ - Watch mode: Higher intervals reduce CPU usage
255
+ - JSON output: Slightly slower due to formatting overhead
256
+
257
+ ## Related Tools
258
+
259
+ - Standard `diff` command (POSIX)
260
+ - `git diff` for version control
261
+ - `rsync --dry-run` for directory synchronization
262
+ - `cmp` for binary file comparison
@@ -0,0 +1,2 @@
1
+ """diff-cli: File and text differ with multiple output formats."""
2
+ __version__ = "0.1.0"
@@ -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()
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "diff-cli"
7
+ version = "0.1.0"
8
+ description = "File and text differ with multiple output formats"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Marcus", email = "marcus.builds.things@gmail.com" }]
12
+ keywords = ["diff", "compare", "cli", "unified", "side-by-side", "text"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Text Processing :: General",
24
+ "Topic :: Utilities",
25
+ ]
26
+ requires-python = ">=3.9"
27
+ dependencies = ["click>=8.0"]
28
+
29
+ [project.scripts]
30
+ diff-cli = "diffcli.cli:cli"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/marcusbuildsthings-droid/diff-cli"
34
+ Repository = "https://github.com/marcusbuildsthings-droid/diff-cli"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["diffcli"]