mseep-patche 1.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.
- Patche/__init__.py +1 -0
- Patche/__main__.py +3 -0
- Patche/__version__.py +9 -0
- Patche/app.py +32 -0
- Patche/commands/apply.py +107 -0
- Patche/commands/help.py +12 -0
- Patche/commands/show.py +41 -0
- Patche/config.py +24 -0
- Patche/mcp/__init__.py +42 -0
- Patche/mcp/__main__.py +3 -0
- Patche/mcp/model.py +29 -0
- Patche/mcp/prompts.py +313 -0
- Patche/mcp/server.py +101 -0
- Patche/mcp/tools.py +160 -0
- Patche/model.py +75 -0
- Patche/utils/common.py +73 -0
- Patche/utils/header.py +34 -0
- Patche/utils/parse.py +269 -0
- Patche/utils/resolve.py +168 -0
- mseep_patche-1.0.1.dist-info/METADATA +14 -0
- mseep_patche-1.0.1.dist-info/RECORD +24 -0
- mseep_patche-1.0.1.dist-info/WHEEL +4 -0
- mseep_patche-1.0.1.dist-info/entry_points.txt +6 -0
- mseep_patche-1.0.1.dist-info/licenses/LICENSE +21 -0
Patche/mcp/server.py
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
from mcp.server import Server
|
2
|
+
from mcp.server.stdio import stdio_server
|
3
|
+
from mcp.types import Prompt, TextContent, Tool
|
4
|
+
|
5
|
+
from Patche.app import logger
|
6
|
+
from Patche.mcp.model import (
|
7
|
+
PatcheApply,
|
8
|
+
PatcheConfig,
|
9
|
+
PatcheList,
|
10
|
+
PatcheShow,
|
11
|
+
PatcheTools,
|
12
|
+
)
|
13
|
+
from Patche.mcp.prompts import prompts
|
14
|
+
from Patche.mcp.tools import patche_apply, patche_config, patche_list, patche_show
|
15
|
+
|
16
|
+
server = None
|
17
|
+
|
18
|
+
|
19
|
+
async def serve(repository: str | None) -> None:
|
20
|
+
|
21
|
+
if repository is None:
|
22
|
+
logger.error("Repository is None")
|
23
|
+
return
|
24
|
+
|
25
|
+
server = Server(
|
26
|
+
"patche-mcp",
|
27
|
+
version="0.0.1",
|
28
|
+
instructions="An MCP Server to show, list, apply or reverse patch to a file",
|
29
|
+
)
|
30
|
+
|
31
|
+
@server.list_tools()
|
32
|
+
async def list_tools() -> list[Tool]:
|
33
|
+
return [
|
34
|
+
Tool(
|
35
|
+
name=PatcheTools.CONFIG,
|
36
|
+
description="Show the config of patche",
|
37
|
+
inputSchema=PatcheConfig.model_json_schema(),
|
38
|
+
),
|
39
|
+
Tool(
|
40
|
+
name=PatcheTools.LIST,
|
41
|
+
description="List all the patches in the directory",
|
42
|
+
inputSchema=PatcheList.model_json_schema(),
|
43
|
+
),
|
44
|
+
Tool(
|
45
|
+
name=PatcheTools.SHOW,
|
46
|
+
description="Show the patch",
|
47
|
+
inputSchema=PatcheShow.model_json_schema(),
|
48
|
+
),
|
49
|
+
Tool(
|
50
|
+
name=PatcheTools.APPLY,
|
51
|
+
description="Apply the patch",
|
52
|
+
inputSchema=PatcheApply.model_json_schema(),
|
53
|
+
),
|
54
|
+
]
|
55
|
+
|
56
|
+
@server.call_tool()
|
57
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
58
|
+
"""
|
59
|
+
Call the tool with the given name and arguments.
|
60
|
+
"""
|
61
|
+
|
62
|
+
logger.info(f"Received tool call: {name} with arguments: {arguments}")
|
63
|
+
|
64
|
+
match name:
|
65
|
+
case PatcheTools.CONFIG:
|
66
|
+
config = patche_config()
|
67
|
+
return [TextContent(type="text", text=f"Patche config: {config}")]
|
68
|
+
case PatcheTools.LIST:
|
69
|
+
patches = patche_list(arguments["patche_dir"])
|
70
|
+
return [TextContent(type="text", text=f"Patche list: {patches}")]
|
71
|
+
case PatcheTools.SHOW:
|
72
|
+
patch = patche_show(arguments["patch_path"])
|
73
|
+
return [TextContent(type="text", text=f"Patche show: {patch}")]
|
74
|
+
case PatcheTools.APPLY:
|
75
|
+
result = patche_apply(
|
76
|
+
arguments["patch_path"],
|
77
|
+
arguments["target_dir"],
|
78
|
+
arguments.get("reverse", False),
|
79
|
+
)
|
80
|
+
return [TextContent(type="text", text=f"Patche apply: {result}")]
|
81
|
+
case _:
|
82
|
+
raise NotImplementedError(f"Tool {name} is not implemented")
|
83
|
+
|
84
|
+
@server.list_prompts()
|
85
|
+
async def list_prompts() -> list[str]:
|
86
|
+
"""
|
87
|
+
List all the prompts.
|
88
|
+
"""
|
89
|
+
return prompts
|
90
|
+
|
91
|
+
@server.call_prompt()
|
92
|
+
async def call_prompt(name: str, arguments: dict) -> list[TextContent]:
|
93
|
+
"""
|
94
|
+
Call the prompt with the given name and arguments.
|
95
|
+
"""
|
96
|
+
logger.info(f"Received prompt call: {name} with arguments: {arguments}")
|
97
|
+
raise NotImplementedError(f"Prompt {name} is not implemented")
|
98
|
+
|
99
|
+
options = server.create_initialization_options()
|
100
|
+
async with stdio_server() as (read_stream, write_stream):
|
101
|
+
await server.run(read_stream, write_stream, options, raise_exceptions=True)
|
Patche/mcp/tools.py
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
import os
|
2
|
+
|
3
|
+
from Patche.app import logger
|
4
|
+
from Patche.config import settings
|
5
|
+
from Patche.mcp.model import PatcheApply, PatcheConfig, PatcheList, PatcheShow
|
6
|
+
from Patche.model import File
|
7
|
+
from Patche.utils.parse import parse_patch
|
8
|
+
from Patche.utils.resolve import apply_change
|
9
|
+
|
10
|
+
|
11
|
+
def patche_config() -> str:
|
12
|
+
"""
|
13
|
+
Show the config of patche
|
14
|
+
"""
|
15
|
+
return settings.model_dump_json()
|
16
|
+
|
17
|
+
|
18
|
+
def patche_list(patche_dir: str) -> str:
|
19
|
+
"""
|
20
|
+
List all the patches in the directory
|
21
|
+
"""
|
22
|
+
pass
|
23
|
+
|
24
|
+
patches: list[str] = []
|
25
|
+
|
26
|
+
for file in os.listdir(patche_dir):
|
27
|
+
if file.endswith(".patch"):
|
28
|
+
patches.append(file)
|
29
|
+
|
30
|
+
return "\n".join(patches)
|
31
|
+
|
32
|
+
|
33
|
+
def patche_show(patch_path: str) -> str:
|
34
|
+
"""
|
35
|
+
Show the patch
|
36
|
+
"""
|
37
|
+
|
38
|
+
if not os.path.exists(patch_path):
|
39
|
+
err = f"Warning: {patch_path} not found!"
|
40
|
+
logger.error(err)
|
41
|
+
return err
|
42
|
+
|
43
|
+
content = ""
|
44
|
+
with open(patch_path, mode="r", encoding="utf-8") as (f):
|
45
|
+
content = f.read()
|
46
|
+
|
47
|
+
patch = parse_patch(content)
|
48
|
+
|
49
|
+
result = []
|
50
|
+
result.append(f"Patch: {patch_path}")
|
51
|
+
result.append(f"Sha: {patch.sha}")
|
52
|
+
result.append(f"Author: {patch.author}")
|
53
|
+
result.append(f"Date: {patch.date}")
|
54
|
+
result.append(f"Subject: {patch.subject}")
|
55
|
+
|
56
|
+
for diff in patch.diff:
|
57
|
+
result.append(f"Diff: {diff.header.old_path} -> {diff.header.new_path}")
|
58
|
+
|
59
|
+
return "\n".join(result)
|
60
|
+
|
61
|
+
|
62
|
+
def patche_apply(patch_path: str, target_dir: str, reverse: bool = False) -> str:
|
63
|
+
"""
|
64
|
+
Apply the patch
|
65
|
+
"""
|
66
|
+
|
67
|
+
# Change dir to target_dir
|
68
|
+
os.chdir(target_dir)
|
69
|
+
logger.info(f"Changing directory to {target_dir}")
|
70
|
+
logger.info(f"Applying patch: {patch_path}")
|
71
|
+
|
72
|
+
# Maybe we can just catch typer.Exit() and return the error message?
|
73
|
+
if not os.path.exists(patch_path):
|
74
|
+
logger.error(f"Warning: {patch_path} not found!")
|
75
|
+
return f"Error: patch {patch_path} not found!"
|
76
|
+
|
77
|
+
if reverse:
|
78
|
+
logger.info("Reversing patch...")
|
79
|
+
|
80
|
+
has_failed = False
|
81
|
+
|
82
|
+
with open(patch_path, mode="r", encoding="utf-8") as (f):
|
83
|
+
diffes = parse_patch(f.read()).diff
|
84
|
+
|
85
|
+
for diff in diffes:
|
86
|
+
|
87
|
+
old_filename = diff.header.old_path
|
88
|
+
new_filename = diff.header.new_path
|
89
|
+
if reverse:
|
90
|
+
old_filename, new_filename = new_filename, old_filename
|
91
|
+
|
92
|
+
logger.debug(f"old_filename: {old_filename} new_filename: {new_filename}")
|
93
|
+
|
94
|
+
if old_filename == "/dev/null":
|
95
|
+
# 根据 diffes 创建新文件
|
96
|
+
try:
|
97
|
+
assert len(diff.hunks) == 1
|
98
|
+
|
99
|
+
new_line_list = []
|
100
|
+
for line in diff.changes:
|
101
|
+
|
102
|
+
assert line.old is None
|
103
|
+
new_line_list.append(line)
|
104
|
+
|
105
|
+
with open(new_filename, mode="w+", encoding="utf-8") as f:
|
106
|
+
for line in new_line_list:
|
107
|
+
f.write(line.content + "\n")
|
108
|
+
|
109
|
+
except AssertionError:
|
110
|
+
err = "Failed to create new file: invalid diff!"
|
111
|
+
logger.error(err)
|
112
|
+
return err
|
113
|
+
|
114
|
+
elif new_filename == "/dev/null":
|
115
|
+
# 移除 old_filename
|
116
|
+
if os.path.exists(old_filename):
|
117
|
+
os.remove(old_filename)
|
118
|
+
else:
|
119
|
+
err = f"{old_filename} not found!"
|
120
|
+
logger.error(err)
|
121
|
+
return err
|
122
|
+
|
123
|
+
else:
|
124
|
+
if os.path.exists(old_filename):
|
125
|
+
|
126
|
+
logger.info(f"Applying patch to {old_filename}...")
|
127
|
+
|
128
|
+
new_line_list = File(file_path=old_filename).line_list
|
129
|
+
apply_result = apply_change(
|
130
|
+
diff.hunks,
|
131
|
+
new_line_list,
|
132
|
+
reverse=reverse,
|
133
|
+
fuzz=3, # Should be a config option?
|
134
|
+
)
|
135
|
+
new_line_list = apply_result.new_line_list
|
136
|
+
|
137
|
+
# 检查失败数
|
138
|
+
for failed_hunk in apply_result.failed_hunk_list:
|
139
|
+
has_failed = True
|
140
|
+
logger.error(f"Failed hunk: {failed_hunk.index}")
|
141
|
+
else:
|
142
|
+
err = f"{old_filename} not found!"
|
143
|
+
logger.error(err)
|
144
|
+
return err
|
145
|
+
|
146
|
+
# 写入文件
|
147
|
+
if not has_failed:
|
148
|
+
with open(new_filename, mode="w+", encoding="utf-8") as f:
|
149
|
+
for line in new_line_list:
|
150
|
+
if line.status:
|
151
|
+
f.write(line.content + "\n")
|
152
|
+
|
153
|
+
if has_failed:
|
154
|
+
err = "Error: Failed to apply patch! Please check the log for details."
|
155
|
+
logger.error(err)
|
156
|
+
return err
|
157
|
+
else:
|
158
|
+
info = "Patch applied successfully!"
|
159
|
+
logger.info(info)
|
160
|
+
return info
|
Patche/model.py
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
from pydantic import BaseModel
|
5
|
+
from whatthepatch_pydantic.model import Change
|
6
|
+
from whatthepatch_pydantic.model import Diff as WTPDiff
|
7
|
+
from whatthepatch_pydantic.model import Header
|
8
|
+
|
9
|
+
|
10
|
+
class Line(BaseModel):
|
11
|
+
index: int
|
12
|
+
content: str
|
13
|
+
changed: bool = False # 标识在一轮 apply 中是否进行了修改
|
14
|
+
status: bool = True # 标识在一轮 apply 中是否被删除
|
15
|
+
flag: bool = False # 标识是否是在初次标记中修改了的行
|
16
|
+
|
17
|
+
hunk: Optional[int] = None # 如果 changed 为 True,则记录其所在的 hunk
|
18
|
+
|
19
|
+
def __str__(self) -> str:
|
20
|
+
return self.content
|
21
|
+
|
22
|
+
|
23
|
+
class File(object):
|
24
|
+
def __init__(self, file_path: str) -> None:
|
25
|
+
self.line_list: list[Line] = []
|
26
|
+
|
27
|
+
with open(file_path, mode="r", encoding="utf-8") as f:
|
28
|
+
for i, line in enumerate(f):
|
29
|
+
self.line_list.append(Line(index=i, content=line.rstrip("\n")))
|
30
|
+
|
31
|
+
def __str__(self) -> str:
|
32
|
+
return "".join([str(line) for line in self.line_list])
|
33
|
+
|
34
|
+
|
35
|
+
class Hunk(BaseModel):
|
36
|
+
index: int
|
37
|
+
context: list[Change]
|
38
|
+
middle: list[Change]
|
39
|
+
post: list[Change]
|
40
|
+
|
41
|
+
all_: list[Change]
|
42
|
+
|
43
|
+
|
44
|
+
class Diff(WTPDiff):
|
45
|
+
hunks: list[Hunk]
|
46
|
+
|
47
|
+
|
48
|
+
class Patch(BaseModel):
|
49
|
+
sha: str | None
|
50
|
+
author: str | None
|
51
|
+
date: str | None
|
52
|
+
subject: str | None
|
53
|
+
message: str | None
|
54
|
+
diff: list[Diff] | list[WTPDiff] = [] # 临时的兼容方案
|
55
|
+
|
56
|
+
|
57
|
+
class ApplyResult(BaseModel):
|
58
|
+
new_line_list: list[Line] = []
|
59
|
+
conflict_hunk_num_list: list[int] = []
|
60
|
+
failed_hunk_list: list[Hunk] = []
|
61
|
+
|
62
|
+
|
63
|
+
class CommandType(Enum):
|
64
|
+
APPLY = "apply"
|
65
|
+
AUTO = "auto"
|
66
|
+
GET = "get"
|
67
|
+
HELP = "help"
|
68
|
+
SHOW = "show"
|
69
|
+
TRACE = "trace"
|
70
|
+
UNKNOWN = "unknown"
|
71
|
+
|
72
|
+
|
73
|
+
class CommandResult(BaseModel):
|
74
|
+
type: CommandType
|
75
|
+
content: dict | list | str | None = None
|
Patche/utils/common.py
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
import subprocess
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
from Patche.app import logger
|
5
|
+
from Patche.model import CommandResult, CommandType
|
6
|
+
|
7
|
+
|
8
|
+
def clean_repo():
|
9
|
+
output = subprocess.run(["git", "clean", "-df"], capture_output=True).stdout.decode(
|
10
|
+
"utf-8"
|
11
|
+
)
|
12
|
+
logger.debug(output)
|
13
|
+
|
14
|
+
output = subprocess.run(
|
15
|
+
["git", "reset", "--hard"], capture_output=True
|
16
|
+
).stdout.decode("utf-8")
|
17
|
+
logger.debug(output)
|
18
|
+
|
19
|
+
|
20
|
+
def process_title(filename: str):
|
21
|
+
"""
|
22
|
+
Process the file name to make it suitable for path
|
23
|
+
"""
|
24
|
+
return "".join([letter for letter in filename if letter.isalnum()])
|
25
|
+
|
26
|
+
|
27
|
+
def find_list_positions(main_list: list[str], sublist: list[str]) -> list[int]:
|
28
|
+
sublist_length = len(sublist)
|
29
|
+
positions = []
|
30
|
+
|
31
|
+
for i in range(len(main_list) - sublist_length + 1):
|
32
|
+
if main_list[i : i + sublist_length] == sublist:
|
33
|
+
positions.append(i)
|
34
|
+
|
35
|
+
return positions
|
36
|
+
|
37
|
+
|
38
|
+
def isnamedtupleinstance(x):
|
39
|
+
_type = type(x)
|
40
|
+
bases = _type.__bases__
|
41
|
+
if len(bases) != 1 or bases[0] != tuple:
|
42
|
+
return False
|
43
|
+
fields = getattr(_type, "_fields", None)
|
44
|
+
if not isinstance(fields, tuple):
|
45
|
+
return False
|
46
|
+
return all(type(i) == str for i in fields)
|
47
|
+
|
48
|
+
|
49
|
+
def unpack(obj):
|
50
|
+
if isinstance(obj, dict):
|
51
|
+
return {key: unpack(value) for key, value in obj.items()}
|
52
|
+
elif isinstance(obj, list):
|
53
|
+
return [unpack(value) for value in obj]
|
54
|
+
elif isnamedtupleinstance(obj):
|
55
|
+
return {key: unpack(value) for key, value in obj._asdict().items()}
|
56
|
+
elif isinstance(obj, tuple):
|
57
|
+
return tuple(unpack(value) for value in obj)
|
58
|
+
else:
|
59
|
+
return obj
|
60
|
+
|
61
|
+
|
62
|
+
def post_executed(executed_command_result: CommandResult | Any, **kwargs) -> None:
|
63
|
+
"""Executed command result callback function"""
|
64
|
+
if type(executed_command_result) != CommandResult:
|
65
|
+
return
|
66
|
+
|
67
|
+
logger.debug(f"Executed {executed_command_result.type}")
|
68
|
+
|
69
|
+
match executed_command_result.type:
|
70
|
+
case CommandType.AUTO:
|
71
|
+
clean_repo()
|
72
|
+
case _:
|
73
|
+
pass
|
Patche/utils/header.py
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
import re
|
2
|
+
from typing import Iterator, Optional
|
3
|
+
|
4
|
+
from Patche.model import Header
|
5
|
+
|
6
|
+
HEADER_OLD = re.compile(r"^--- ([^\t\n]+)(?:\t([^\n]*)|)$")
|
7
|
+
HEADER_NEW = re.compile(r"^\+\+\+ ([^\t\n]+)(?:\t([^\n]*)|)$")
|
8
|
+
HUNK_START = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
9
|
+
CHANGE_LINE = re.compile(r"^([- +\\])(.*)$")
|
10
|
+
GIT_HEADER = re.compile(r"^diff --git a/(.*) b/(.*)$")
|
11
|
+
|
12
|
+
|
13
|
+
def parse_header(lines: Iterator[str]) -> Optional[Header]:
|
14
|
+
"""解析 diff 头部信息"""
|
15
|
+
|
16
|
+
old_line = next(lines, "")
|
17
|
+
new_line = next(lines, "")
|
18
|
+
|
19
|
+
if GIT_HEADER.match(old_line):
|
20
|
+
old_line = next(lines, "")
|
21
|
+
new_line = next(lines, "")
|
22
|
+
|
23
|
+
old_match = HEADER_OLD.match(old_line)
|
24
|
+
new_match = HEADER_NEW.match(new_line)
|
25
|
+
|
26
|
+
if old_match and new_match:
|
27
|
+
return Header(
|
28
|
+
index_path=None,
|
29
|
+
old_path=old_match.group(1),
|
30
|
+
old_version=old_match.group(2) if old_match.group(2) else None,
|
31
|
+
new_path=new_match.group(1),
|
32
|
+
new_version=new_match.group(2) if new_match.group(2) else None,
|
33
|
+
)
|
34
|
+
return None
|