patchllm 0.2.1__py3-none-any.whl → 1.0.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.
- patchllm/__main__.py +0 -0
- patchllm/agent/__init__.py +0 -0
- patchllm/agent/actions.py +73 -0
- patchllm/agent/executor.py +57 -0
- patchllm/agent/planner.py +76 -0
- patchllm/agent/session.py +425 -0
- patchllm/cli/__init__.py +0 -0
- patchllm/cli/entrypoint.py +120 -0
- patchllm/cli/handlers.py +192 -0
- patchllm/cli/helpers.py +72 -0
- patchllm/interactive/__init__.py +0 -0
- patchllm/interactive/selector.py +100 -0
- patchllm/llm.py +39 -0
- patchllm/main.py +1 -283
- patchllm/parser.py +120 -64
- patchllm/patcher.py +118 -0
- patchllm/scopes/__init__.py +0 -0
- patchllm/scopes/builder.py +55 -0
- patchllm/scopes/constants.py +70 -0
- patchllm/scopes/helpers.py +147 -0
- patchllm/scopes/resolvers.py +82 -0
- patchllm/scopes/structure.py +64 -0
- patchllm/tui/__init__.py +0 -0
- patchllm/tui/completer.py +153 -0
- patchllm/tui/interface.py +703 -0
- patchllm/utils.py +19 -1
- patchllm/voice/__init__.py +0 -0
- patchllm/{listener.py → voice/listener.py} +8 -1
- patchllm-1.0.0.dist-info/METADATA +153 -0
- patchllm-1.0.0.dist-info/RECORD +51 -0
- patchllm-1.0.0.dist-info/entry_points.txt +2 -0
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
- tests/__init__.py +0 -0
- tests/conftest.py +112 -0
- tests/test_actions.py +62 -0
- tests/test_agent.py +383 -0
- tests/test_completer.py +121 -0
- tests/test_context.py +140 -0
- tests/test_executor.py +60 -0
- tests/test_interactive.py +64 -0
- tests/test_parser.py +70 -0
- tests/test_patcher.py +71 -0
- tests/test_planner.py +53 -0
- tests/test_recipes.py +111 -0
- tests/test_scopes.py +47 -0
- tests/test_structure.py +48 -0
- tests/test_tui.py +397 -0
- tests/test_utils.py +31 -0
- patchllm/context.py +0 -238
- patchllm-0.2.1.dist-info/METADATA +0 -127
- patchllm-0.2.1.dist-info/RECORD +0 -12
- patchllm-0.2.1.dist-info/entry_points.txt +0 -2
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/licenses/LICENSE +0 -0
patchllm/utils.py
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
import importlib.util
|
2
2
|
from pathlib import Path
|
3
|
+
import pprint
|
4
|
+
from rich.console import Console
|
5
|
+
|
6
|
+
console = Console()
|
3
7
|
|
4
8
|
def load_from_py_file(file_path, dict_name):
|
5
9
|
"""Dynamically loads a dictionary from a Python file."""
|
@@ -8,6 +12,8 @@ def load_from_py_file(file_path, dict_name):
|
|
8
12
|
raise FileNotFoundError(f"The file '{path}' was not found.")
|
9
13
|
|
10
14
|
spec = importlib.util.spec_from_file_location(path.stem, path)
|
15
|
+
if spec is None:
|
16
|
+
raise ImportError(f"Could not load spec for module at '{path}'")
|
11
17
|
module = importlib.util.module_from_spec(spec)
|
12
18
|
spec.loader.exec_module(module)
|
13
19
|
|
@@ -15,4 +21,16 @@ def load_from_py_file(file_path, dict_name):
|
|
15
21
|
if not isinstance(dictionary, dict):
|
16
22
|
raise TypeError(f"The file '{path}' must contain a dictionary named '{dict_name}'.")
|
17
23
|
|
18
|
-
return dictionary
|
24
|
+
return dictionary
|
25
|
+
|
26
|
+
def write_scopes_to_file(file_path, scopes_dict):
|
27
|
+
"""Writes the scopes dictionary back to a Python file."""
|
28
|
+
try:
|
29
|
+
path = Path(file_path)
|
30
|
+
with open(path, "w", encoding="utf-8") as f:
|
31
|
+
f.write("scopes = ")
|
32
|
+
f.write(pprint.pformat(scopes_dict, indent=4))
|
33
|
+
f.write("\n")
|
34
|
+
console.print(f"✅ Successfully updated '{path}'.", style="green")
|
35
|
+
except Exception as e:
|
36
|
+
console.print(f"❌ Failed to write to '{path}': {e}", style="red")
|
File without changes
|
@@ -17,6 +17,7 @@ def listen(prompt=None, timeout=5):
|
|
17
17
|
speak(prompt)
|
18
18
|
console.print("🎙 Listening...", style="cyan")
|
19
19
|
try:
|
20
|
+
recognizer.adjust_for_ambient_noise(source)
|
20
21
|
audio = recognizer.listen(source, timeout=timeout)
|
21
22
|
text = recognizer.recognize_google(audio)
|
22
23
|
console.print(f"🗣 Recognized: {text}", style="cyan")
|
@@ -27,4 +28,10 @@ def listen(prompt=None, timeout=5):
|
|
27
28
|
speak("Sorry, I didn’t catch that.")
|
28
29
|
except sr.RequestError:
|
29
30
|
speak("Speech recognition failed. Check your internet.")
|
30
|
-
return None
|
31
|
+
return None
|
32
|
+
```<file_path:patchllm/__main__.py>
|
33
|
+
```python
|
34
|
+
from .cli.entrypoint import main
|
35
|
+
|
36
|
+
if __name__ == "__main__":
|
37
|
+
main()
|
@@ -0,0 +1,153 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: patchllm
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: An interactive agent for codebase modification using LLMs
|
5
|
+
Author: nassimberrada
|
6
|
+
License: MIT License
|
7
|
+
|
8
|
+
Copyright (c) 2025 nassimberrada
|
9
|
+
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
11
|
+
of this software and associated documentation files (the “Software”), to deal
|
12
|
+
in the Software without restriction, including without limitation the rights
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
15
|
+
furnished to do so, subject to the following conditions:
|
16
|
+
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
18
|
+
copies or substantial portions of the Software.
|
19
|
+
|
20
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
26
|
+
SOFTWARE.
|
27
|
+
Requires-Python: >=3.8
|
28
|
+
Description-Content-Type: text/markdown
|
29
|
+
License-File: LICENSE
|
30
|
+
Requires-Dist: litellm
|
31
|
+
Requires-Dist: python-dotenv
|
32
|
+
Requires-Dist: rich
|
33
|
+
Requires-Dist: prompt_toolkit
|
34
|
+
Requires-Dist: InquirerPy
|
35
|
+
Provides-Extra: voice
|
36
|
+
Requires-Dist: SpeechRecognition; extra == "voice"
|
37
|
+
Requires-Dist: pyttsx3; extra == "voice"
|
38
|
+
Provides-Extra: url
|
39
|
+
Requires-Dist: html2text; extra == "url"
|
40
|
+
Provides-Extra: all
|
41
|
+
Requires-Dist: SpeechRecognition; extra == "all"
|
42
|
+
Requires-Dist: pyttsx3; extra == "all"
|
43
|
+
Requires-Dist: html2text; extra == "all"
|
44
|
+
Dynamic: license-file
|
45
|
+
|
46
|
+
<p align="center">
|
47
|
+
<picture>
|
48
|
+
<source srcset="./assets/logo_dark.png" media="(prefers-color-scheme: dark)">
|
49
|
+
<source srcset="./assets/logo_light.png" media="(prefers-color-scheme: light)">
|
50
|
+
<img src="./assets/logo_light.png" alt="PatchLLM Logo" height="200">
|
51
|
+
</picture>
|
52
|
+
</p>
|
53
|
+
|
54
|
+
## About
|
55
|
+
PatchLLM is an interactive command-line agent that helps you modify your codebase. It uses an LLM to plan and execute changes, allowing you to review and approve every step.
|
56
|
+
|
57
|
+
## Key Features
|
58
|
+
- **Interactive Planning:** The agent proposes a step-by-step plan before writing any code. You stay in control.
|
59
|
+
- **Dynamic Context:** Build and modify the code context on-the-fly using powerful scope definitions (`@git:staged`, `@dir:src`, etc.).
|
60
|
+
- **Mobile-First TUI:** A clean, command-driven interface with autocompletion makes it easy to use on any device.
|
61
|
+
- **Resilient Sessions:** Automatically saves your progress so you can resume if you get disconnected.
|
62
|
+
|
63
|
+
## Getting Started
|
64
|
+
|
65
|
+
**1. Initialize a configuration file (optional):**
|
66
|
+
This creates a `scopes.py` file to define reusable file collections.
|
67
|
+
```bash
|
68
|
+
patchllm --init
|
69
|
+
```
|
70
|
+
|
71
|
+
**2. Start the Agent:**
|
72
|
+
Running `patchllm` with no arguments drops you into the interactive agentic TUI.
|
73
|
+
```bash
|
74
|
+
patchllm
|
75
|
+
```
|
76
|
+
|
77
|
+
**3. Follow the Agent Workflow:**
|
78
|
+
Inside the TUI, you direct the agent with simple slash commands.
|
79
|
+
|
80
|
+
```bash
|
81
|
+
# 1. Set the goal
|
82
|
+
>>> /task Add a health check endpoint to the API
|
83
|
+
|
84
|
+
# 2. Build the context
|
85
|
+
>>> /context @dir:src/api
|
86
|
+
|
87
|
+
# 3. Ask the agent to generate a plan
|
88
|
+
>>> /plan
|
89
|
+
1. Add a new route `/health` to `src/api/routes.py`.
|
90
|
+
2. Implement the health check logic to return a 200 OK status.
|
91
|
+
|
92
|
+
# 4. Execute the first step and review the proposed changes
|
93
|
+
>>> /run
|
94
|
+
|
95
|
+
# 5. If the changes look good, approve them
|
96
|
+
>>> /approve
|
97
|
+
```
|
98
|
+
|
99
|
+
## Agent Commands (TUI)
|
100
|
+
| Command | Description |
|
101
|
+
|---|---|
|
102
|
+
| `/task <goal>` | Sets the high-level goal for the agent. |
|
103
|
+
| `/plan [management]` | Generates a plan, or opens an interactive TUI to edit/add/remove steps. |
|
104
|
+
| `/run [all]` | Executes the next step, or all remaining steps with `/run all`. |
|
105
|
+
| `/approve` | Interactively select and apply changes from the last run. |
|
106
|
+
| `/diff [all \| file]`| Shows the full diff for the proposed changes. |
|
107
|
+
| `/retry <feedback>`| Retries the last step with new feedback. |
|
108
|
+
| `/skip` | Skips the current step and moves to the next. |
|
109
|
+
| `/revert` | Reverts the changes from the last `/approve`. |
|
110
|
+
| `/context <scope>` | Replaces the context with files from a scope. |
|
111
|
+
| `/scopes` | Opens an interactive menu to manage your saved scopes. |
|
112
|
+
| `/ask <question>` | Ask a question about the plan or code context. |
|
113
|
+
| `/refine <feedback>`| Refine the plan based on new feedback or ideas. |
|
114
|
+
| `/show [state]` | Shows the current state (goal, plan, context, history, step). |
|
115
|
+
| `/settings` | Configure the model and API keys. |
|
116
|
+
| `/help` | Shows the detailed help message. |
|
117
|
+
| `/exit` | Exits the agent session. |
|
118
|
+
|
119
|
+
## Headless Mode Flags
|
120
|
+
For scripting or single-shot edits, you can still use the original flags.
|
121
|
+
|
122
|
+
| Flag | Alias | Description |
|
123
|
+
|---|---|---|
|
124
|
+
| `-p`, `--patch` | **Main action:** Query the LLM and apply file changes. |
|
125
|
+
| `-t`, `--task` | Provide a specific instruction to the LLM. |
|
126
|
+
| `-s`, `--scope` | Use a static scope from `scopes.py` or a dynamic one. |
|
127
|
+
| `-r`, `--recipe` | Use a predefined task from `recipes.py`. |
|
128
|
+
| `-in`, `--interactive` | Interactively build the context by selecting files. |
|
129
|
+
| `-i`, `--init` | Create a new `scopes.py` file. |
|
130
|
+
| `-sl`, `--list-scopes`| List all available scopes. |
|
131
|
+
| `-ff`, `--from-file` | Apply patches from a local file. |
|
132
|
+
| `-fc`, `--from-clipboard` | Apply patches from the system clipboard. |
|
133
|
+
| `-m`, `--model` | Specify a different model (default: `gemini/gemini-1.5-flash`). |
|
134
|
+
| `-v`, `--voice` | Enable voice interaction (requires voice dependencies). |
|
135
|
+
|
136
|
+
## Setup
|
137
|
+
PatchLLM uses [LiteLLM](https://github.com/BerriAI/litellm). Set up your API keys (e.g., `OPENAI_API_KEY`, `GEMINI_API_KEY`) in a `.env` file.
|
138
|
+
|
139
|
+
The interactive TUI requires `prompt_toolkit` and `InquirerPy`. You can install all core dependencies with:```bash
|
140
|
+
pip install -r requirements.txt
|
141
|
+
```
|
142
|
+
|
143
|
+
Optional features require extra dependencies:
|
144
|
+
```bash
|
145
|
+
# For URL support in scopes
|
146
|
+
pip install "patchllm[url]"
|
147
|
+
|
148
|
+
# For voice commands (in headless mode)
|
149
|
+
pip install "patchllm[voice]"
|
150
|
+
```
|
151
|
+
|
152
|
+
## License
|
153
|
+
This project is licensed under the MIT License.
|
@@ -0,0 +1,51 @@
|
|
1
|
+
patchllm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
patchllm/__main__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
patchllm/llm.py,sha256=vy9PZ1tyG5nd1sH3R0jYQRUxSHgvmk9sV_zX6nw3Kfk,1475
|
4
|
+
patchllm/main.py,sha256=suL4MmpshaVzkZ-ubhIE4GsVb0fKgyzU4GoHHaJbw88,71
|
5
|
+
patchllm/parser.py,sha256=H6mjUehPK9WfM-zVIU5pAx5scNfra64Ld6TL9QN-2to,5340
|
6
|
+
patchllm/patcher.py,sha256=zQ8nnhCXPvIeR9bMEj81lyeDvJyZp_Cenaux80aCivw,5024
|
7
|
+
patchllm/utils.py,sha256=yjXElAanhB_l3Kn_PIiU6uheNrrVtH-2mM7N19CvjJE,1313
|
8
|
+
patchllm/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
9
|
+
patchllm/agent/actions.py,sha256=0IbhKmZ5TzYdeEB9NpC3IT-J4sXV6YTo-wMl9ws_Hgo,2428
|
10
|
+
patchllm/agent/executor.py,sha256=pm4Elzzxy-ZGGktn7CKyv9cm8sOxCslxYXo3UGxEXUI,2114
|
11
|
+
patchllm/agent/planner.py,sha256=LugSnVVBq7ZX0ji3ca-nfABle-4pY7W6C_fBwWFVN4A,3493
|
12
|
+
patchllm/agent/session.py,sha256=M-4MzZMRrn26qug3iwXrAOTrFlHiOVO5OkyuX7Aj_Vc,18072
|
13
|
+
patchllm/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
14
|
+
patchllm/cli/entrypoint.py,sha256=nHpE_cJu1U3fHTkFdUdgb_FYGMor9573IcdUy8J0adA,5568
|
15
|
+
patchllm/cli/handlers.py,sha256=uNVyJF-WzABNicEvAqeG_GduRLUXFfCGjBcZGrvdwpk,7854
|
16
|
+
patchllm/cli/helpers.py,sha256=KYiYeTQTJMcxKa_jXiDM3lJrHxOQCt3Niohk1lb_osg,3556
|
17
|
+
patchllm/interactive/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
18
|
+
patchllm/interactive/selector.py,sha256=aUfV5vpsWxkKpTl2JcmI4Zxcu6Zj4w4whJJhKV_MazM,3915
|
19
|
+
patchllm/scopes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
20
|
+
patchllm/scopes/builder.py,sha256=u1AF0DG0i6Lh0ZefmAPqlo3G8GIqNQkq2Xxug82Awl8,2294
|
21
|
+
patchllm/scopes/constants.py,sha256=xogjoPXwkuLGONFtO3wIfiUBBb4sOAysa0VLAnGAapM,1950
|
22
|
+
patchllm/scopes/helpers.py,sha256=5h6h1F-B1EP2m-PupDQP4V8lkUNLUSInV-IT_lqP1Rs,5897
|
23
|
+
patchllm/scopes/resolvers.py,sha256=PxwRu_4GS8PMVIrQKtxWwWKx685XoEcRIikhkOmWI1o,3684
|
24
|
+
patchllm/scopes/structure.py,sha256=KSxVpHxG0IlEwxx-kfZy5G1q_OiVRrPX-fMn7so2R5g,2688
|
25
|
+
patchllm/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
26
|
+
patchllm/tui/completer.py,sha256=Zb7fVXKZHcl_mZbIsmxJVgg3-JmuwXqTQz3E18obw5U,8555
|
27
|
+
patchllm/tui/interface.py,sha256=yq5u7aeuEuRwtFQUlARMn1N5H5cLVMAC7u989jrUYUI,40547
|
28
|
+
patchllm/voice/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
29
|
+
patchllm/voice/listener.py,sha256=t8noDPsKfYFnqPw9LkbQAIUejO4zq3J-p6ah_jkburU,1142
|
30
|
+
patchllm-1.0.0.dist-info/licenses/LICENSE,sha256=vZxgIRNxffjkTV2NWLemgYjDRu0hSMTyFXCZ1zEWbUc,1077
|
31
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
32
|
+
tests/conftest.py,sha256=mJQG1axjsv97UNYcI3bfXMBk6xBiMDsW1aRkRBUqSUw,4093
|
33
|
+
tests/test_actions.py,sha256=X67hUUC2bdSoI4XP8ImIjjxLoZC5AToPHAHZRKPdglY,1840
|
34
|
+
tests/test_agent.py,sha256=aeAQ37GDZlJEKTXtDpZ03oF8NAMuG6oNN6-XESYorNs,15961
|
35
|
+
tests/test_completer.py,sha256=6S1s3DAfQrBsPxIT4wjLfCIwSf84-ipB1-DxZuRqIW8,5866
|
36
|
+
tests/test_context.py,sha256=wud1kEAGElefrtn2HWJ01MTuWLUk_TudLV9KCxJ44uM,4942
|
37
|
+
tests/test_executor.py,sha256=IZ80oAaDrhi5daJjNIKOAc5pIUnTMFyKYsyLVBxDKmk,2344
|
38
|
+
tests/test_interactive.py,sha256=W98JY7jRWN8-bCSZ1OE373FttGzOY4kVFd9m6f56nzc,2505
|
39
|
+
tests/test_parser.py,sha256=mRS6BsnrPCUkZnHpNpMn71QbVZfJ25L1zTIMtoRJKrw,2848
|
40
|
+
tests/test_patcher.py,sha256=xU-ATCI_w3H7_Jfri4qFyLNPTqBHT2pJ7Z0ZpyynJS0,2424
|
41
|
+
tests/test_planner.py,sha256=zzurBD72WiO0Lz8YVCwVgPkEAhua56lWFZCu7Q-8eB0,2204
|
42
|
+
tests/test_recipes.py,sha256=IFNW58xC1E3dT-Bxh1LS9eSKmkRZoNwX6y6cmVdSmDM,3850
|
43
|
+
tests/test_scopes.py,sha256=WsVG4N7XU06LYgn0OIEGYs0QyPnMS-mS3LuarYjxxc8,2108
|
44
|
+
tests/test_structure.py,sha256=Usoj7MP7V0skt2rONfaEVL4Q_nBM5cN3nlfRS1ax11U,2026
|
45
|
+
tests/test_tui.py,sha256=o9sbSYaVK1kf8RLhWOhGFXF8h2gZ2HgIP4R7M3y_W0A,16123
|
46
|
+
tests/test_utils.py,sha256=d8s5z2RXCbm3Tz29dCTm4tUqqx5U2W5ibOsjBN7f9s8,1142
|
47
|
+
patchllm-1.0.0.dist-info/METADATA,sha256=EnylLS0H7Ztr263qpKxbdlWYXWazOn3y5y1lVXfg9kE,6332
|
48
|
+
patchllm-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
49
|
+
patchllm-1.0.0.dist-info/entry_points.txt,sha256=6vKL9W8Lbel2FUys_L4b87FVtuLn8VkoDzC6ZyXgIIw,58
|
50
|
+
patchllm-1.0.0.dist-info/top_level.txt,sha256=efpee4d-m4g0gCUGizjwsbGfqoFQE_nJytm8eTW3DYk,15
|
51
|
+
patchllm-1.0.0.dist-info/RECORD,,
|
tests/__init__.py
ADDED
File without changes
|
tests/conftest.py
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
import pytest
|
2
|
+
import subprocess
|
3
|
+
import textwrap
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
@pytest.fixture
|
7
|
+
def temp_project(tmp_path):
|
8
|
+
"""Creates a temporary project structure for testing."""
|
9
|
+
project_dir = tmp_path / "test_project"
|
10
|
+
project_dir.mkdir()
|
11
|
+
|
12
|
+
(project_dir / "main.py").write_text("import utils\n\ndef hello():\n print('hello')")
|
13
|
+
(project_dir / "utils.py").write_text("def helper_function():\n return 1")
|
14
|
+
(project_dir / "README.md").write_text("# Test Project")
|
15
|
+
|
16
|
+
src_dir = project_dir / "src"
|
17
|
+
src_dir.mkdir()
|
18
|
+
(src_dir / "component.js").write_text("console.log('component');")
|
19
|
+
(src_dir / "styles.css").write_text("body { color: red; }")
|
20
|
+
|
21
|
+
tests_dir = project_dir / "tests"
|
22
|
+
tests_dir.mkdir()
|
23
|
+
(tests_dir / "test_utils.py").write_text("from .. import utils\n\ndef test_helper():\n assert utils.helper_function() == 1")
|
24
|
+
|
25
|
+
(project_dir / "data.log").write_text("some log data")
|
26
|
+
(project_dir / "logo.png").write_bytes(b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc`\x00\x00\x00\x02\x00\x01\xe2!\xbc\x33\x00\x00\x00\x00IEND\xaeB`\x82')
|
27
|
+
|
28
|
+
return project_dir
|
29
|
+
|
30
|
+
@pytest.fixture
|
31
|
+
def git_project(temp_project):
|
32
|
+
"""Initializes the temp_project as a Git repository."""
|
33
|
+
subprocess.run(["git", "init"], cwd=temp_project, check=True, capture_output=True)
|
34
|
+
subprocess.run(["git", "config", "user.name", "Test User"], cwd=temp_project, check=True)
|
35
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=temp_project, check=True)
|
36
|
+
subprocess.run(["git", "add", "."], cwd=temp_project, check=True)
|
37
|
+
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=temp_project, check=True, capture_output=True)
|
38
|
+
return temp_project
|
39
|
+
|
40
|
+
@pytest.fixture
|
41
|
+
def temp_scopes_file(tmp_path):
|
42
|
+
"""Creates a temporary scopes.py file for testing."""
|
43
|
+
scopes_content = """
|
44
|
+
scopes = {
|
45
|
+
'base': {
|
46
|
+
'path': '.',
|
47
|
+
'include_patterns': ['**/*.py'],
|
48
|
+
'exclude_patterns': ['tests/**'],
|
49
|
+
},
|
50
|
+
'search_scope': {
|
51
|
+
'path': '.',
|
52
|
+
'include_patterns': ['**/*'],
|
53
|
+
'search_words': ['hello']
|
54
|
+
},
|
55
|
+
'js_and_css': {
|
56
|
+
'path': 'src',
|
57
|
+
'include_patterns': ['**/*.js', '**/*.css']
|
58
|
+
}
|
59
|
+
}
|
60
|
+
"""
|
61
|
+
scopes_file = tmp_path / "scopes.py"
|
62
|
+
scopes_file.write_text(scopes_content)
|
63
|
+
return scopes_file
|
64
|
+
|
65
|
+
@pytest.fixture
|
66
|
+
def temp_recipes_file(tmp_path):
|
67
|
+
"""Creates a temporary recipes.py file for testing."""
|
68
|
+
recipes_content = """
|
69
|
+
recipes = {
|
70
|
+
"add_tests": "Please write comprehensive pytest unit tests for the functions in the provided file.",
|
71
|
+
"add_docs": "Generate Google-style docstrings for all public functions and classes.",
|
72
|
+
}
|
73
|
+
"""
|
74
|
+
recipes_file = tmp_path / "recipes.py"
|
75
|
+
recipes_file.write_text(recipes_content)
|
76
|
+
return recipes_file
|
77
|
+
|
78
|
+
@pytest.fixture
|
79
|
+
def mixed_project(tmp_path):
|
80
|
+
"""Creates a project with both Python and JS files for structure testing."""
|
81
|
+
proj_dir = tmp_path / "mixed_project"
|
82
|
+
|
83
|
+
py_api_dir = proj_dir / "api"
|
84
|
+
py_api_dir.mkdir(parents=True)
|
85
|
+
(py_api_dir / "main.py").write_text(textwrap.dedent("""
|
86
|
+
import os
|
87
|
+
from .models import User
|
88
|
+
class APIServer:
|
89
|
+
def start(self): pass
|
90
|
+
async def get_user(id: int) -> User:
|
91
|
+
# A comment
|
92
|
+
return User()
|
93
|
+
"""))
|
94
|
+
(py_api_dir / "models.py").write_text(textwrap.dedent("""
|
95
|
+
from db import Base
|
96
|
+
class User(Base): pass
|
97
|
+
"""))
|
98
|
+
|
99
|
+
js_src_dir = proj_dir / "frontend" / "src"
|
100
|
+
js_src_dir.mkdir(parents=True)
|
101
|
+
(js_src_dir / "index.js").write_text(textwrap.dedent("""
|
102
|
+
import React from "react";
|
103
|
+
export class App extends React.Component { render() { return <h1>Hello</h1>; } }
|
104
|
+
export const arrowFunc = () => { console.log('test'); }
|
105
|
+
"""))
|
106
|
+
(js_src_dir / "utils.ts").write_text(textwrap.dedent("""
|
107
|
+
export async function fetchData(url: string): Promise<any> { }
|
108
|
+
"""))
|
109
|
+
|
110
|
+
(proj_dir / "README.md").write_text("# Mixed Project")
|
111
|
+
|
112
|
+
return proj_dir
|
tests/test_actions.py
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
import pytest
|
2
|
+
from unittest.mock import patch, MagicMock
|
3
|
+
from patchllm.agent.actions import run_tests, stage_files
|
4
|
+
|
5
|
+
@patch('subprocess.run')
|
6
|
+
def test_run_tests_passed(mock_subprocess_run):
|
7
|
+
"""
|
8
|
+
Tests the run_tests function when pytest returns a success code.
|
9
|
+
"""
|
10
|
+
mock_result = MagicMock()
|
11
|
+
mock_result.returncode = 0
|
12
|
+
mock_result.stdout = "== 1 passed in 0.1s =="
|
13
|
+
mock_result.stderr = ""
|
14
|
+
mock_subprocess_run.return_value = mock_result
|
15
|
+
|
16
|
+
run_tests()
|
17
|
+
|
18
|
+
mock_subprocess_run.assert_called_once_with(
|
19
|
+
["pytest"], capture_output=True, text=True, check=False
|
20
|
+
)
|
21
|
+
|
22
|
+
@patch('subprocess.run')
|
23
|
+
def test_run_tests_failed(mock_subprocess_run):
|
24
|
+
"""
|
25
|
+
Tests the run_tests function when pytest returns a failure code.
|
26
|
+
"""
|
27
|
+
mock_result = MagicMock()
|
28
|
+
mock_result.returncode = 1
|
29
|
+
mock_result.stdout = "== 1 failed in 0.2s =="
|
30
|
+
mock_result.stderr = ""
|
31
|
+
mock_subprocess_run.return_value = mock_result
|
32
|
+
|
33
|
+
run_tests()
|
34
|
+
|
35
|
+
mock_subprocess_run.assert_called_once()
|
36
|
+
|
37
|
+
@patch('subprocess.run')
|
38
|
+
def test_stage_files_all(mock_subprocess_run):
|
39
|
+
"""
|
40
|
+
Tests staging all files with `git add .`.
|
41
|
+
"""
|
42
|
+
mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
43
|
+
|
44
|
+
stage_files()
|
45
|
+
|
46
|
+
mock_subprocess_run.assert_called_once_with(
|
47
|
+
["git", "add", "."], capture_output=True, text=True, check=True
|
48
|
+
)
|
49
|
+
|
50
|
+
@patch('subprocess.run')
|
51
|
+
def test_stage_files_specific(mock_subprocess_run):
|
52
|
+
"""
|
53
|
+
Tests staging a specific list of files.
|
54
|
+
"""
|
55
|
+
mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
56
|
+
files = ["main.py", "utils.py"]
|
57
|
+
|
58
|
+
stage_files(files)
|
59
|
+
|
60
|
+
mock_subprocess_run.assert_called_once_with(
|
61
|
+
["git", "add", "main.py", "utils.py"], capture_output=True, text=True, check=True
|
62
|
+
)
|