hud-python 0.4.45__py3-none-any.whl → 0.5.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.
Files changed (274) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +11 -5
  3. hud/agents/base.py +220 -500
  4. hud/agents/claude.py +200 -240
  5. hud/agents/gemini.py +275 -0
  6. hud/agents/gemini_cua.py +335 -0
  7. hud/agents/grounded_openai.py +98 -100
  8. hud/agents/misc/integration_test_agent.py +51 -20
  9. hud/agents/misc/response_agent.py +41 -36
  10. hud/agents/openai.py +291 -292
  11. hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
  12. hud/agents/operator.py +211 -0
  13. hud/agents/tests/conftest.py +133 -0
  14. hud/agents/tests/test_base.py +300 -622
  15. hud/agents/tests/test_base_runtime.py +233 -0
  16. hud/agents/tests/test_claude.py +379 -210
  17. hud/agents/tests/test_client.py +9 -10
  18. hud/agents/tests/test_gemini.py +369 -0
  19. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  20. hud/agents/tests/test_openai.py +376 -140
  21. hud/agents/tests/test_operator.py +362 -0
  22. hud/agents/tests/test_run_eval.py +179 -0
  23. hud/cli/__init__.py +461 -545
  24. hud/cli/analyze.py +43 -5
  25. hud/cli/build.py +664 -110
  26. hud/cli/debug.py +8 -5
  27. hud/cli/dev.py +882 -734
  28. hud/cli/eval.py +782 -668
  29. hud/cli/flows/dev.py +167 -0
  30. hud/cli/flows/init.py +191 -0
  31. hud/cli/flows/tasks.py +153 -56
  32. hud/cli/flows/templates.py +151 -0
  33. hud/cli/flows/tests/__init__.py +1 -0
  34. hud/cli/flows/tests/test_dev.py +126 -0
  35. hud/cli/init.py +60 -58
  36. hud/cli/push.py +29 -11
  37. hud/cli/rft.py +311 -0
  38. hud/cli/rft_status.py +145 -0
  39. hud/cli/tests/test_analyze.py +5 -5
  40. hud/cli/tests/test_analyze_metadata.py +3 -2
  41. hud/cli/tests/test_analyze_module.py +120 -0
  42. hud/cli/tests/test_build.py +108 -6
  43. hud/cli/tests/test_build_failure.py +41 -0
  44. hud/cli/tests/test_build_module.py +50 -0
  45. hud/cli/tests/test_cli_init.py +6 -1
  46. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  47. hud/cli/tests/test_cli_root.py +140 -0
  48. hud/cli/tests/test_convert.py +361 -0
  49. hud/cli/tests/test_debug.py +12 -10
  50. hud/cli/tests/test_dev.py +197 -0
  51. hud/cli/tests/test_eval.py +251 -0
  52. hud/cli/tests/test_eval_bedrock.py +51 -0
  53. hud/cli/tests/test_init.py +124 -0
  54. hud/cli/tests/test_main_module.py +11 -5
  55. hud/cli/tests/test_mcp_server.py +12 -100
  56. hud/cli/tests/test_push_happy.py +74 -0
  57. hud/cli/tests/test_push_wrapper.py +23 -0
  58. hud/cli/tests/test_registry.py +1 -1
  59. hud/cli/tests/test_utils.py +1 -1
  60. hud/cli/{rl → utils}/celebrate.py +14 -12
  61. hud/cli/utils/config.py +18 -1
  62. hud/cli/utils/docker.py +130 -4
  63. hud/cli/utils/env_check.py +9 -9
  64. hud/cli/utils/git.py +136 -0
  65. hud/cli/utils/interactive.py +39 -5
  66. hud/cli/utils/metadata.py +69 -0
  67. hud/cli/utils/runner.py +1 -1
  68. hud/cli/utils/server.py +2 -2
  69. hud/cli/utils/source_hash.py +3 -3
  70. hud/cli/utils/tasks.py +4 -1
  71. hud/cli/utils/tests/__init__.py +0 -0
  72. hud/cli/utils/tests/test_config.py +58 -0
  73. hud/cli/utils/tests/test_docker.py +93 -0
  74. hud/cli/utils/tests/test_docker_hints.py +71 -0
  75. hud/cli/utils/tests/test_env_check.py +74 -0
  76. hud/cli/utils/tests/test_environment.py +42 -0
  77. hud/cli/utils/tests/test_git.py +142 -0
  78. hud/cli/utils/tests/test_interactive_module.py +60 -0
  79. hud/cli/utils/tests/test_local_runner.py +50 -0
  80. hud/cli/utils/tests/test_logging_utils.py +23 -0
  81. hud/cli/utils/tests/test_metadata.py +49 -0
  82. hud/cli/utils/tests/test_package_runner.py +35 -0
  83. hud/cli/utils/tests/test_registry_utils.py +49 -0
  84. hud/cli/utils/tests/test_remote_runner.py +25 -0
  85. hud/cli/utils/tests/test_runner_modules.py +52 -0
  86. hud/cli/utils/tests/test_source_hash.py +36 -0
  87. hud/cli/utils/tests/test_tasks.py +80 -0
  88. hud/cli/utils/version_check.py +258 -0
  89. hud/cli/{rl → utils}/viewer.py +2 -2
  90. hud/clients/README.md +12 -11
  91. hud/clients/__init__.py +4 -3
  92. hud/clients/base.py +166 -26
  93. hud/clients/environment.py +51 -0
  94. hud/clients/fastmcp.py +13 -6
  95. hud/clients/mcp_use.py +40 -15
  96. hud/clients/tests/test_analyze_scenarios.py +206 -0
  97. hud/clients/tests/test_protocol.py +9 -3
  98. hud/datasets/__init__.py +23 -20
  99. hud/datasets/loader.py +327 -0
  100. hud/datasets/runner.py +192 -105
  101. hud/datasets/tests/__init__.py +0 -0
  102. hud/datasets/tests/test_loader.py +221 -0
  103. hud/datasets/tests/test_utils.py +315 -0
  104. hud/datasets/utils.py +270 -90
  105. hud/environment/__init__.py +50 -0
  106. hud/environment/connection.py +206 -0
  107. hud/environment/connectors/__init__.py +33 -0
  108. hud/environment/connectors/base.py +68 -0
  109. hud/environment/connectors/local.py +177 -0
  110. hud/environment/connectors/mcp_config.py +109 -0
  111. hud/environment/connectors/openai.py +101 -0
  112. hud/environment/connectors/remote.py +172 -0
  113. hud/environment/environment.py +694 -0
  114. hud/environment/integrations/__init__.py +45 -0
  115. hud/environment/integrations/adk.py +67 -0
  116. hud/environment/integrations/anthropic.py +196 -0
  117. hud/environment/integrations/gemini.py +92 -0
  118. hud/environment/integrations/langchain.py +82 -0
  119. hud/environment/integrations/llamaindex.py +68 -0
  120. hud/environment/integrations/openai.py +238 -0
  121. hud/environment/mock.py +306 -0
  122. hud/environment/router.py +112 -0
  123. hud/environment/scenarios.py +493 -0
  124. hud/environment/tests/__init__.py +1 -0
  125. hud/environment/tests/test_connection.py +317 -0
  126. hud/environment/tests/test_connectors.py +218 -0
  127. hud/environment/tests/test_environment.py +161 -0
  128. hud/environment/tests/test_integrations.py +257 -0
  129. hud/environment/tests/test_local_connectors.py +201 -0
  130. hud/environment/tests/test_scenarios.py +280 -0
  131. hud/environment/tests/test_tools.py +208 -0
  132. hud/environment/types.py +23 -0
  133. hud/environment/utils/__init__.py +35 -0
  134. hud/environment/utils/formats.py +215 -0
  135. hud/environment/utils/schema.py +171 -0
  136. hud/environment/utils/tool_wrappers.py +113 -0
  137. hud/eval/__init__.py +67 -0
  138. hud/eval/context.py +674 -0
  139. hud/eval/display.py +299 -0
  140. hud/eval/instrument.py +185 -0
  141. hud/eval/manager.py +466 -0
  142. hud/eval/parallel.py +268 -0
  143. hud/eval/task.py +340 -0
  144. hud/eval/tests/__init__.py +1 -0
  145. hud/eval/tests/test_context.py +178 -0
  146. hud/eval/tests/test_eval.py +210 -0
  147. hud/eval/tests/test_manager.py +152 -0
  148. hud/eval/tests/test_parallel.py +168 -0
  149. hud/eval/tests/test_task.py +145 -0
  150. hud/eval/types.py +63 -0
  151. hud/eval/utils.py +183 -0
  152. hud/patches/__init__.py +19 -0
  153. hud/patches/mcp_patches.py +151 -0
  154. hud/patches/warnings.py +54 -0
  155. hud/samples/browser.py +4 -4
  156. hud/server/__init__.py +2 -1
  157. hud/server/low_level.py +2 -1
  158. hud/server/router.py +164 -0
  159. hud/server/server.py +567 -80
  160. hud/server/tests/test_mcp_server_integration.py +11 -11
  161. hud/server/tests/test_mcp_server_more.py +1 -1
  162. hud/server/tests/test_server_extra.py +2 -0
  163. hud/settings.py +45 -3
  164. hud/shared/exceptions.py +36 -10
  165. hud/shared/hints.py +26 -1
  166. hud/shared/requests.py +15 -3
  167. hud/shared/tests/test_exceptions.py +40 -31
  168. hud/shared/tests/test_hints.py +167 -0
  169. hud/telemetry/__init__.py +20 -19
  170. hud/telemetry/exporter.py +201 -0
  171. hud/telemetry/instrument.py +158 -253
  172. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  173. hud/telemetry/tests/test_exporter.py +258 -0
  174. hud/telemetry/tests/test_instrument.py +401 -0
  175. hud/tools/__init__.py +16 -2
  176. hud/tools/apply_patch.py +639 -0
  177. hud/tools/base.py +54 -4
  178. hud/tools/bash.py +2 -2
  179. hud/tools/computer/__init__.py +4 -0
  180. hud/tools/computer/anthropic.py +2 -2
  181. hud/tools/computer/gemini.py +385 -0
  182. hud/tools/computer/hud.py +23 -6
  183. hud/tools/computer/openai.py +20 -21
  184. hud/tools/computer/qwen.py +434 -0
  185. hud/tools/computer/settings.py +37 -0
  186. hud/tools/edit.py +3 -7
  187. hud/tools/executors/base.py +4 -2
  188. hud/tools/executors/pyautogui.py +1 -1
  189. hud/tools/grounding/grounded_tool.py +13 -18
  190. hud/tools/grounding/grounder.py +10 -31
  191. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  192. hud/tools/jupyter.py +330 -0
  193. hud/tools/playwright.py +18 -3
  194. hud/tools/shell.py +308 -0
  195. hud/tools/tests/test_apply_patch.py +718 -0
  196. hud/tools/tests/test_computer.py +4 -9
  197. hud/tools/tests/test_computer_actions.py +24 -2
  198. hud/tools/tests/test_jupyter_tool.py +181 -0
  199. hud/tools/tests/test_shell.py +596 -0
  200. hud/tools/tests/test_submit.py +85 -0
  201. hud/tools/tests/test_types.py +193 -0
  202. hud/tools/types.py +21 -1
  203. hud/types.py +167 -57
  204. hud/utils/__init__.py +2 -0
  205. hud/utils/env.py +67 -0
  206. hud/utils/hud_console.py +61 -3
  207. hud/utils/mcp.py +15 -58
  208. hud/utils/strict_schema.py +162 -0
  209. hud/utils/tests/test_init.py +1 -2
  210. hud/utils/tests/test_mcp.py +1 -28
  211. hud/utils/tests/test_pretty_errors.py +186 -0
  212. hud/utils/tests/test_tool_shorthand.py +154 -0
  213. hud/utils/tests/test_version.py +1 -1
  214. hud/utils/types.py +20 -0
  215. hud/version.py +1 -1
  216. hud_python-0.5.1.dist-info/METADATA +264 -0
  217. hud_python-0.5.1.dist-info/RECORD +299 -0
  218. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
  219. hud/agents/langchain.py +0 -261
  220. hud/agents/lite_llm.py +0 -72
  221. hud/cli/rl/__init__.py +0 -180
  222. hud/cli/rl/config.py +0 -101
  223. hud/cli/rl/display.py +0 -133
  224. hud/cli/rl/gpu.py +0 -63
  225. hud/cli/rl/gpu_utils.py +0 -321
  226. hud/cli/rl/local_runner.py +0 -595
  227. hud/cli/rl/presets.py +0 -96
  228. hud/cli/rl/remote_runner.py +0 -463
  229. hud/cli/rl/rl_api.py +0 -150
  230. hud/cli/rl/vllm.py +0 -177
  231. hud/cli/rl/wait_utils.py +0 -89
  232. hud/datasets/parallel.py +0 -687
  233. hud/misc/__init__.py +0 -1
  234. hud/misc/claude_plays_pokemon.py +0 -292
  235. hud/otel/__init__.py +0 -35
  236. hud/otel/collector.py +0 -142
  237. hud/otel/config.py +0 -181
  238. hud/otel/context.py +0 -570
  239. hud/otel/exporters.py +0 -369
  240. hud/otel/instrumentation.py +0 -135
  241. hud/otel/processors.py +0 -121
  242. hud/otel/tests/__init__.py +0 -1
  243. hud/otel/tests/test_processors.py +0 -197
  244. hud/rl/README.md +0 -30
  245. hud/rl/__init__.py +0 -1
  246. hud/rl/actor.py +0 -176
  247. hud/rl/buffer.py +0 -405
  248. hud/rl/chat_template.jinja +0 -101
  249. hud/rl/config.py +0 -192
  250. hud/rl/distributed.py +0 -132
  251. hud/rl/learner.py +0 -637
  252. hud/rl/tests/__init__.py +0 -1
  253. hud/rl/tests/test_learner.py +0 -186
  254. hud/rl/train.py +0 -382
  255. hud/rl/types.py +0 -101
  256. hud/rl/utils/start_vllm_server.sh +0 -30
  257. hud/rl/utils.py +0 -524
  258. hud/rl/vllm_adapter.py +0 -143
  259. hud/telemetry/job.py +0 -352
  260. hud/telemetry/replay.py +0 -74
  261. hud/telemetry/tests/test_replay.py +0 -40
  262. hud/telemetry/tests/test_trace.py +0 -63
  263. hud/telemetry/trace.py +0 -158
  264. hud/utils/agent_factories.py +0 -86
  265. hud/utils/async_utils.py +0 -65
  266. hud/utils/group_eval.py +0 -223
  267. hud/utils/progress.py +0 -149
  268. hud/utils/tasks.py +0 -127
  269. hud/utils/tests/test_async_utils.py +0 -173
  270. hud/utils/tests/test_progress.py +0 -261
  271. hud_python-0.4.45.dist-info/METADATA +0 -552
  272. hud_python-0.4.45.dist-info/RECORD +0 -228
  273. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
  274. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,718 @@
