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.
Files changed (54) hide show
  1. patchllm/__main__.py +0 -0
  2. patchllm/agent/__init__.py +0 -0
  3. patchllm/agent/actions.py +73 -0
  4. patchllm/agent/executor.py +57 -0
  5. patchllm/agent/planner.py +76 -0
  6. patchllm/agent/session.py +425 -0
  7. patchllm/cli/__init__.py +0 -0
  8. patchllm/cli/entrypoint.py +120 -0
  9. patchllm/cli/handlers.py +192 -0
  10. patchllm/cli/helpers.py +72 -0
  11. patchllm/interactive/__init__.py +0 -0
  12. patchllm/interactive/selector.py +100 -0
  13. patchllm/llm.py +39 -0
  14. patchllm/main.py +1 -283
  15. patchllm/parser.py +120 -64
  16. patchllm/patcher.py +118 -0
  17. patchllm/scopes/__init__.py +0 -0
  18. patchllm/scopes/builder.py +55 -0
  19. patchllm/scopes/constants.py +70 -0
  20. patchllm/scopes/helpers.py +147 -0
  21. patchllm/scopes/resolvers.py +82 -0
  22. patchllm/scopes/structure.py +64 -0
  23. patchllm/tui/__init__.py +0 -0
  24. patchllm/tui/completer.py +153 -0
  25. patchllm/tui/interface.py +703 -0
  26. patchllm/utils.py +19 -1
  27. patchllm/voice/__init__.py +0 -0
  28. patchllm/{listener.py → voice/listener.py} +8 -1
  29. patchllm-1.0.0.dist-info/METADATA +153 -0
  30. patchllm-1.0.0.dist-info/RECORD +51 -0
  31. patchllm-1.0.0.dist-info/entry_points.txt +2 -0
  32. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
  33. tests/__init__.py +0 -0
  34. tests/conftest.py +112 -0
  35. tests/test_actions.py +62 -0
  36. tests/test_agent.py +383 -0
  37. tests/test_completer.py +121 -0
  38. tests/test_context.py +140 -0
  39. tests/test_executor.py +60 -0
  40. tests/test_interactive.py +64 -0
  41. tests/test_parser.py +70 -0
  42. tests/test_patcher.py +71 -0
  43. tests/test_planner.py +53 -0
  44. tests/test_recipes.py +111 -0
  45. tests/test_scopes.py +47 -0
  46. tests/test_structure.py +48 -0
  47. tests/test_tui.py +397 -0
  48. tests/test_utils.py +31 -0
  49. patchllm/context.py +0 -238
  50. patchllm-0.2.1.dist-info/METADATA +0 -127
  51. patchllm-0.2.1.dist-info/RECORD +0 -12
  52. patchllm-0.2.1.dist-info/entry_points.txt +0 -2
  53. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
  54. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/licenses/LICENSE +0 -0
