uni-diff-patch 0.0.1__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,46 @@
1
+ """uni-diff-patch: MCP server and CLI for generating valid unified diff patches."""
2
+
3
+ from uni_diff_patch.core.diff_engine import (
4
+ DiffGenerationError,
5
+ GenerateResult,
6
+ generate_patch,
7
+ )
8
+ from uni_diff_patch.core.search_replace import (
9
+ AmbiguousMatchError,
10
+ NoMatchError,
11
+ Replacement,
12
+ SearchReplaceError,
13
+ )
14
+ from uni_diff_patch.core.validator import ValidationResult, validate_patch
15
+ from uni_diff_patch.models import (
16
+ ChmodChange,
17
+ CreateFileChange,
18
+ DeleteFileChange,
19
+ FileChange,
20
+ FileContentChange,
21
+ RenameFileChange,
22
+ ReplacementModel,
23
+ SearchReplaceChange,
24
+ TextPairChange,
25
+ )
26
+
27
+ __all__ = [
28
+ "ChmodChange",
29
+ "CreateFileChange",
30
+ "DeleteFileChange",
31
+ "DiffGenerationError",
32
+ "FileChange",
33
+ "FileContentChange",
34
+ "GenerateResult",
35
+ "RenameFileChange",
36
+ "ReplacementModel",
37
+ "SearchReplaceChange",
38
+ "TextPairChange",
39
+ "generate_patch",
40
+ "validate_patch",
41
+ "ValidationResult",
42
+ "Replacement",
43
+ "SearchReplaceError",
44
+ "NoMatchError",
45
+ "AmbiguousMatchError",
46
+ ]
uni_diff_patch/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """CLI entry point for uni-diff-patch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import click
9
+
10
+ from uni_diff_patch.core.diff_engine import (
11
+ DiffGenerationError,
12
+ generate_patch,
13
+ )
14
+ from uni_diff_patch.core.search_replace import SearchReplaceError
15
+ from uni_diff_patch.core.validator import validate_patch
16
+ from uni_diff_patch.server import _parse_changes
17
+
18
+
19
+ @click.group()
20
+ @click.version_option(package_name="uni-diff-patch")
21
+ def main() -> None:
22
+ """uni-diff-patch: Generate valid unified diff patches deterministically."""
23
+
24
+
25
+ @main.command()
26
+ @click.option(
27
+ "--spec",
28
+ "spec_source",
29
+ required=True,
30
+ help="JSON spec file path, or '-' for stdin.",
31
+ )
32
+ @click.option(
33
+ "-o",
34
+ "--output",
35
+ "output_override",
36
+ default=None,
37
+ help="Override output file path (takes precedence over spec's output_path).",
38
+ )
39
+ @click.option(
40
+ "--work-dir",
41
+ default=None,
42
+ help="Base directory for relativizing absolute paths in diff headers.",
43
+ )
44
+ @click.option(
45
+ "--overwrite",
46
+ is_flag=True,
47
+ help="Overwrite existing output file.",
48
+ )
49
+ def generate(
50
+ spec_source: str,
51
+ output_override: str | None,
52
+ work_dir: str | None,
53
+ overwrite: bool,
54
+ ) -> None:
55
+ """Generate a unified diff patch from a JSON spec.
56
+
57
+ The spec JSON must contain a "changes" array. Each change has:
58
+ file_path, mode, and mode-specific fields.
59
+
60
+ Modes: file_content, search_replace, create_file, delete_file,
61
+ text_pair, rename_file, chmod.
62
+ """
63
+ # 读取 spec
64
+ try:
65
+ if spec_source == "-":
66
+ spec_data = json.load(sys.stdin)
67
+ else:
68
+ with open(spec_source, encoding="utf-8") as f:
69
+ spec_data = json.load(f)
70
+ except (json.JSONDecodeError, FileNotFoundError, OSError) as e:
71
+ raise click.ClickException(f"Failed to read spec: {e}")
72
+
73
+ if not isinstance(spec_data, dict):
74
+ raise click.ClickException("Spec must be a JSON object")
75
+
76
+ raw_changes = spec_data.get("changes")
77
+ if not isinstance(raw_changes, list) or not raw_changes:
78
+ raise click.ClickException("Spec must contain a non-empty 'changes' array")
79
+
80
+ try:
81
+ file_changes = _parse_changes(raw_changes)
82
+ except ValueError as e:
83
+ raise click.ClickException(str(e))
84
+
85
+ # output 优先级:CLI flag > spec 中的 output_path
86
+ final_output = output_override or spec_data.get("output_path")
87
+
88
+ try:
89
+ result = generate_patch(
90
+ file_changes,
91
+ output_path=final_output,
92
+ work_dir=work_dir,
93
+ overwrite=overwrite,
94
+ )
95
+ except (DiffGenerationError, SearchReplaceError) as e:
96
+ raise click.ClickException(str(e))
97
+
98
+ if not result.patch:
99
+ click.echo("No changes detected.", err=True)
100
+ sys.exit(0)
101
+
102
+ if result.skipped_files:
103
+ click.echo(
104
+ f"Skipped (no changes): {', '.join(result.skipped_files)}",
105
+ err=True,
106
+ )
107
+
108
+ if not result.self_valid:
109
+ click.echo("WARNING: generated patch failed self-validation", err=True)
110
+
111
+ if final_output:
112
+ click.echo(
113
+ f"Patch written to {final_output} "
114
+ f"({result.files_changed} file(s) changed)",
115
+ err=True,
116
+ )
117
+ else:
118
+ click.echo(result.patch, nl=False)
119
+
120
+
121
+ @main.command()
122
+ @click.argument("patch_source", default="-")
123
+ @click.option(
124
+ "--git-check", is_flag=True, help="Also run git apply --check."
125
+ )
126
+ @click.option(
127
+ "--work-dir",
128
+ default=None,
129
+ help="Working directory for git apply --check.",
130
+ )
131
+ def validate(patch_source: str, git_check: bool, work_dir: str | None) -> None:
132
+ """Validate a unified diff patch file.
133
+
134
+ Pass a file path or '-' (default) to read from stdin.
135
+ """
136
+ patch_content: str | None = None
137
+ patch_path: str | None = None
138
+
139
+ if patch_source == "-":
140
+ raw = sys.stdin.read()
141
+ if not raw.strip():
142
+ raise click.ClickException("No patch content provided on stdin")
143
+ patch_content = raw
144
+ else:
145
+ patch_path = patch_source
146
+
147
+ result = validate_patch(
148
+ patch_content=patch_content,
149
+ patch_path=patch_path,
150
+ git_check=git_check,
151
+ work_dir=work_dir,
152
+ )
153
+
154
+ if result.valid:
155
+ click.echo(f"VALID — {len(result.files)} file(s), {result.total_hunks} hunk(s)")
156
+ for f in result.files:
157
+ status = " [new]" if f.is_creation else ""
158
+ click.echo(f" {f.file_path}: {f.hunk_count} hunk(s){status}")
159
+ else:
160
+ click.echo("INVALID", err=True)
161
+ if result.parse_error:
162
+ click.echo(f" Parse error: {result.parse_error}", err=True)
163
+ if result.git_apply_error:
164
+ click.echo(f" git apply: {result.git_apply_error}", err=True)
165
+ sys.exit(1)
166
+
167
+ if result.git_apply_ok is True:
168
+ click.echo("git apply --check: OK")
169
+ elif result.git_apply_ok is False:
170
+ click.echo(f"git apply --check: FAILED — {result.git_apply_error}", err=True)
171
+
172
+
173
+ @main.command()
174
+ def serve() -> None:
175
+ """Start the MCP server (stdio transport)."""
176
+ from uni_diff_patch.server import run_server
177
+
178
+ run_server()
File without changes
@@ -0,0 +1,402 @@
1
+ """Core diff generation engine backed by mpatch.
2
+
3
+ Uses mpatch (Rust) for normal diffs and falls back to difflib (stdlib)
4
+ when files lack trailing newlines, since difflib correctly preserves
5
+ line endings and allows proper '\\No newline at end of file' markers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ import mpatch
16
+
17
+ from uni_diff_patch.core.search_replace import Replacement, apply_replacements
18
+ from uni_diff_patch.core.validator import validate_patch
19
+ from uni_diff_patch.models import (
20
+ ChmodChange,
21
+ CreateFileChange,
22
+ DeleteFileChange,
23
+ FileChange,
24
+ FileContentChange,
25
+ RenameFileChange,
26
+ SearchReplaceChange,
27
+ TextPairChange,
28
+ )
29
+
30
+ _NO_NEWLINE_MARKER = "\"
31
+
32
+
33
+ class DiffGenerationError(Exception):
34
+ """Raised when diff generation fails."""
35
+
36
+
37
+ @dataclass
38
+ class GenerateResult:
39
+ """Result of a diff generation operation.
40
+
41
+ Args:
42
+ patch: The generated unified diff string.
43
+ files_changed: Number of files with actual changes.
44
+ self_valid: Whether the generated patch passed self-validation.
45
+ skipped_files: Files skipped because old == new (no changes).
46
+ """
47
+
48
+ patch: str
49
+ files_changed: int = 0
50
+ self_valid: bool = True
51
+ skipped_files: list[str] = field(default_factory=list)
52
+
53
+
54
+ # 二进制探测只读前 8KB,全文件读取有大小限制
55
+ _BINARY_PROBE_SIZE = 8192
56
+ _MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
57
+
58
+
59
+ def _read_file(file_path: str, encoding: str = "utf-8") -> str:
60
+ """Read a file from disk with binary detection.
61
+
62
+ Args:
63
+ file_path: Path to the file.
64
+ encoding: Text encoding.
65
+
66
+ Returns:
67
+ File content as string.
68
+
69
+ Raises:
70
+ DiffGenerationError: If the file is binary, not found, or unreadable.
71
+ """
72
+ path = Path(file_path)
73
+ if not path.is_file():
74
+ raise DiffGenerationError(f"File not found: {file_path}")
75
+ if path.is_symlink():
76
+ raise DiffGenerationError(f"Symlinks not supported: {file_path}")
77
+
78
+ size = path.stat().st_size
79
+ if size > _MAX_FILE_SIZE:
80
+ raise DiffGenerationError(
81
+ f"File too large ({size} bytes, max {_MAX_FILE_SIZE}): {file_path}"
82
+ )
83
+
84
+ with open(path, "rb") as f:
85
+ probe = f.read(_BINARY_PROBE_SIZE)
86
+ if b"\x00" in probe:
87
+ raise DiffGenerationError(f"Binary file detected: {file_path}")
88
+
89
+ try:
90
+ return path.read_text(encoding=encoding)
91
+ except (UnicodeDecodeError, LookupError) as e:
92
+ raise DiffGenerationError(
93
+ f"Cannot decode {file_path} with encoding {encoding}: {e}"
94
+ ) from e
95
+
96
+
97
+ def _make_patch_path(file_path: str, work_dir: str | None) -> str:
98
+ """Normalize a file path for diff headers, ensuring git apply compatibility.
99
+
100
+ Relative paths are preserved as-is. Absolute paths are relativized
101
+ against work_dir to avoid the 'a//abs/path' double-slash issue.
102
+ """
103
+ path = Path(file_path)
104
+ if not path.is_absolute():
105
+ return file_path
106
+
107
+ resolved = path.resolve()
108
+ if work_dir:
109
+ base = Path(work_dir).resolve()
110
+ try:
111
+ return str(resolved.relative_to(base))
112
+ except ValueError:
113
+ raise DiffGenerationError(
114
+ f"Absolute path {file_path} is outside work_dir {work_dir}. "
115
+ f"Use a path within work_dir or switch to text_pair mode."
116
+ )
117
+
118
+ return str(resolved).lstrip("/")
119
+
120
+
121
+ def _create_unified_diff(
122
+ patch_path: str, old: str, new: str, context_lines: int
123
+ ) -> str:
124
+ """Generate a unified diff, choosing the best engine.
125
+
126
+ Uses mpatch (Rust, fast) for normal files. Falls back to difflib
127
+ when either side lacks a trailing newline (correct marker handling).
128
+ """
129
+ old_has_newline = (not old) or old.endswith("\n")
130
+ new_has_newline = (not new) or new.endswith("\n")
131
+
132
+ if old_has_newline and new_has_newline:
133
+ return mpatch.create_unified_diff(
134
+ patch_path, old, new, context_len=context_lines
135
+ )
136
+
137
+ # difflib 降级:正确处理 "" 标记
138
+ old_lines = old.splitlines(keepends=True)
139
+ new_lines = new.splitlines(keepends=True)
140
+
141
+ raw = difflib.unified_diff(
142
+ old_lines, new_lines,
143
+ fromfile=f"a/{patch_path}", tofile=f"b/{patch_path}",
144
+ n=context_lines,
145
+ )
146
+
147
+ result: list[str] = []
148
+ for line in raw:
149
+ result.append(line)
150
+ if line and not line.endswith("\n") and line[0] in (" ", "-", "+"):
151
+ result.append(f"\n{_NO_NEWLINE_MARKER}\n")
152
+
153
+ return "".join(result)
154
+
155
+
156
+ # --- git 扩展 diff header 构建 ---
157
+
158
+
159
+ def _build_rename_diff(
160
+ old_path: str, new_path: str, old: str, new: str, context_lines: int
161
+ ) -> str:
162
+ """Build a git-compatible rename diff with extended headers.
163
+
164
+ Format:
165
+ diff --git a/old b/new
166
+ rename from old
167
+ rename to new
168
+ --- a/old
169
+ +++ b/new
170
+ @@ ... @@
171
+ ...
172
+ """
173
+ header = (
174
+ f"diff --git a/{old_path} b/{new_path}\n"
175
+ f"rename from {old_path}\n"
176
+ f"rename to {new_path}\n"
177
+ )
178
+
179
+ if old == new:
180
+ # 纯重命名,无内容变更
181
+ return header
182
+
183
+ # 有内容变更,生成 body
184
+ body = _create_unified_diff(old_path, old, new, context_lines)
185
+ # 替换 body 中的路径:--- a/old_path 保持, +++ b/new_path
186
+ body = body.replace(f"+++ b/{old_path}", f"+++ b/{new_path}", 1)
187
+ return header + body
188
+
189
+
190
+ def _build_chmod_diff(
191
+ patch_path: str, old_mode: str, new_mode: str,
192
+ old: str | None, new: str | None, context_lines: int
193
+ ) -> str:
194
+ """Build a git-compatible mode change diff with extended headers.
195
+
196
+ Format:
197
+ diff --git a/path b/path
198
+ old mode <old>
199
+ new mode <new>
200
+ --- a/path (only if content changed)
201
+ +++ b/path
202
+ @@ ... @@
203
+ ...
204
+ """
205
+ header = (
206
+ f"diff --git a/{patch_path} b/{patch_path}\n"
207
+ f"old mode {old_mode}\n"
208
+ f"new mode {new_mode}\n"
209
+ )
210
+
211
+ if old is None or new is None or old == new:
212
+ # 纯权限变更
213
+ return header
214
+
215
+ # 有内容变更
216
+ body = _create_unified_diff(patch_path, old, new, context_lines)
217
+ return header + body
218
+
219
+
220
+ # --- 主入口 ---
221
+
222
+
223
+ def _to_replacements(change: SearchReplaceChange) -> list[Replacement]:
224
+ """Convert Pydantic ReplacementModel list to internal Replacement list."""
225
+ return [
226
+ Replacement(search=r.search, replace=r.replace, line_hint=r.line_hint)
227
+ for r in change.replacements
228
+ ]
229
+
230
+
231
+ def _generate_single_diff(
232
+ change: FileChange, work_dir: str | None
233
+ ) -> str | None:
234
+ """Generate a unified diff for a single file change.
235
+
236
+ Returns:
237
+ The diff string, or None if there are no changes.
238
+
239
+ Raises:
240
+ DiffGenerationError: On invalid input or file access errors.
241
+ """
242
+ match change:
243
+ case FileContentChange():
244
+ old = _read_file(change.file_path, change.encoding)
245
+ new = change.new_content
246
+ if old == new:
247
+ return None
248
+ patch_path = _make_patch_path(change.file_path, work_dir)
249
+ return _create_unified_diff(patch_path, old, new, change.context_lines)
250
+
251
+ case SearchReplaceChange():
252
+ old = _read_file(change.file_path, change.encoding)
253
+ new = apply_replacements(old, _to_replacements(change), change.file_path)
254
+ if old == new:
255
+ return None
256
+ patch_path = _make_patch_path(change.file_path, work_dir)
257
+ return _create_unified_diff(patch_path, old, new, change.context_lines)
258
+
259
+ case CreateFileChange():
260
+ if Path(change.file_path).exists():
261
+ raise DiffGenerationError(
262
+ f"File already exists (use file_content mode to modify): "
263
+ f"{change.file_path}"
264
+ )
265
+ patch_path = _make_patch_path(change.file_path, work_dir)
266
+ return _create_unified_diff(patch_path, "", change.new_content, 3)
267
+
268
+ case DeleteFileChange():
269
+ old = _read_file(change.file_path, change.encoding)
270
+ patch_path = _make_patch_path(change.file_path, work_dir)
271
+ return _create_unified_diff(patch_path, old, "", 3)
272
+
273
+ case TextPairChange():
274
+ if change.old_text == change.new_text:
275
+ return None
276
+ patch_path = _make_patch_path(change.file_path, work_dir)
277
+ return _create_unified_diff(
278
+ patch_path, change.old_text, change.new_text, change.context_lines
279
+ )
280
+
281
+ case RenameFileChange():
282
+ old = _read_file(change.file_path, change.encoding)
283
+ # 检查目标文件是否已存在
284
+ if Path(change.new_path).exists():
285
+ raise DiffGenerationError(
286
+ f"Rename target already exists: {change.new_path}"
287
+ )
288
+ new = change.new_content if change.new_content is not None else old
289
+ old_patch = _make_patch_path(change.file_path, work_dir)
290
+ new_patch = _make_patch_path(change.new_path, work_dir)
291
+ return _build_rename_diff(old_patch, new_patch, old, new, change.context_lines)
292
+
293
+ case ChmodChange():
294
+ # 始终验证文件存在(即使纯 chmod 不读内容)
295
+ if not Path(change.file_path).is_file():
296
+ raise DiffGenerationError(f"File not found: {change.file_path}")
297
+ old_patch = _make_patch_path(change.file_path, work_dir)
298
+ old_content: str | None = None
299
+ new_content: str | None = None
300
+ if change.new_content is not None:
301
+ old_content = _read_file(change.file_path, change.encoding)
302
+ new_content = change.new_content
303
+ return _build_chmod_diff(
304
+ old_patch, change.old_mode, change.new_mode,
305
+ old_content, new_content, change.context_lines,
306
+ )
307
+
308
+ case _:
309
+ raise DiffGenerationError(f"Unknown change type: {type(change)}")
310
+
311
+
312
+ def generate_patch(
313
+ changes: Sequence[FileChange],
314
+ output_path: str | None = None,
315
+ work_dir: str | None = None,
316
+ self_validate: bool = True,
317
+ overwrite: bool = False,
318
+ ) -> GenerateResult:
319
+ """Generate a unified diff patch from one or more file changes.
320
+
321
+ Args:
322
+ changes: List of file changes (Pydantic models) to include in the patch.
323
+ output_path: If provided, write the patch to this file path.
324
+ work_dir: Base directory for relativizing absolute paths in diff headers.
325
+ Defaults to current working directory.
326
+ self_validate: If True, validate the generated patch with mpatch.parse_auto.
327
+ overwrite: If True, overwrite existing output files. Default False.
328
+
329
+ Returns:
330
+ GenerateResult with the patch string and metadata.
331
+
332
+ Raises:
333
+ DiffGenerationError: On input errors or generation failures.
334
+ """
335
+ if not changes:
336
+ raise DiffGenerationError("No changes provided")
337
+
338
+ # 检查重复文件目标(含 rename 的 new_path)
339
+ seen_paths: set[str] = set()
340
+ for change in changes:
341
+ normalized = str(Path(change.file_path).resolve())
342
+ if normalized in seen_paths:
343
+ raise DiffGenerationError(
344
+ f"Duplicate file target: {change.file_path}. "
345
+ f"Combine changes into a single FileChange per file."
346
+ )
347
+ seen_paths.add(normalized)
348
+ # rename 的目标路径也需要检查冲突
349
+ if isinstance(change, RenameFileChange):
350
+ new_normalized = str(Path(change.new_path).resolve())
351
+ if new_normalized in seen_paths:
352
+ raise DiffGenerationError(
353
+ f"Duplicate file target: {change.new_path} (rename destination). "
354
+ )
355
+ seen_paths.add(new_normalized)
356
+
357
+ effective_work_dir = work_dir or str(Path.cwd())
358
+
359
+ diffs: list[str] = []
360
+ skipped: list[str] = []
361
+
362
+ for change in changes:
363
+ diff = _generate_single_diff(change, effective_work_dir)
364
+ if diff is None:
365
+ skipped.append(change.file_path)
366
+ else:
367
+ diffs.append(diff)
368
+
369
+ if not diffs:
370
+ return GenerateResult(
371
+ patch="", files_changed=0, self_valid=True, skipped_files=skipped,
372
+ )
373
+
374
+ combined = "\n".join(diffs)
375
+ if not combined.endswith("\n"):
376
+ combined += "\n"
377
+
378
+ is_valid = True
379
+ if self_validate:
380
+ # 检查是否所有 diff 都是 header-only(纯 rename/chmod 无 hunk body)
381
+ # mpatch.parse_auto 无法解析这类 diff,跳过验证
382
+ has_hunk_body = any("@@" in d for d in diffs)
383
+ if has_hunk_body:
384
+ validation = validate_patch(patch_content=combined)
385
+ is_valid = validation.valid
386
+
387
+ result = GenerateResult(
388
+ patch=combined, files_changed=len(diffs),
389
+ self_valid=is_valid, skipped_files=skipped,
390
+ )
391
+
392
+ if output_path is not None:
393
+ out = Path(output_path)
394
+ if out.exists() and not overwrite:
395
+ raise DiffGenerationError(
396
+ f"Output file already exists: {output_path}. "
397
+ f"Use overwrite=True to replace."
398
+ )
399
+ out.parent.mkdir(parents=True, exist_ok=True)
400
+ out.write_text(combined, encoding="utf-8")
401
+
402
+ return result