patchpal 0.4.5__py3-none-any.whl → 0.7.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.
@@ -0,0 +1,288 @@
1
+ """Utility to automatically convert Python functions to LiteLLM tool schemas.
2
+
3
+ Also provides custom tools discovery system for loading user-defined tools
4
+ from ~/.patchpal/tools/
5
+ """
6
+
7
+ import inspect
8
+ import sys
9
+ from importlib import util
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional, Union, get_args, get_origin, get_type_hints
12
+
13
+
14
+ def python_type_to_json_schema(py_type: Any) -> Dict[str, Any]:
15
+ """Convert Python type hint to JSON schema type.
16
+
17
+ Args:
18
+ py_type: Python type hint
19
+
20
+ Returns:
21
+ JSON schema type dict
22
+ """
23
+ if py_type is type(None):
24
+ return {"type": "null"}
25
+
26
+ origin = get_origin(py_type)
27
+
28
+ # Handle Optional/Union types
29
+ if origin is Union:
30
+ args = get_args(py_type)
31
+ non_none = [a for a in args if a is not type(None)]
32
+ if non_none:
33
+ return python_type_to_json_schema(non_none[0])
34
+
35
+ # Handle List
36
+ if origin is list:
37
+ args = get_args(py_type)
38
+ if args:
39
+ return {"type": "array", "items": python_type_to_json_schema(args[0])}
40
+ return {"type": "array"}
41
+
42
+ # Handle Dict
43
+ if origin is dict:
44
+ return {"type": "object"}
45
+
46
+ # Basic types
47
+ type_map = {
48
+ str: {"type": "string"},
49
+ int: {"type": "integer"},
50
+ float: {"type": "number"},
51
+ bool: {"type": "boolean"},
52
+ list: {"type": "array"},
53
+ dict: {"type": "object"},
54
+ }
55
+
56
+ return type_map.get(py_type, {"type": "string"})
57
+
58
+
59
+ def parse_docstring_params(docstring: str) -> Dict[str, str]:
60
+ """Parse parameter descriptions from Google-style docstring.
61
+
62
+ Args:
63
+ docstring: Function docstring
64
+
65
+ Returns:
66
+ Dict mapping parameter names to descriptions
67
+ """
68
+ if not docstring:
69
+ return {}
70
+
71
+ params = {}
72
+ lines = docstring.split("\n")
73
+ in_args = False
74
+
75
+ for i, line in enumerate(lines):
76
+ stripped = line.strip()
77
+
78
+ if stripped.lower() in ("args:", "arguments:", "parameters:"):
79
+ in_args = True
80
+ continue
81
+
82
+ if in_args:
83
+ # Check if we left the Args section
84
+ if stripped and not line.startswith((" ", "\t")) and ":" in stripped:
85
+ break
86
+
87
+ # Parse "param_name: description"
88
+ if ":" in stripped:
89
+ parts = stripped.split(":", 1)
90
+ param_name = parts[0].strip()
91
+ description = parts[1].strip()
92
+
93
+ # Collect continuation lines
94
+ for j in range(i + 1, len(lines)):
95
+ next_line = lines[j].strip()
96
+ if not next_line or ":" in next_line:
97
+ break
98
+ description += " " + next_line
99
+
100
+ params[param_name] = description
101
+
102
+ return params
103
+
104
+
105
+ def function_to_tool_schema(func: Callable) -> Dict[str, Any]:
106
+ """Convert a Python function to LiteLLM tool schema.
107
+
108
+ Extracts schema from function signature and docstring.
109
+
110
+ Args:
111
+ func: Python function with type hints and docstring
112
+
113
+ Returns:
114
+ LiteLLM tool schema dict
115
+ """
116
+ sig = inspect.signature(func)
117
+ docstring = inspect.getdoc(func) or ""
118
+
119
+ # Extract description (first paragraph)
120
+ description = (
121
+ docstring.split("\n\n")[0].replace("\n", " ").strip() or f"Execute {func.__name__}"
122
+ )
123
+
124
+ # Parse parameter descriptions
125
+ param_descriptions = parse_docstring_params(docstring)
126
+
127
+ # Get type hints
128
+ try:
129
+ type_hints = get_type_hints(func)
130
+ except Exception:
131
+ type_hints = {}
132
+
133
+ # Build parameters
134
+ properties = {}
135
+ required = []
136
+
137
+ for param_name, param in sig.parameters.items():
138
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
139
+ continue
140
+
141
+ param_type = type_hints.get(param_name, str)
142
+ param_schema = python_type_to_json_schema(param_type)
143
+ param_schema["description"] = param_descriptions.get(param_name, f"Parameter {param_name}")
144
+
145
+ properties[param_name] = param_schema
146
+
147
+ if param.default is inspect.Parameter.empty:
148
+ required.append(param_name)
149
+
150
+ return {
151
+ "type": "function",
152
+ "function": {
153
+ "name": func.__name__,
154
+ "description": description,
155
+ "parameters": {
156
+ "type": "object",
157
+ "properties": properties,
158
+ "required": required,
159
+ },
160
+ },
161
+ }
162
+
163
+
164
+ def _is_valid_tool_function(func: Callable) -> bool:
165
+ """Check if a function is valid for use as a tool.
166
+
167
+ Args:
168
+ func: Function to validate
169
+
170
+ Returns:
171
+ True if function can be used as a tool
172
+ """
173
+ # Must have a docstring
174
+ if not func.__doc__:
175
+ return False
176
+
177
+ # Must have type hints
178
+ try:
179
+ sig = inspect.signature(func)
180
+ for param_name, param in sig.parameters.items():
181
+ # Skip *args, **kwargs
182
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
183
+ continue
184
+ # Check if parameter has annotation
185
+ if param.annotation is inspect.Parameter.empty:
186
+ return False
187
+ except Exception:
188
+ return False
189
+
190
+ return True
191
+
192
+
193
+ def discover_tools(tools_dir: Optional[Path] = None) -> List[Callable]:
194
+ """Discover custom tool functions from Python files.
195
+
196
+ Loads all .py files from the tools directory and extracts functions
197
+ that have proper type hints and docstrings.
198
+
199
+ Tool functions must:
200
+ - Have type hints for all parameters
201
+ - Have a docstring with description and Args section
202
+ - Be defined at module level (not nested)
203
+ - Not start with underscore (private functions ignored)
204
+
205
+ Args:
206
+ tools_dir: Directory to search for tool files (default: ~/.patchpal/tools/)
207
+
208
+ Returns:
209
+ List of callable tool functions
210
+ """
211
+ if tools_dir is None:
212
+ tools_dir = Path.home() / ".patchpal" / "tools"
213
+
214
+ if not tools_dir.exists():
215
+ return []
216
+
217
+ tools = []
218
+ loaded_modules = []
219
+
220
+ # Discover all .py files
221
+ for tool_file in sorted(tools_dir.glob("*.py")):
222
+ try:
223
+ # Create a unique module name to avoid conflicts
224
+ module_name = f"patchpal_custom_tools.{tool_file.stem}"
225
+
226
+ # Load the module
227
+ spec = util.spec_from_file_location(module_name, tool_file)
228
+ if spec and spec.loader:
229
+ module = util.module_from_spec(spec)
230
+
231
+ # Store reference to prevent garbage collection
232
+ sys.modules[module_name] = module
233
+ loaded_modules.append(module)
234
+
235
+ # Execute the module
236
+ spec.loader.exec_module(module)
237
+
238
+ # Extract valid tool functions
239
+ for name, obj in inspect.getmembers(module, inspect.isfunction):
240
+ # Skip private functions
241
+ if name.startswith("_"):
242
+ continue
243
+
244
+ # Skip functions from imports (only module-level definitions)
245
+ if obj.__module__ != module_name:
246
+ continue
247
+
248
+ # Validate tool function
249
+ if _is_valid_tool_function(obj):
250
+ tools.append(obj)
251
+
252
+ except Exception as e:
253
+ # Print warning but continue with other tools
254
+ print(
255
+ f"\033[1;33m⚠️ Warning: Failed to load custom tool from {tool_file.name}: {e}\033[0m"
256
+ )
257
+ continue
258
+
259
+ return tools
260
+
261
+
262
+ def list_custom_tools(tools_dir: Optional[Path] = None) -> List[tuple[str, str, Path]]:
263
+ """List all custom tools with their descriptions.
264
+
265
+ Args:
266
+ tools_dir: Directory to search for tool files (default: ~/.patchpal/tools/)
267
+
268
+ Returns:
269
+ List of (tool_name, description, file_path) tuples
270
+ """
271
+ tools = discover_tools(tools_dir)
272
+
273
+ result = []
274
+ for tool in tools:
275
+ # Extract description from docstring (first line)
276
+ description = ""
277
+ if tool.__doc__:
278
+ description = tool.__doc__.split("\n")[0].strip()
279
+
280
+ # Get source file
281
+ try:
282
+ source_file = Path(inspect.getfile(tool))
283
+ except Exception:
284
+ source_file = Path("unknown")
285
+
286
+ result.append((tool.__name__, description, source_file))
287
+
288
+ return result
patchpal/tools.py CHANGED
@@ -80,7 +80,10 @@ CRITICAL_FILES = {
80
80
  }