tests/test_executor.py ADDED
@@ -0,0 +1,60 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from patchllm.agent.executor import execute_step
4
+
5
+ @patch('patchllm.agent.executor.run_llm_query')
6
+ def test_execute_step_with_text_only(mock_run_llm_query):
7
+ """Tests that execute_step sends a simple text prompt when no images are present."""
8
+ mock_run_llm_query.return_value = (
9
+ "<change_summary>Summary of changes.</change_summary>\n"
10
+ "<file_path:/a.txt>\n```\n...```"
11
+ )
12
+
13
+ result = execute_step("test instruction", [], "test context", None, "gemini/gemini-1.5-flash")
14
+
15
+ mock_run_llm_query.assert_called_once()
16
+ messages = mock_run_llm_query.call_args[0][0]
17
+ user_message = messages[-1]
18
+
19
+ assert user_message["role"] == "user"
20
+ assert isinstance(user_message["content"], list)
21
+ assert len(user_message["content"]) == 1
22
+ assert user_message["content"][0]["type"] == "text"
23
+ assert "test instruction" in user_message["content"][0]["text"]
24
+ assert "test context" in user_message["content"][0]["text"]
25
+
26
+ assert result is not None
27
+ assert result["change_summary"] == "Summary of changes."
28
+
29
+ @patch('patchllm.agent.executor.run_llm_query')
30
+ def test_execute_step_with_images(mock_run_llm_query):
31
+ """Tests that execute_step constructs a multimodal prompt when images are present."""
32
+ mock_run_llm_query.return_value = (
33
+ "<change_summary>Image-related changes.</change_summary>\n"
34
+ "<file_path:/a.txt>\n```\n...```"
35
+ )
36
+
37
+ mock_image_data = [{
38
+ "mime_type": "image/png",
39
+ "content_base64": "base64string"
40
+ }]
41
+
42
+ result = execute_step("test instruction", [], "test context", mock_image_data, "gemini/gemini-1.5-flash")
43
+
44
+ mock_run_llm_query.assert_called_once()
45
+ messages = mock_run_llm_query.call_args[0][0]
46
+ user_message = messages[-1]
47
+
48
+ assert user_message["role"] == "user"
49
+ assert isinstance(user_message["content"], list)
50
+ assert len(user_message["content"]) == 2 # 1 for text, 1 for image
51
+
52
+ text_part = user_message["content"][0]
53
+ assert text_part["type"] == "text"
54
+
55
+ image_part = user_message["content"][1]
56
+ assert image_part["type"] == "image_url"
57
+ assert image_part["image_url"]["url"] == "data:image/png;base64,base64string"
58
+
59
+ assert result is not None
60
+ assert result["change_summary"] == "Image-related changes."
@@ -0,0 +1,64 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from pathlib import Path
4
+ import os
5
+ import re
6
+
7
+ pytest.importorskip("InquirerPy")
8
+
9
+ from patchllm.cli.entrypoint import main
10
+ from patchllm.interactive.selector import _build_choices_recursively
11
+
12
+ def test_build_choices_recursively(temp_project):
13
+ choices = _build_choices_recursively(temp_project, temp_project)
14
+ path_extraction_pattern = re.compile(r"([📁📄]\s.*)")
15
+ plain_choices = set()
16
+ for choice in choices:
17
+ match = path_extraction_pattern.search(choice)
18
+ if match:
19
+ plain_choices.add(match.group(1).strip())
20
+
21
+ assert "📁 src/" in plain_choices
22
+ assert "📄 main.py" in plain_choices
23
+ assert "📄 src/component.js" in plain_choices
24
+ assert not any("data.log" in choice for choice in plain_choices)
25
+
26
+ @patch('patchllm.interactive.selector.prompt', new_callable=MagicMock)
27
+ def test_interactive_flag_flow_files_with_fuzzy(mock_prompt, temp_project):
28
+ selected_items = ['├── 📄 main.py', '│ └── 📄 src/styles.css']
29
+ mock_prompt.return_value = {"selected_items": selected_items}
30
+
31
+ output_file = temp_project / "context_output.md"
32
+ original_cwd = os.getcwd()
33
+ os.chdir(temp_project)
34
+ try:
35
+ with patch('sys.argv', ['patchllm', '--interactive', '--context-out', str(output_file)]):
36
+ main()
37
+ finally:
38
+ os.chdir(original_cwd)
39
+
40
+ assert output_file.exists()
41
+ content = output_file.read_text()
42
+ assert "<file_path:" + (temp_project / 'main.py').as_posix() in content
43
+ assert "<file_path:" + (temp_project / 'src/styles.css').as_posix() in content
44
+ assert "utils.py" not in content
45
+
46
+ @patch('patchllm.interactive.selector.prompt', new_callable=MagicMock)
47
+ def test_interactive_flag_flow_folder_with_fuzzy(mock_prompt, temp_project):
48
+ selected_items = ['└── 📁 src/']
49
+ mock_prompt.return_value = {"selected_items": selected_items}
50
+
51
+ output_file = temp_project / "context_output.md"
52
+ original_cwd = os.getcwd()
53
+ os.chdir(temp_project)
54
+ try:
55
+ with patch('sys.argv', ['patchllm', '--interactive', '--context-out', str(output_file)]):
56
+ main()
57
+ finally:
58
+ os.chdir(original_cwd)
59
+
60
+ assert output_file.exists()
61
+ content = output_file.read_text()
62
+ assert "<file_path:" + (temp_project / 'src/component.js').as_posix() in content
63
+ assert "<file_path:" + (temp_project / 'src/styles.css').as_posix() in content
64
+ assert "main.py" not in content
tests/test_parser.py ADDED
@@ -0,0 +1,70 @@
1
+ from pathlib import Path
2
+ from patchllm.parser import paste_response, summarize_changes, _parse_file_blocks, parse_change_summary
3
+
4
+ def test_parse_file_blocks_simple():
5
+ response = "<file_path:/app/main.py>\n```python\nprint('hello')\n```"
6
+ result = _parse_file_blocks(response)
7
+ assert len(result) == 1
8
+ path, content = result[0]
9
+ assert path == Path("/app/main.py").resolve()
10
+ assert content == "print('hello')"
11
+
12
+ def test_parse_change_summary():
13
+ """Tests that the change summary is correctly extracted."""
14
+ response_with_summary = (
15
+ "<change_summary>\nThis is the summary.\n</change_summary>\n"
16
+ "<file_path:/app/main.py>\n```python\nprint('hello')\n```"
17
+ )
18
+ summary = parse_change_summary(response_with_summary)
19
+ assert summary == "This is the summary."
20
+
21
+ response_no_summary = "<file_path:/app/main.py>\n```python\nprint('hello')\n```"
22
+ summary_none = parse_change_summary(response_no_summary)
23
+ assert summary_none is None
24
+
25
+ def test_paste_response_updates_file(tmp_path):
26
+ file_path = tmp_path / "test.txt"
27
+ file_path.write_text("old content")
28
+ response = f"<file_path:{file_path.as_posix()}>\n```\nnew content\n```"
29
+ paste_response(response)
30
+ assert file_path.read_text() == "new content"
31
+
32
+ def test_paste_response_skip_unchanged_file(tmp_path, capsys):
33
+ file_path = tmp_path / "unchanged.txt"
34
+ content = "no changes here"
35
+ file_path.write_text(content)
36
+ response = f"<file_path:{file_path.as_posix()}>\n```\n{content}\n```"
37
+ paste_response(response)
38
+ captured = capsys.readouterr()
39
+ # --- CORRECTION: The function now just updates, it doesn't "skip" ---
40
+ assert "Updated" in captured.out
41
+ assert file_path.read_text() == content
42
+
43
+ def test_paste_response_create_in_new_directory(tmp_path):
44
+ new_dir = tmp_path / "new_dir"
45
+ new_file = new_dir / "file.txt"
46
+ content = "some text"
47
+ assert not new_dir.exists()
48
+ response = f"<file_path:{new_file.as_posix()}>\n```\n{content}\n```"
49
+ paste_response(response)
50
+ assert new_dir.is_dir()
51
+ assert new_file.read_text() == content
52
+
53
+ def test_summarize_changes(tmp_path):
54
+ created_file = tmp_path / "new.txt"
55
+ modified_file = tmp_path / "old.txt"
56
+ modified_file.touch()
57
+ response = (
58
+ f"<file_path:{created_file.as_posix()}>\n```\ncontent\n```\n"
59
+ f"<file_path:{modified_file.as_posix()}>\n```\ncontent\n```"
60
+ )
61
+ summary = summarize_changes(response)
62
+ assert created_file.as_posix() in summary["created"]
63
+ assert modified_file.as_posix() in summary["modified"]
64
+
65
+ def test_paste_response_no_matches(capsys):
66
+ response = "No valid file blocks."
67
+ paste_response(response)
68
+ captured = capsys.readouterr()
69
+ # --- CORRECTION: Update assertion to match new warning message ---
70
+ assert "Could not find any file blocks to apply" in captured.out
tests/test_patcher.py ADDED
@@ -0,0 +1,71 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from pathlib import Path
4
+ import subprocess
5
+ import textwrap
6
+ import shutil
7
+
8
+ from patchllm.patcher import apply_external_patch
9
+
10
+ patch_installed = shutil.which("patch") is not None
11
+
12
+ @pytest.fixture
13
+ def temp_project_for_patching(tmp_path):
14
+ proj = tmp_path / "patch_proj"
15
+ proj.mkdir()
16
+ (proj / "main.py").write_text("def hello():\n print('old world')")
17
+ (proj / "utils.py").write_text("# Initial utility file")
18
+
19
+ subprocess.run(["git", "init"], cwd=proj, check=True, capture_output=True)
20
+ subprocess.run(["git", "add", "."], cwd=proj, check=True, capture_output=True)
21
+ subprocess.run(["git", "commit", "-m", "initial"], cwd=proj, check=True, capture_output=True)
22
+ return proj
23
+
24
+ @pytest.mark.skipif(not patch_installed, reason="The 'patch' command-line utility is not installed.")
25
+ def test_apply_patch_with_diff_format(temp_project_for_patching):
26
+ diff_content = """--- a/main.py
27
+ +++ b/main.py
28
+ @@ -1,2 +1,2 @@
29
+ def hello():
30
+ - print('old world')
31
+ + print('new world')
32
+ """
33
+
34
+ main_py = temp_project_for_patching / "main.py"
35
+ original_content = main_py.read_text()
36
+
37
+ apply_external_patch(diff_content, temp_project_for_patching)
38
+
39
+ new_content = main_py.read_text()
40
+ assert original_content != new_content
41
+ assert "print('new world')" in new_content
42
+
43
+ def test_apply_patch_with_patchllm_format(temp_project_for_patching):
44
+ main_py = temp_project_for_patching / "main.py"
45
+ patchllm_content = f"<file_path:{main_py.as_posix()}>\n```python\ndef hello():\n print('patched world')\n```"
46
+
47
+ apply_external_patch(patchllm_content, temp_project_for_patching)
48
+
49
+ assert "print('patched world')" in main_py.read_text()
50
+
51
+ @patch('patchllm.patcher.prompt')
52
+ def test_apply_patch_interactive_flow(mock_prompt, temp_project_for_patching):
53
+ mock_prompt.side_effect = [
54
+ {"file": "utils.py"},
55
+ {"confirm": True}
56
+ ]
57
+
58
+ ambiguous_content = textwrap.dedent("""
59
+ Sure, here is the code you requested for the utility file:
60
+ ```python
61
+ def new_utility_function():
62
+ return True
63
+ ```
64
+ I hope this helps!
65
+ """)
66
+ utils_py = temp_project_for_patching / "utils.py"
67
+
68
+ apply_external_patch(ambiguous_content, temp_project_for_patching)
69
+
70
+ assert "def new_utility_function():" in utils_py.read_text()
71
+ assert mock_prompt.call_count == 2
tests/test_planner.py ADDED
@@ -0,0 +1,53 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from patchllm.agent.planner import generate_plan_and_history, generate_refined_plan
4
+
5
+ @patch('patchllm.agent.planner.run_llm_query')
6
+ def test_generate_plan_and_history(mock_run_llm_query):
7
+ """
8
+ Tests that the planner returns the initial history and response correctly.
9
+ """
10
+ mock_llm_response = "1. First, modify the main.py file."
11
+ mock_run_llm_query.return_value = mock_llm_response
12
+
13
+ history, response = generate_plan_and_history("some goal", "some tree", "mock-model")
14
+
15
+ assert response == mock_llm_response
16
+ assert len(history) == 3 # System, User, Assistant
17
+ assert history[2]['role'] == 'assistant'
18
+ assert "## Goal:\nsome goal" in history[1]['content']
19
+ assert "## Project Structure:\n```\nsome tree\n```" in history[1]['content']
20
+
21
+ @patch('patchllm.agent.planner.run_llm_query')
22
+ def test_generate_refined_plan(mock_run_llm_query):
23
+ """
24
+ Tests that the refine function correctly constructs a prompt with history and feedback.
25
+ """
26
+ initial_history = [
27
+ {"role": "system", "content": "You are a planner."},
28
+ {"role": "user", "content": "My goal is X."},
29
+ {"role": "assistant", "content": "1. Do step 1."}
30
+ ]
31
+ feedback = "Actually, let's do step 2 first."
32
+
33
+ mock_run_llm_query.return_value = "1. Do step 2.\n2. Do step 1."
34
+
35
+ generate_refined_plan(initial_history, feedback, "mock-model")
36
+
37
+ mock_run_llm_query.assert_called_once()
38
+ sent_messages = mock_run_llm_query.call_args[0][0]
39
+
40
+ # The sent messages should be the full history plus the new user feedback/instruction
41
+ assert len(sent_messages) == 4
42
+ assert sent_messages[0]['role'] == 'system'
43
+ assert sent_messages[2]['content'] == "1. Do step 1."
44
+ assert "## User Feedback:\nActually, let's do step 2 first." in sent_messages[3]['content']
45
+
46
+ @patch('patchllm.agent.planner.run_llm_query')
47
+ def test_generate_plan_handles_no_response(mock_run_llm_query):
48
+ """
49
+ Tests that the planner returns None if the LLM gives an empty response.
50
+ """
51
+ mock_run_llm_query.return_value = None
52
+ _, response = generate_plan_and_history("goal", "tree", "model")
53
+ assert response is None
tests/test_recipes.py ADDED
@@ -0,0 +1,111 @@
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
+
27
+ return project_dir
28
+
29
+ @pytest.fixture
30
+ def git_project(temp_project):
31
+ """Initializes the temp_project as a Git repository."""
32
+ subprocess.run(["git", "init"], cwd=temp_project, check=True, capture_output=True)
33
+ subprocess.run(["git", "config", "user.name", "Test User"], cwd=temp_project, check=True)
34
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=temp_project, check=True)
35
+ subprocess.run(["git", "add", "."], cwd=temp_project, check=True)
36
+ subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=temp_project, check=True, capture_output=True)
37
+ return temp_project
38
+
39
+ @pytest.fixture
40
+ def temp_scopes_file(tmp_path):
41
+ """Creates a temporary scopes.py file for testing."""
42
+ scopes_content = """
43
+ scopes = {
44
+ 'base': {
45
+ 'path': '.',
46
+ 'include_patterns': ['**/*.py'],
47
+ 'exclude_patterns': ['tests/**'],
48
+ },
49
+ 'search_scope': {
50
+ 'path': '.',
51
+ 'include_patterns': ['**/*'],
52
+ 'search_words': ['hello']
53
+ },
54
+ 'js_and_css': {
55
+ 'path': 'src',
56
+ 'include_patterns': ['**/*.js', '**/*.css']
57
+ }
58
+ }
59
+ """
60
+ scopes_file = tmp_path / "scopes.py"
61
+ scopes_file.write_text(scopes_content)
62
+ return scopes_file
63
+
64
+ @pytest.fixture
65
+ def temp_recipes_file(tmp_path):
66
+ """Creates a temporary recipes.py file for testing."""
67
+ recipes_content = """
68
+ recipes = {
69
+ "add_tests": "Please write comprehensive pytest unit tests for the functions in the provided file.",
70
+ "add_docs": "Generate Google-style docstrings for all public functions and classes.",
71
+ }
72
+ """
73
+ recipes_file = tmp_path / "recipes.py"
74
+ recipes_file.write_text(recipes_content)
75
+ return recipes_file
76
+
77
+ @pytest.fixture
78
+ def mixed_project(tmp_path):
79
+ """Creates a project with both Python and JS files for structure testing."""
80
+ proj_dir = tmp_path / "mixed_project"
81
+
82
+ py_api_dir = proj_dir / "api"
83
+ py_api_dir.mkdir(parents=True)
84
+ (py_api_dir / "main.py").write_text(textwrap.dedent("""
85
+ import os
86
+ from .models import User
87
+ class APIServer:
88
+ def start(self): pass
89
+ async def get_user(id: int) -> User:
90
+ # A comment
91
+ return User()
92
+ """))
93
+ (py_api_dir / "models.py").write_text(textwrap.dedent("""
94
+ from db import Base
95
+ class User(Base): pass
96
+ """))
97
+
98
+ js_src_dir = proj_dir / "frontend" / "src"
99
+ js_src_dir.mkdir(parents=True)
100
+ (js_src_dir / "index.js").write_text(textwrap.dedent("""
101
+ import React from "react";
102
+ export class App extends React.Component { render() { return <h1>Hello</h1>; } }
103
+ export const arrowFunc = () => { console.log('test'); }
104
+ """))
105
+ (js_src_dir / "utils.ts").write_text(textwrap.dedent("""
106
+ export async function fetchData(url: string): Promise<any> { }
107
+ """))
108
+
109
+ (proj_dir / "README.md").write_text("# Mixed Project")
110
+
111
+ return proj_dir
tests/test_scopes.py ADDED
@@ -0,0 +1,47 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from patchllm.cli.entrypoint import main
4
+ from patchllm.utils import load_from_py_file, write_scopes_to_file
5
+
6
+ def run_main_with_args(args, expect_exit=False):
7
+ with patch('sys.argv', ['patchllm'] + args):
8
+ if expect_exit:
9
+ with pytest.raises(SystemExit):
10
+ main()
11
+ else:
12
+ main()
13
+
14
+ def test_add_scope(tmp_path):
15
+ scopes_file = tmp_path / "scopes.py"
16
+ write_scopes_to_file(scopes_file, {})
17
+ with patch.dict('os.environ', {'PATCHLLM_SCOPES_FILE': scopes_file.as_posix()}):
18
+ run_main_with_args(["--add-scope", "new_scope"])
19
+ scopes = load_from_py_file(scopes_file, "scopes")
20
+ assert "new_scope" in scopes
21
+ assert scopes["new_scope"]["path"] == "."
22
+
23
+ def test_remove_scope(temp_scopes_file):
24
+ scopes_before = load_from_py_file(temp_scopes_file, "scopes")
25
+ assert "base" in scopes_before
26
+ with patch.dict('os.environ', {'PATCHLLM_SCOPES_FILE': temp_scopes_file.as_posix()}):
27
+ run_main_with_args(["--remove-scope", "base"])
28
+ scopes_after = load_from_py_file(temp_scopes_file, "scopes")
29
+ assert "base" not in scopes_after
30
+
31
+ def test_update_scope(temp_scopes_file):
32
+ with patch.dict('os.environ', {'PATCHLLM_SCOPES_FILE': temp_scopes_file.as_posix()}):
33
+ run_main_with_args(["--update-scope", "base", "path='/new/path'"])
34
+ scopes = load_from_py_file(temp_scopes_file, "scopes")
35
+ assert scopes["base"]["path"] == "/new/path"
36
+
37
+ def test_update_scope_add_new_key(temp_scopes_file):
38
+ with patch.dict('os.environ', {'PATCHLLM_SCOPES_FILE': temp_scopes_file.as_posix()}):
39
+ run_main_with_args(["--update-scope", "base", "new_key=True"])
40
+ scopes = load_from_py_file(temp_scopes_file, "scopes")
41
+ assert scopes["base"]["new_key"] is True
42
+
43
+ def test_update_scope_invalid_value(temp_scopes_file, capsys):
44
+ with patch.dict('os.environ', {'PATCHLLM_SCOPES_FILE': temp_scopes_file.as_posix()}):
45
+ run_main_with_args(["--update-scope", "base", "path=unquoted"])
46
+ captured = capsys.readouterr()
47
+ assert "Error parsing update values" in captured.out
@@ -0,0 +1,48 @@
1
+ import os
2
+ from patchllm.scopes.builder import build_context
3
+ from patchllm.scopes.structure import _extract_symbols_by_regex
4
+ from patchllm.scopes.constants import LANGUAGE_PATTERNS
5
+
6
+ def test_extract_python_symbols():
7
+ content = """
8
+ import os
9
+ class MyClass(Parent):
10
+ def method_one(self, arg1): pass
11
+ async def top_level_async_func(): pass
12
+ """
13
+ patterns = LANGUAGE_PATTERNS['python']['patterns']
14
+ symbols = _extract_symbols_by_regex(content, patterns)
15
+ assert "import os" in symbols["imports"]
16
+ assert "class MyClass(Parent):" in symbols["class"]
17
+ assert "def method_one(self, arg1):" in symbols["function"]
18
+ assert "async def top_level_async_func():" in symbols["function"]
19
+
20
+ def test_extract_javascript_symbols():
21
+ content = """
22
+ import React from 'react';
23
+ export class MyComponent extends React.Component {}
24
+ function helper() {}
25
+ export const arrowFunc = (arg1) => {}
26
+ export async function getData() {}
27
+ """
28
+ patterns = LANGUAGE_PATTERNS['javascript']['patterns']
29
+ symbols = _extract_symbols_by_regex(content, patterns)
30
+ assert "import React from 'react';" in symbols["imports"]
31
+ assert "export class MyComponent extends React.Component {}" in symbols["class"]
32
+ assert "function helper() {}" in symbols["function"]
33
+ assert "export const arrowFunc = (arg1) => {}" in symbols["function"]
34
+ assert "export async function getData() {}" in symbols["function"]
35
+
36
+ def test_build_structure_context(mixed_project):
37
+ os.chdir(mixed_project)
38
+ result = build_context("@structure", {}, mixed_project)
39
+ assert result is not None
40
+ context = result["context"]
41
+ assert "<file_path:api/main.py>" in context
42
+ assert "<file_path:frontend/src/index.js>" in context
43
+ assert "README.md" not in context
44
+ assert "class APIServer:" in context
45
+ assert "async def get_user(id: int) -> User:" in context
46
+ assert "export class App extends React.Component {" in context
47
+ assert "export const arrowFunc = () => {" in context
48
+ assert "Project Structure Outline:" in context