Skip to content

Commit 841d8dd

Browse files
committed
fixes #754
1 parent 325ece1 commit 841d8dd

File tree

4 files changed

+85
-55
lines changed

4 files changed

+85
-55
lines changed

fastcore/_modidx.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@
588588
'fastcore.test.test_stdout': ('test.html#test_stdout', 'fastcore/test.py'),
589589
'fastcore.test.test_warns': ('test.html#test_warns', 'fastcore/test.py')},
590590
'fastcore.tools': { 'fastcore.tools._fmt_path': ('tools.html#_fmt_path', 'fastcore/tools.py'),
591+
'fastcore.tools._load_valid_paths': ('tools.html#_load_valid_paths', 'fastcore/tools.py'),
591592
'fastcore.tools.create': ('tools.html#create', 'fastcore/tools.py'),
592593
'fastcore.tools.ensure': ('tools.html#ensure', 'fastcore/tools.py'),
593594
'fastcore.tools.explain_exc': ('tools.html#explain_exc', 'fastcore/tools.py'),

fastcore/imports.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,17 @@ def remove_suffix(text, suffix):
100100
"Temporary until py39 is a prereq"
101101
return text[:-len(suffix)] if text.endswith(suffix) else text
102102

103+
def is_usable_tool(func:callable):
104+
"True if the function has a docstring and all parameters have types, meaning that it can be used as an LLM tool."
105+
from inspect import Parameter,signature
106+
if not func.__doc__ or not callable(func): return False
107+
return all(p.annotation != Parameter.empty for p in signature(func).parameters.values())
108+
109+
__llmtools__ = set()
110+
111+
def llmtool(f):
112+
assert is_usable_tool(f), f"Function {f.__name__} is not usable as a tool"
113+
__llmtools__.add(f.__name__)
114+
f.__llmtool__ = True
115+
return f
116+

fastcore/tools.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/12_tools.ipynb.
44

55
# %% auto #0
6-
__all__ = ['explain_exc', 'ensure', 'valid_path', 'run_cmd', 'rg', 'sed', 'view', 'create', 'insert', 'str_replace',
7-
'strs_replace', 'replace_lines', 'move_lines', 'get_callable']
6+
__all__ = ['valid_paths', 'explain_exc', 'ensure', 'valid_path', 'run_cmd', 'rg', 'sed', 'view', 'create', 'insert',
7+
'str_replace', 'strs_replace', 'replace_lines', 'move_lines', 'get_callable']
88

99
# %% ../nbs/12_tools.ipynb #578246d2
10+
from .xdg import *
1011
from .imports import *
1112
from .xtras import truncstr
1213
from shlex import split
@@ -27,10 +28,21 @@ def ensure(b: bool, msg:str=""):
2728
if not b: raise ValueError(msg)
2829

2930

31+
# %% ../nbs/12_tools.ipynb #6afaf015
32+
def _load_valid_paths():
33+
cfg = xdg_config_home() / 'fc_tools_paths'
34+
base = ['.', '/tmp']
35+
if not cfg.exists(): return base
36+
return base + cfg.read_text().split()
37+
38+
valid_paths = _load_valid_paths()
39+
3040
# %% ../nbs/12_tools.ipynb #e4288129
31-
def valid_path(path:str, must_exist:bool=True) -> Path:
41+
def valid_path(path:str, must_exist:bool=True, chk_perms:bool=True) -> Path:
3242
'Return expanded/resolved Path, raising FileNotFoundError if must_exist and missing'
3343
p = Path(path).expanduser().resolve()
44+
vpaths = [Path(vp).expanduser().resolve() for vp in valid_paths]
45+
if chk_perms and not any(p == vp or vp in p.parents for vp in vpaths): raise PermissionError(f'Path not in valid_paths: {p}')
3446
if must_exist and not p.exists(): raise FileNotFoundError(f'File not found: {p}')
3547
return p
3648

@@ -52,6 +64,7 @@ def run_cmd(
5264
return res + outp.stderr
5365

5466
# %% ../nbs/12_tools.ipynb #eb253a39
67+
@llmtool
5568
def rg(
5669
argstr:str, # All args to the command, will be split with shlex
5770
disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command
@@ -61,6 +74,7 @@ def rg(
6174
return run_cmd('rg', '-n '+argstr, disallow_re=disallow_re, allow_re=allow_re)
6275

6376
# %% ../nbs/12_tools.ipynb #09de7b32
77+
@llmtool
6478
def sed(
6579
argstr:str, # All args to the command, will be split with shlex
6680
disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command
@@ -80,6 +94,7 @@ def _fmt_path(f, p, skip_folders=()):
8094
return f'{f} ({f.stat().st_size/1024:.1f}k)'
8195

8296
# %% ../nbs/12_tools.ipynb #5dd1aaf3
97+
@llmtool
8398
def view(
8499
path:str, # Path to directory or file to view
85100
view_range:tuple[int,int]=None, # Optional 1-indexed (start, end) line range for files, end=-1 for EOF. Do NOT use unless it's known that the file is too big to keep in context—simply view the WHOLE file when possible
@@ -88,7 +103,7 @@ def view(
88103
):
89104
'View directory or file contents with optional line range and numbers'
90105
try:
91-
p = valid_path(path)
106+
p = valid_path(path, chk_perms=False)
92107
header = None
93108
if p.is_dir():
94109
lines = [s for f in p.glob('**/*') if (s := _fmt_path(f, p, skip_folders))]
@@ -106,6 +121,7 @@ def view(
106121
except: return explain_exc('viewing')
107122

108123
# %% ../nbs/12_tools.ipynb #36f58e38
124+
@llmtool
109125
def create(
110126
path: str, # Path where the new file should be created
111127
file_text: str, # Content to write to the file
@@ -122,6 +138,7 @@ def create(
122138
except: return explain_exc('creating file')
123139

124140
# %% ../nbs/12_tools.ipynb #434147ef
141+
@llmtool
125142
def insert(
126143
path: str, # Path to the file to modify
127144
insert_line: int, # Line number where to insert (0-based indexing)
@@ -139,6 +156,7 @@ def insert(
139156
except: return explain_exc('inserting text')
140157

141158
# %% ../nbs/12_tools.ipynb #9272ff7d
159+
@llmtool
142160
def str_replace(
143161
path: str, # Path to the file to modify
144162
old_str: str, # Text to find and replace
@@ -157,6 +175,7 @@ def str_replace(
157175
except: return explain_exc('replacing text')
158176

159177
# %% ../nbs/12_tools.ipynb #eb907119
178+
@llmtool
160179
def strs_replace(
161180
path:str, # Path to the file to modify
162181
old_strs:list[str], # List of strings to find and replace
@@ -167,6 +186,7 @@ def strs_replace(
167186
return 'Results for each replacement:\n' + '; '.join(res)
168187

169188
# %% ../nbs/12_tools.ipynb #94dd09ed
189+
@llmtool
170190
def replace_lines(
171191
path:str, # Path to the file to modify
172192
start_line:int, # Starting line number to replace (1-based indexing)
@@ -184,6 +204,7 @@ def replace_lines(
184204
except: return explain_exc('replacing lines')
185205

186206
# %% ../nbs/12_tools.ipynb #b136abf8
207+
@llmtool
187208
def move_lines(
188209
path: str, # Path to the file to modify
189210
start_line: int, # Starting line number to move (1-based)

nbs/12_tools.ipynb

Lines changed: 45 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"outputs": [],
2828
"source": [
2929
"#| export\n",
30+
"from fastcore.xdg import *\n",
3031
"from fastcore.imports import *\n",
3132
"from fastcore.xtras import truncstr\n",
3233
"from shlex import split\n",
@@ -41,7 +42,7 @@
4142
"outputs": [],
4243
"source": [
4344
"#| hide\n",
44-
"from fastcore.test import test_eq\n",
45+
"from fastcore.test import test_eq,test_fail\n",
4546
"from toolslm.funccall import get_schema\n",
4647
"import inspect"
4748
]
@@ -135,6 +136,23 @@
135136
"calc_div(1,0)"
136137
]
137138
},
139+
{
140+
"cell_type": "code",
141+
"execution_count": null,
142+
"id": "6afaf015",
143+
"metadata": {},
144+
"outputs": [],
145+
"source": [
146+
"#| export\n",
147+
"def _load_valid_paths():\n",
148+
" cfg = xdg_config_home() / 'fc_tools_paths'\n",
149+
" base = ['.', '/tmp']\n",
150+
" if not cfg.exists(): return base\n",
151+
" return base + cfg.read_text().split()\n",
152+
"\n",
153+
"valid_paths = _load_valid_paths()"
154+
]
155+
},
138156
{
139157
"cell_type": "code",
140158
"execution_count": null,
@@ -143,32 +161,26 @@
143161
"outputs": [],
144162
"source": [
145163
"#| export\n",
146-
"def valid_path(path:str, must_exist:bool=True) -> Path:\n",
164+
"def valid_path(path:str, must_exist:bool=True, chk_perms:bool=True) -> Path:\n",
147165
" 'Return expanded/resolved Path, raising FileNotFoundError if must_exist and missing'\n",
148166
" p = Path(path).expanduser().resolve()\n",
167+
" vpaths = [Path(vp).expanduser().resolve() for vp in valid_paths]\n",
168+
" if chk_perms and not any(p == vp or vp in p.parents for vp in vpaths): raise PermissionError(f'Path not in valid_paths: {p}')\n",
149169
" if must_exist and not p.exists(): raise FileNotFoundError(f'File not found: {p}')\n",
150170
" return p"
151171
]
152172
},
153173
{
154174
"cell_type": "code",
155175
"execution_count": null,
156-
"id": "a2b93ffe",
176+
"id": "c30153ab",
157177
"metadata": {},
158-
"outputs": [
159-
{
160-
"data": {
161-
"text/plain": [
162-
"Path('/Users/jhoward/aai-ws/fastcore/nbs')"
163-
]
164-
},
165-
"execution_count": null,
166-
"metadata": {},
167-
"output_type": "execute_result"
168-
}
169-
],
178+
"outputs": [],
170179
"source": [
171-
"valid_path(\".\")"
180+
"assert valid_path('.')\n",
181+
"assert valid_path('/tmp')\n",
182+
"\n",
183+
"test_fail(lambda: valid_path('..'), exc=PermissionError)"
172184
]
173185
},
174186
{
@@ -299,6 +311,7 @@
299311
"outputs": [],
300312
"source": [
301313
"#| export\n",
314+
"@llmtool\n",
302315
"def rg(\n",
303316
" argstr:str, # All args to the command, will be split with shlex\n",
304317
" disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command\n",
@@ -436,6 +449,7 @@
436449
"outputs": [],
437450
"source": [
438451
"#| export\n",
452+
"@llmtool\n",
439453
"def sed(\n",
440454
" argstr:str, # All args to the command, will be split with shlex\n",
441455
" disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command\n",
@@ -539,6 +553,7 @@
539553
"outputs": [],
540554
"source": [
541555
"#| export\n",
556+
"@llmtool\n",
542557
"def view(\n",
543558
" path:str, # Path to directory or file to view\n",
544559
" view_range:tuple[int,int]=None, # Optional 1-indexed (start, end) line range for files, end=-1 for EOF. Do NOT use unless it's known that the file is too big to keep in context—simply view the WHOLE file when possible\n",
@@ -547,7 +562,7 @@
547562
"):\n",
548563
" 'View directory or file contents with optional line range and numbers'\n",
549564
" try:\n",
550-
" p = valid_path(path)\n",
565+
" p = valid_path(path, chk_perms=False)\n",
551566
" header = None\n",
552567
" if p.is_dir():\n",
553568
" lines = [s for f in p.glob('**/*') if (s := _fmt_path(f, p, skip_folders))]\n",
@@ -639,6 +654,7 @@
639654
"outputs": [],
640655
"source": [
641656
"#| export\n",
657+
"@llmtool\n",
642658
"def create(\n",
643659
" path: str, # Path where the new file should be created\n",
644660
" file_text: str, # Content to write to the file\n",
@@ -665,7 +681,13 @@
665681
"name": "stdout",
666682
"output_type": "stream",
667683
"text": [
668-
"Created file /path/test.txt.\n",
684+
"Created file /path/test.txt.\n"
685+
]
686+
},
687+
{
688+
"name": "stdout",
689+
"output_type": "stream",
690+
"text": [
669691
"Contents:\n",
670692
" 1 │ Hello, world!\n"
671693
]
@@ -686,6 +708,7 @@
686708
"outputs": [],
687709
"source": [
688710
"#| export\n",
711+
"@llmtool\n",
689712
"def insert(\n",
690713
" path: str, # Path to the file to modify\n",
691714
" insert_line: int, # Line number where to insert (0-based indexing)\n",
@@ -731,6 +754,7 @@
731754
"outputs": [],
732755
"source": [
733756
"#| export\n",
757+
"@llmtool\n",
734758
"def str_replace(\n",
735759
" path: str, # Path to the file to modify\n",
736760
" old_str: str, # Text to find and replace\n",
@@ -799,6 +823,7 @@
799823
"outputs": [],
800824
"source": [
801825
"#| export\n",
826+
"@llmtool\n",
802827
"def strs_replace(\n",
803828
" path:str, # Path to the file to modify\n",
804829
" old_strs:list[str], # List of strings to find and replace\n",
@@ -863,6 +888,7 @@
863888
"outputs": [],
864889
"source": [
865890
"#| export\n",
891+
"@llmtool\n",
866892
"def replace_lines(\n",
867893
" path:str, # Path to the file to modify\n",
868894
" start_line:int, # Starting line number to replace (1-based indexing)\n",
@@ -929,6 +955,7 @@
929955
"outputs": [],
930956
"source": [
931957
"#| export\n",
958+
"@llmtool\n",
932959
"def move_lines(\n",
933960
" path: str, # Path to the file to modify\n",
934961
" start_line: int, # Starting line number to move (1-based)\n",
@@ -1120,39 +1147,6 @@
11201147
" }"
11211148
]
11221149
},
1123-
{
1124-
"cell_type": "code",
1125-
"execution_count": null,
1126-
"id": "6ef89850",
1127-
"metadata": {},
1128-
"outputs": [
1129-
{
1130-
"data": {
1131-
"text/plain": [
1132-
"'run_cmd; explain_exc; ensure; valid_path; rg; sed; view; create; insert; str_replace; strs_replace; replace_lines; move_lines; get_callable; calc_div'"
1133-
]
1134-
},
1135-
"execution_count": null,
1136-
"metadata": {},
1137-
"output_type": "execute_result"
1138-
}
1139-
],
1140-
"source": [
1141-
"'; '.join(get_callable())"
1142-
]
1143-
},
1144-
{
1145-
"cell_type": "code",
1146-
"execution_count": null,
1147-
"id": "4092b227",
1148-
"metadata": {},
1149-
"outputs": [],
1150-
"source": [
1151-
"# Verify that all public functions defined in this module have valid schemas\n",
1152-
"# (i.e., they have proper type annotations and docstrings required by get_schema)\n",
1153-
"for f,_o in get_callable().items(): test_eq(f, get_schema(globals()[f])['name'])"
1154-
]
1155-
},
11561150
{
11571151
"cell_type": "markdown",
11581152
"id": "ed04dff5",

0 commit comments

Comments
 (0)