1
+ """Tests for apply_patch tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from hud.tools.apply_patch import (
12
+ ActionType,
13
+ ApplyPatchResult,
14
+ ApplyPatchTool,
15
+ Chunk,
16
+ Commit,
17
+ DiffError,
18
+ FileChange,
19
+ Parser,
20
+ Patch,
21
+ PatchAction,
22
+ _apply_commit,
23
+ _find_context,
24
+ _find_context_core,
25
+ _get_updated_file,
26
+ _identify_files_needed,
27
+ _patch_to_commit,
28
+ _text_to_patch,
29
+ )
30
+
31
+
32
+ class TestApplyPatchResult:
33
+ """Tests for ApplyPatchResult dataclass."""
34
+
35
+ def test_to_dict_completed(self):
36
+ """Test to_dict for completed result."""
37
+ result = ApplyPatchResult(status="completed", output="Success")
38
+ assert result.to_dict() == {"status": "completed", "output": "Success"}
39
+
40
+ def test_to_dict_failed(self):
41
+ """Test to_dict for failed result."""
42
+ result = ApplyPatchResult(status="failed", output="Error message")
43
+ assert result.to_dict() == {"status": "failed", "output": "Error message"}
44
+
45
+
46
+ class TestParser:
47
+ """Tests for Parser class."""
48
+
49
+ def test_is_done_at_end(self):
50
+ """Test is_done when at end of lines."""
51
+ parser = Parser(current_files={}, lines=["line1"], index=1)
52
+ assert parser.is_done() is True
53
+
54
+ def test_is_done_with_prefix(self):
55
+ """Test is_done with matching prefix."""
56
+ parser = Parser(current_files={}, lines=["*** End Patch"], index=0)
57
+ assert parser.is_done(("*** End Patch",)) is True
58
+
59
+ def test_is_done_no_match(self):
60
+ """Test is_done when prefix doesn't match."""
61
+ parser = Parser(current_files={}, lines=["other line"], index=0)
62
+ assert parser.is_done(("*** End Patch",)) is False
63
+
64
+ def test_startswith(self):
65
+ """Test startswith method."""
66
+ parser = Parser(current_files={}, lines=["*** Update File: test.txt"], index=0)
67
+ assert parser.startswith("*** Update File:") is True
68
+ assert parser.startswith("*** Delete File:") is False
69
+
70
+ def test_read_str_with_prefix(self):
71
+ """Test read_str extracts text after prefix."""
72
+ parser = Parser(current_files={}, lines=["*** Update File: test.txt"], index=0)
73
+ result = parser.read_str("*** Update File: ")
74
+ assert result == "test.txt"
75
+ assert parser.index == 1
76
+
77
+ def test_read_str_no_match(self):
78
+ """Test read_str returns empty when prefix doesn't match."""
79
+ parser = Parser(current_files={}, lines=["other line"], index=0)
80
+ result = parser.read_str("*** Update File: ")
81
+ assert result == ""
82
+ assert parser.index == 0
83
+
84
+ def test_read_str_return_everything(self):
85
+ """Test read_str with return_everything=True."""
86
+ parser = Parser(current_files={}, lines=["*** Update File: test.txt"], index=0)
87
+ result = parser.read_str("*** Update File: ", return_everything=True)
88
+ assert result == "*** Update File: test.txt"
89
+
90
+ def test_parse_add_file(self):
91
+ """Test parsing add file action."""
92
+ lines = [
93
+ "*** Begin Patch",
94
+ "*** Add File: new.txt",
95
+ "+line 1",
96
+ "+line 2",
97
+ "*** End Patch",
98
+ ]
99
+ parser = Parser(current_files={}, lines=lines, index=1)
100
+ parser.parse()
101
+
102
+ assert "new.txt" in parser.patch.actions
103
+ action = parser.patch.actions["new.txt"]
104
+ assert action.type == ActionType.ADD
105
+ assert action.new_file == "line 1\nline 2"
106
+
107
+ def test_parse_delete_file(self):
108
+ """Test parsing delete file action."""
109
+ lines = [
110
+ "*** Begin Patch",
111
+ "*** Delete File: old.txt",
112
+ "*** End Patch",
113
+ ]
114
+ parser = Parser(current_files={"old.txt": "content"}, lines=lines, index=1)
115
+ parser.parse()
116
+
117
+ assert "old.txt" in parser.patch.actions
118
+ action = parser.patch.actions["old.txt"]
119
+ assert action.type == ActionType.DELETE
120
+
121
+ def test_parse_missing_end_patch(self):
122
+ """Test that truncated patch (no end marker) raises error."""
123
+ lines = [
124
+ "*** Begin Patch",
125
+ "*** Add File: new.txt",
126
+ "+content",
127
+ ]
128
+ parser = Parser(current_files={}, lines=lines, index=1)
129
+ with pytest.raises(DiffError, match="Missing End Patch"):
130
+ parser.parse()
131
+
132
+ def test_parse_truncated_update_file(self):
133
+ """Test that truncated update file patch raises DiffError, not AssertionError."""
134
+ lines = [
135
+ "*** Begin Patch",
136
+ "*** Update File: test.txt",
137
+ ]
138
+ parser = Parser(current_files={"test.txt": "content"}, lines=lines, index=1)
139
+ # Should raise DiffError for unexpected EOF, not AssertionError
140
+ with pytest.raises(DiffError):
141
+ parser.parse()
142
+
143
+ def test_startswith_at_eof(self):
144
+ """Test that startswith at EOF raises DiffError, not AssertionError."""
145
+ parser = Parser(current_files={}, lines=["line"], index=1) # index past end
146
+ with pytest.raises(DiffError, match="Unexpected end of patch"):
147
+ parser.startswith("test")
148
+
149
+ def test_read_str_at_eof(self):
150
+ """Test that read_str at EOF returns empty string, not AssertionError."""
151
+ parser = Parser(current_files={}, lines=["line"], index=1) # index past end
152
+ result = parser.read_str("test")
153
+ assert result == ""
154
+
155
+ def test_parse_wrong_end_marker(self):
156
+ """Test that wrong end marker in add file content raises error."""
157
+ lines = [
158
+ "*** Begin Patch",
159
+ "*** Add File: new.txt",
160
+ "+content",
161
+ "*** Wrong End", # This is inside the add file, so it's an invalid line
162
+ ]
163
+ parser = Parser(current_files={}, lines=lines, index=1)
164
+ with pytest.raises(DiffError, match="Invalid Add File Line"):
165
+ parser.parse()
166
+
167
+ def test_parse_duplicate_path_error(self):
168
+ """Test that duplicate paths raise error."""
169
+ lines = [
170
+ "*** Begin Patch",
171
+ "*** Add File: test.txt",
172
+ "+content",
173
+ "*** Add File: test.txt",
174
+ "+more content",
175
+ "*** End Patch",
176
+ ]
177
+ parser = Parser(current_files={}, lines=lines, index=1)
178
+ with pytest.raises(DiffError, match="Duplicate Path"):
179
+ parser.parse()
180
+
181
+ def test_parse_update_missing_file_error(self):
182
+ """Test that updating missing file raises error."""
183
+ lines = [
184
+ "*** Begin Patch",
185
+ "*** Update File: nonexistent.txt",
186
+ " context",
187
+ "*** End Patch",
188
+ ]
189
+ parser = Parser(current_files={}, lines=lines, index=1)
190
+ with pytest.raises(DiffError, match="Missing File"):
191
+ parser.parse()
192
+
193
+ def test_parse_delete_missing_file_error(self):
194
+ """Test that deleting missing file raises error."""
195
+ lines = [
196
+ "*** Begin Patch",
197
+ "*** Delete File: nonexistent.txt",
198
+ "*** End Patch",
199
+ ]
200
+ parser = Parser(current_files={}, lines=lines, index=1)
201
+ with pytest.raises(DiffError, match="Missing File"):
202
+ parser.parse()
203
+
204
+
205
+ class TestHelperFunctions:
206
+ """Tests for helper functions."""
207
+
208
+ def test_find_context_core_exact_match(self):
209
+ """Test _find_context_core with exact match."""
210
+ lines = ["a", "b", "c", "d"]
211
+ context = ["b", "c"]
212
+ index, fuzz = _find_context_core(lines, context, 0)
213
+ assert index == 1
214
+ assert fuzz == 0
215
+
216
+ def test_find_context_core_rstrip_match(self):
217
+ """Test _find_context_core with rstrip match."""
218
+ lines = ["a", "b ", "c ", "d"]
219
+ context = ["b", "c"]
220
+ index, fuzz = _find_context_core(lines, context, 0)
221
+ assert index == 1
222
+ assert fuzz == 1
223
+
224
+ def test_find_context_core_strip_match(self):
225
+ """Test _find_context_core with strip match."""
226
+ lines = ["a", " b ", " c ", "d"]
227
+ context = ["b", "c"]
228
+ index, fuzz = _find_context_core(lines, context, 0)
229
+ assert index == 1
230
+ assert fuzz == 100
231
+
232
+ def test_find_context_core_no_match(self):
233
+ """Test _find_context_core with no match."""
234
+ lines = ["a", "b", "c"]
235
+ context = ["x", "y"]
236
+ index, _ = _find_context_core(lines, context, 0)
237
+ assert index == -1
238
+
239
+ def test_find_context_core_empty_context(self):
240
+ """Test _find_context_core with empty context."""
241
+ lines = ["a", "b"]
242
+ index, fuzz = _find_context_core(lines, [], 0)
243
+ assert index == 0
244
+ assert fuzz == 0
245
+
246
+ def test_find_context_eof(self):
247
+ """Test _find_context with EOF flag."""
248
+ lines = ["a", "b", "c", "d"]
249
+ context = ["c", "d"]
250
+ index, fuzz = _find_context(lines, context, 0, eof=True)
251
+ assert index == 2
252
+ assert fuzz == 0
253
+
254
+ def test_identify_files_needed(self):
255
+ """Test _identify_files_needed."""
256
+ text = """*** Begin Patch
257
+ *** Update File: file1.txt
258
+ context
259
+ *** Delete File: file2.txt
260
+ *** Add File: file3.txt
261
+ +new content
262
+ *** End Patch"""
263
+ files = _identify_files_needed(text)
264
+ assert set(files) == {"file1.txt", "file2.txt"}
265
+
266
+ def test_get_updated_file_simple(self):
267
+ """Test _get_updated_file with simple update."""
268
+ text = "line1\nline2\nline3"
269
+ action = PatchAction(
270
+ type=ActionType.UPDATE,
271
+ chunks=[
272
+ Chunk(orig_index=1, del_lines=["line2"], ins_lines=["new line2"]),
273
+ ],
274
+ )
275
+ result = _get_updated_file(text, action, "test.txt")
276
+ assert result == "line1\nnew line2\nline3"
277
+
278
+ def test_patch_to_commit_add(self):
279
+ """Test _patch_to_commit with add action."""
280
+ patch = Patch(actions={"new.txt": PatchAction(type=ActionType.ADD, new_file="content")})
281
+ commit = _patch_to_commit(patch, {})
282
+ assert "new.txt" in commit.changes
283
+ assert commit.changes["new.txt"].type == ActionType.ADD
284
+ assert commit.changes["new.txt"].new_content == "content"
285
+
286
+ def test_patch_to_commit_delete(self):
287
+ """Test _patch_to_commit with delete action."""
288
+ patch = Patch(actions={"old.txt": PatchAction(type=ActionType.DELETE)})
289
+ orig = {"old.txt": "old content"}
290
+ commit = _patch_to_commit(patch, orig)
291
+ assert commit.changes["old.txt"].type == ActionType.DELETE
292
+ assert commit.changes["old.txt"].old_content == "old content"
293
+
294
+ def test_apply_commit(self):
295
+ """Test _apply_commit function."""
296
+ written = {}
297
+ removed = []
298
+
299
+ def write_fn(path, content):
300
+ written[path] = content
301
+
302
+ def remove_fn(path):
303
+ removed.append(path)
304
+
305
+ commit = Commit(
306
+ changes={
307
+ "new.txt": FileChange(type=ActionType.ADD, new_content="new content"),
308
+ "old.txt": FileChange(type=ActionType.DELETE, old_content="old"),
309
+ }
310
+ )
311
+ _apply_commit(commit, write_fn, remove_fn)
312
+
313
+ assert written == {"new.txt": "new content"}
314
+ assert removed == ["old.txt"]
315
+
316
+ def test_apply_commit_with_move(self):
317
+ """Test _apply_commit with move operation."""
318
+ written = {}
319
+ removed = []
320
+
321
+ def write_fn(path, content):
322
+ written[path] = content
323
+
324
+ def remove_fn(path):
325
+ removed.append(path)
326
+
327
+ commit = Commit(
328
+ changes={
329
+ "old.txt": FileChange(
330
+ type=ActionType.UPDATE,
331
+ old_content="old",
332
+ new_content="new",
333
+ move_path="renamed.txt",
334
+ ),
335
+ }
336
+ )
337
+ _apply_commit(commit, write_fn, remove_fn)
338
+
339
+ assert written == {"renamed.txt": "new"}
340
+ assert removed == ["old.txt"]
341
+
342
+ def test_text_to_patch_invalid(self):
343
+ """Test _text_to_patch with invalid patch text."""
344
+ with pytest.raises(DiffError, match="Invalid patch text"):
345
+ _text_to_patch("invalid", {})
346
+
347
+ def test_text_to_patch_valid(self):
348
+ """Test _text_to_patch with valid patch."""
349
+ text = """*** Begin Patch
350
+ *** Add File: test.txt
351
+ +content
352
+ *** End Patch"""
353
+ patch, fuzz = _text_to_patch(text, {})
354
+ assert "test.txt" in patch.actions
355
+ assert fuzz == 0
356
+
357
+
358
+ class TestApplyPatchTool:
359
+ """Tests for ApplyPatchTool."""
360
+
361
+ def test_init_default(self):
362
+ """Test default initialization."""
363
+ tool = ApplyPatchTool()
364
+ assert tool.base_path == os.path.abspath(".")
365
+
366
+ def test_init_with_base_path(self):
367
+ """Test initialization with custom base path."""
368
+ with tempfile.TemporaryDirectory() as tmpdir:
369
+ tool = ApplyPatchTool(base_path=tmpdir)
370
+ assert tool.base_path == os.path.abspath(tmpdir)
371
+
372
+ def test_validate_path_absolute(self):
373
+ """Test that absolute paths are rejected."""
374
+ tool = ApplyPatchTool()
375
+ with pytest.raises(DiffError, match="Absolute paths are not allowed"):
376
+ tool._validate_path("/absolute/path")
377
+
378
+ def test_validate_path_traversal(self):
379
+ """Test that path traversal is detected."""
380
+ with tempfile.TemporaryDirectory() as tmpdir:
381
+ tool = ApplyPatchTool(base_path=tmpdir)
382
+ with pytest.raises(DiffError, match="Path traversal detected"):
383
+ tool._validate_path("../outside")
384
+
385
+ def test_validate_path_traversal_sibling_prefix(self):
386
+ """Test that path traversal via sibling directory with shared prefix is detected.
387
+
388
+ Bug: Path traversal check bypassed via sibling directory prefix.
389
+
390
+ The path traversal check `full_path.startswith(self.base_path)` uses string
391
+ prefix matching, which can be bypassed when sibling directories share a name
392
+ prefix with the base directory. For example, if base_path is /tmp/myapp and
393
+ a user provides path ../myapp_sibling/secret.txt, the resolved full_path
394
+ becomes /tmp/myapp_sibling/secret.txt. The check passes because the string
395
+ /tmp/myapp_sibling/secret.txt starts with /tmp/myapp, allowing access to
396
+ files outside the intended sandbox.
397
+
398
+ The fix is to ensure a path separator follows the base path
399
+ (e.g., full_path.startswith(self.base_path + os.sep)) or use os.path.commonpath.
400
+ """
401
+ with tempfile.TemporaryDirectory() as tmpdir:
402
+ # Create base directory "myapp" and sibling directory "myapp_sibling"
403
+ base_dir = os.path.join(tmpdir, "myapp")
404
+ sibling_dir = os.path.join(tmpdir, "myapp_sibling")
405
+ os.makedirs(base_dir)
406
+ os.makedirs(sibling_dir)
407
+
408
+ # Create a "secret" file in the sibling directory
409
+ secret_file = os.path.join(sibling_dir, "secret.txt")
410
+ Path(secret_file).write_text("secret content")
411
+
412
+ tool = ApplyPatchTool(base_path=base_dir)
413
+
414
+ # Attempt to access the sibling directory via path traversal
415
+ # This should be detected as path traversal, but the bug allows it
416
+ # because "/tmp/.../myapp_sibling/secret.txt".startswith("/tmp/.../myapp")
417
+ # returns True due to string prefix matching
418
+ with pytest.raises(DiffError, match="Path traversal detected"):
419
+ tool._validate_path("../myapp_sibling/secret.txt")
420
+
421
+ def test_validate_path_valid(self):
422
+ """Test valid path validation."""
423
+ with tempfile.TemporaryDirectory() as tmpdir:
424
+ tool = ApplyPatchTool(base_path=tmpdir)
425
+ result = tool._validate_path("subdir/file.txt")
426
+ # Normalize path separators for cross-platform compatibility
427
+ expected = os.path.normpath(os.path.join(tmpdir, "subdir/file.txt"))
428
+ assert result == expected
429
+
430
+ @pytest.mark.asyncio
431
+ async def test_call_missing_type(self):
432
+ """Test call with missing operation type."""
433
+ tool = ApplyPatchTool()
434
+ result = await tool(path="test.txt")
435
+ assert result.status == "failed"
436
+ assert "Missing operation type" in result.output
437
+
438
+ @pytest.mark.asyncio
439
+ async def test_call_missing_path(self):
440
+ """Test call with missing path."""
441
+ tool = ApplyPatchTool()
442
+ result = await tool(type="create_file")
443
+ assert result.status == "failed"
444
+ assert "Missing file path" in result.output
445
+
446
+ @pytest.mark.asyncio
447
+ async def test_call_unknown_type(self):
448
+ """Test call with unknown operation type."""
449
+ with tempfile.TemporaryDirectory() as tmpdir:
450
+ tool = ApplyPatchTool(base_path=tmpdir)
451
+ result = await tool(type="unknown_op", path="test.txt")
452
+ assert result.status == "failed"
453
+ assert "Unknown operation type" in result.output
454
+
455
+ @pytest.mark.asyncio
456
+ async def test_create_file_success(self):
457
+ """Test successful file creation."""
458
+ with tempfile.TemporaryDirectory() as tmpdir:
459
+ tool = ApplyPatchTool(base_path=tmpdir)
460
+ result = await tool(
461
+ type="create_file",
462
+ path="new.txt",
463
+ diff="+line 1\n+line 2",
464
+ )
465
+ assert result.status == "completed"
466
+ assert "Created" in result.output
467
+
468
+ # Verify file was created
469
+ with open(os.path.join(tmpdir, "new.txt")) as f: # noqa: ASYNC230
470
+ content = f.read()
471
+ assert content == "line 1\nline 2"
472
+
473
+ @pytest.mark.asyncio
474
+ async def test_create_file_already_exists(self):
475
+ """Test creating file that already exists."""
476
+ with tempfile.TemporaryDirectory() as tmpdir:
477
+ # Create existing file
478
+ existing_path = os.path.join(tmpdir, "existing.txt")
479
+ Path(existing_path).write_text("existing content")
480
+
481
+ tool = ApplyPatchTool(base_path=tmpdir)
482
+ result = await tool(
483
+ type="create_file",
484
+ path="existing.txt",
485
+ diff="+new content",
486
+ )
487
+ assert result.status == "failed"
488
+ assert "already exists" in result.output
489
+
490
+ @pytest.mark.asyncio
491
+ async def test_create_file_missing_diff(self):
492
+ """Test creating file without diff."""
493
+ with tempfile.TemporaryDirectory() as tmpdir:
494
+ tool = ApplyPatchTool(base_path=tmpdir)
495
+ result = await tool(
496
+ type="create_file",
497
+ path="new.txt",
498
+ )
499
+ assert result.status == "failed"
500
+ assert "Missing diff" in result.output
501
+
502
+ @pytest.mark.asyncio
503
+ async def test_delete_file_success(self):
504
+ """Test successful file deletion."""
505
+ with tempfile.TemporaryDirectory() as tmpdir:
506
+ # Create file to delete
507
+ file_path = os.path.join(tmpdir, "to_delete.txt")
508
+ Path(file_path).write_text("content")
509
+
510
+ tool = ApplyPatchTool(base_path=tmpdir)
511
+ result = await tool(
512
+ type="delete_file",
513
+ path="to_delete.txt",
514
+ )
515
+ assert result.status == "completed"
516
+ assert "Deleted" in result.output
517
+ assert not os.path.exists(file_path)
518
+
519
+ @pytest.mark.asyncio
520
+ async def test_delete_file_not_found(self):
521
+ """Test deleting non-existent file."""
522
+ with tempfile.TemporaryDirectory() as tmpdir:
523
+ tool = ApplyPatchTool(base_path=tmpdir)
524
+ result = await tool(
525
+ type="delete_file",
526
+ path="nonexistent.txt",
527
+ )
528
+ assert result.status == "failed"
529
+ assert "not found" in result.output
530
+
531
+ @pytest.mark.asyncio
532
+ async def test_update_file_success(self):
533
+ """Test successful file update."""
534
+ with tempfile.TemporaryDirectory() as tmpdir:
535
+ # Create file to update
536
+ file_path = os.path.join(tmpdir, "test.txt")
537
+ Path(file_path).write_text("line1\nline2\nline3")
538
+
539
+ tool = ApplyPatchTool(base_path=tmpdir)
540
+ result = await tool(
541
+ type="update_file",
542
+ path="test.txt",
543
+ diff=" line1\n-line2\n+new line2\n line3",
544
+ )
545
+ assert result.status == "completed"
546
+ assert "Updated" in result.output
547
+
548
+ # Verify file was updated
549
+ with open(file_path) as f: # noqa: ASYNC230
550
+ content = f.read()
551
+ assert content == "line1\nnew line2\nline3"
552
+
553
+ @pytest.mark.asyncio
554
+ async def test_update_file_not_found(self):
555
+ """Test updating non-existent file."""
556
+ with tempfile.TemporaryDirectory() as tmpdir:
557
+ tool = ApplyPatchTool(base_path=tmpdir)
558
+ result = await tool(
559
+ type="update_file",
560
+ path="nonexistent.txt",
561
+ diff=" line1\n-line2\n+new line2",
562
+ )
563
+ assert result.status == "failed"
564
+ assert "not found" in result.output
565
+
566
+ @pytest.mark.asyncio
567
+ async def test_update_file_missing_diff(self):
568
+ """Test updating file without diff."""
569
+ with tempfile.TemporaryDirectory() as tmpdir:
570
+ # Create file
571
+ file_path = os.path.join(tmpdir, "test.txt")
572
+ Path(file_path).write_text("content")
573
+
574
+ tool = ApplyPatchTool(base_path=tmpdir)
575
+ result = await tool(
576
+ type="update_file",
577
+ path="test.txt",
578
+ )
579
+ assert result.status == "failed"
580
+ assert "Missing diff" in result.output
581
+
582
+ @pytest.mark.asyncio
583
+ async def test_create_file_with_subdirectory(self):
584
+ """Test creating file in subdirectory."""
585
+ with tempfile.TemporaryDirectory() as tmpdir:
586
+ tool = ApplyPatchTool(base_path=tmpdir)
587
+ result = await tool(
588
+ type="create_file",
589
+ path="subdir/nested/file.txt",
590
+ diff="+content",
591
+ )
592
+ assert result.status == "completed"
593
+
594
+ # Verify file was created in subdirectory
595
+ file_path = os.path.join(tmpdir, "subdir/nested/file.txt")
596
+ assert os.path.exists(file_path)
597
+ with open(file_path) as f: # noqa: ASYNC230
598
+ assert f.read() == "content"
599
+
600
+ def test_parse_create_diff(self):
601
+ """Test _parse_create_diff method."""
602
+ tool = ApplyPatchTool()
603
+ content = tool._parse_create_diff("+line 1\n+line 2\n+line 3")
604
+ assert content == "line 1\nline 2\nline 3"
605
+
606
+ def test_parse_create_diff_with_spaces(self):
607
+ """Test _parse_create_diff with space-prefixed lines."""
608
+ tool = ApplyPatchTool()
609
+ content = tool._parse_create_diff("+line 1\n context\n+line 3")
610
+ assert content == "line 1\ncontext\nline 3"
611
+
612
+ def test_open_file_not_found(self):
613
+ """Test _open_file with non-existent file."""
614
+ with tempfile.TemporaryDirectory() as tmpdir:
615
+ tool = ApplyPatchTool(base_path=tmpdir)
616
+ with pytest.raises(DiffError, match="File not found"):
617
+ tool._open_file("nonexistent.txt")
618
+
619
+ def test_write_file(self):
620
+ """Test _write_file method."""
621
+ with tempfile.TemporaryDirectory() as tmpdir:
622
+ tool = ApplyPatchTool(base_path=tmpdir)
623
+ tool._write_file("test.txt", "content")
624
+
625
+ with open(os.path.join(tmpdir, "test.txt")) as f:
626
+ assert f.read() == "content"
627
+
628
+ def test_remove_file(self):
629
+ """Test _remove_file method."""
630
+ with tempfile.TemporaryDirectory() as tmpdir:
631
+ # Create file
632
+ file_path = os.path.join(tmpdir, "test.txt")
633
+ Path(file_path).write_text("content")
634
+
635
+ tool = ApplyPatchTool(base_path=tmpdir)
636
+ tool._remove_file("test.txt")
637
+
638
+ assert not os.path.exists(file_path)
639
+
640
+ def test_load_files(self):
641
+ """Test _load_files method."""
642
+ with tempfile.TemporaryDirectory() as tmpdir:
643
+ # Create files
644
+ Path(os.path.join(tmpdir, "file1.txt")).write_text("content1")
645
+ Path(os.path.join(tmpdir, "file2.txt")).write_text("content2")
646
+
647
+ tool = ApplyPatchTool(base_path=tmpdir)
648
+ files = tool._load_files(["file1.txt", "file2.txt"])
649
+
650
+ assert files == {"file1.txt": "content1", "file2.txt": "content2"}
651
+
652
+ @pytest.mark.asyncio
653
+ async def test_update_with_fuzz(self):
654
+ """Test update that requires fuzzy matching."""
655
+ with tempfile.TemporaryDirectory() as tmpdir:
656
+ # Create file with trailing whitespace
657
+ file_path = os.path.join(tmpdir, "test.txt")
658
+ Path(file_path).write_text("line1 \nline2\nline3")
659
+
660
+ tool = ApplyPatchTool(base_path=tmpdir)
661
+ result = await tool(
662
+ type="update_file",
663
+ path="test.txt",
664
+ diff=" line1\n-line2\n+new line2\n line3",
665
+ )
666
+ assert result.status == "completed"
667
+ # Fuzz > 0 should be reported
668
+ assert "Updated" in result.output
669
+
670
+
671
+ class TestDataclasses:
672
+ """Tests for dataclass structures."""
673
+
674
+ def test_file_change(self):
675
+ """Test FileChange dataclass."""
676
+ change = FileChange(
677
+ type=ActionType.UPDATE,
678
+ old_content="old",
679
+ new_content="new",
680
+ move_path="moved.txt",
681
+ )
682
+ assert change.type == ActionType.UPDATE
683
+ assert change.old_content == "old"
684
+ assert change.new_content == "new"
685
+ assert change.move_path == "moved.txt"
686
+
687
+ def test_commit(self):
688
+ """Test Commit dataclass."""
689
+ commit = Commit()
690
+ assert commit.changes == {}
691
+ commit.changes["test.txt"] = FileChange(type=ActionType.ADD, new_content="content")
692
+ assert "test.txt" in commit.changes
693
+
694
+ def test_chunk(self):
695
+ """Test Chunk dataclass."""
696
+ chunk = Chunk(orig_index=5, del_lines=["old"], ins_lines=["new"])
697
+ assert chunk.orig_index == 5
698
+ assert chunk.del_lines == ["old"]
699
+ assert chunk.ins_lines == ["new"]
700
+
701
+ def test_patch_action(self):
702
+ """Test PatchAction dataclass."""
703
+ action = PatchAction(type=ActionType.ADD, new_file="content")
704
+ assert action.type == ActionType.ADD
705
+ assert action.new_file == "content"
706
+ assert action.chunks == []
707
+ assert action.move_path is None
708
+
709
+ def test_patch(self):
710
+ """Test Patch dataclass."""
711
+ patch = Patch()
712
+ assert patch.actions == {}
713
+
714
+ def test_action_type_enum(self):
715
+ """Test ActionType enum values."""
716
+ assert ActionType.ADD.value == "add"
717
+ assert ActionType.DELETE.value == "delete"
718
+ assert ActionType.UPDATE.value == "update"