81
81
 
82
82
  # Configuration
83
- MAX_FILE_SIZE = int(os.getenv("PATCHPAL_MAX_FILE_SIZE", 10 * 1024 * 1024)) # 10MB default
83
+ # Reduced from 10MB to 500KB to prevent context window explosions
84
+ # A 3.46MB file = ~1.15M tokens which exceeds most model context limits (128K-200K)
85
+ # 500KB ≈ 166K tokens which is safe for most models
86
+ MAX_FILE_SIZE = int(os.getenv("PATCHPAL_MAX_FILE_SIZE", 500 * 1024)) # 500KB default
84
87
  READ_ONLY_MODE = os.getenv("PATCHPAL_READ_ONLY", "false").lower() == "true"
85
88
  ALLOW_SENSITIVE = os.getenv("PATCHPAL_ALLOW_SENSITIVE", "false").lower() == "true"
86
89
  ENABLE_AUDIT_LOG = os.getenv("PATCHPAL_AUDIT_LOG", "true").lower() == "true"
@@ -2521,8 +2524,24 @@ def web_search(query: str, max_results: int = 5) -> str:
2521
2524
  max_results = min(max_results, 10)
2522
2525
 
2523
2526
  try:
2527
+ # Determine SSL verification setting
2528
+ # Priority: PATCHPAL_VERIFY_SSL env var > SSL_CERT_FILE > REQUESTS_CA_BUNDLE > default True
2529
+ verify_ssl = os.getenv("PATCHPAL_VERIFY_SSL")
2530
+ if verify_ssl is not None:
2531
+ # User explicitly set PATCHPAL_VERIFY_SSL
2532
+ if verify_ssl.lower() in ("false", "0", "no"):
2533
+ verify = False
2534
+ elif verify_ssl.lower() in ("true", "1", "yes"):
2535
+ verify = True
2536
+ else:
2537
+ # Treat as path to CA bundle
2538
+ verify = verify_ssl
2539
+ else:
2540
+ # Use SSL_CERT_FILE or REQUESTS_CA_BUNDLE if set (for corporate environments)
2541
+ verify = os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE") or True
2542
+
2524
2543
  # Perform search using DuckDuckGo
2525
- with DDGS() as ddgs:
2544
+ with DDGS(verify=verify) as ddgs:
2526
2545
  results = list(ddgs.text(query, max_results=max_results))
2527
2546
 
2528
2547
  if not results: