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/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