Nymbo commited on
Commit
1bd1d32
·
verified ·
1 Parent(s): 1791edf

Update Modules/File_System.py

Browse files
Files changed (1) hide show
  1. Modules/File_System.py +167 -19
Modules/File_System.py CHANGED
@@ -2,11 +2,11 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
5
  import shutil
6
  import stat
7
  from datetime import datetime
8
  from typing import Annotated, Optional
9
- import re
10
 
11
  import gradio as gr
12
 
@@ -15,8 +15,8 @@ from ._docstrings import autodoc
15
 
16
 
17
  TOOL_SUMMARY = (
18
- "Browse and manage files within a safe root. "
19
- "Actions: list, read, write, append, mkdir, move, copy, delete, info, help. "
20
  "Fill other fields as needed. "
21
  "Use paths like `.` because all paths are relative to the root (`/`). "
22
  "Use 'help' to see action-specific required fields and examples."
@@ -27,7 +27,7 @@ HELP_TEXT = (
27
  "Root: paths resolve under Nymbo-Tools/Filesystem by default (or NYMBO_TOOLS_ROOT if set). "
28
  "Absolute paths are disabled unless UNSAFE_ALLOW_ABS_PATHS=1.\n\n"
29
  "Actions and fields:\n"
30
- "- list: path='.' (default), recursive=false, show_hidden=false, max_entries=200\n"
31
  "- read: path, offset=0, max_chars=4000 (shows next_cursor when truncated)\n"
32
  "- write: path, content (UTF-8), create_dirs=true\n"
33
  "- append: path, content (UTF-8), create_dirs=true\n"
@@ -36,6 +36,7 @@ HELP_TEXT = (
36
  "- copy: path (src), dest_path (dst), overwrite=false\n"
37
  "- delete: path, recursive=true (required for directories)\n"
38
  "- info: path\n"
 
39
  "- help: show this guide\n\n"
40
  "Errors are returned as JSON with fields: {status:'error', code, message, path?, hint?, data?}.\n\n"
41
  "Examples:\n"
@@ -45,6 +46,9 @@ HELP_TEXT = (
45
  "- read file: action=read, path='notes/todo.txt', max_chars=200\n"
46
  "- move file: action=move, path='notes/todo.txt', dest_path='notes/todo-old.txt', overwrite=true\n"
47
  "- delete dir: action=delete, path='notes', recursive=true\n"
 
 
 
48
  )
49
 
50
 
@@ -240,6 +244,129 @@ def _list_dir(abs_path: str, *, show_hidden: bool, recursive: bool, max_entries:
240
  return (header + "\n" + "\n".join(lines)).strip()
241
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  def _read_file(abs_path: str, *, offset: int, max_chars: int) -> str:
244
  if not os.path.exists(abs_path):
245
  return _err("file_not_found", f"File not found: {_display_path(abs_path)}", path=_display_path(abs_path))
@@ -371,17 +498,18 @@ def _info(abs_path: str) -> str:
371
 
372
  @autodoc(summary=TOOL_SUMMARY)
373
  def File_System(
374
- action: Annotated[str, "Operation to perform: 'list', 'read', 'write', 'append', 'mkdir', 'move', 'copy', 'delete', 'info'."],
375
  path: Annotated[str, "Target path, relative to root unless UNSAFE_ALLOW_ABS_PATHS=1."] = ".",
376
- content: Annotated[Optional[str], "Content for write/append actions (UTF-8)."] = None,
377
  dest_path: Annotated[Optional[str], "Destination for move/copy (relative to root unless unsafe absolute allowed)."] = None,
378
- recursive: Annotated[bool, "For list (recurse into subfolders) and delete (required for directories)."] = False,
379
- show_hidden: Annotated[bool, "Include hidden files (dotfiles)."] = False,
380
- max_entries: Annotated[int, "Max entries to list (for list)."] = 200,
381
  offset: Annotated[int, "Start offset for reading files (for read)."] = 0,
382
  max_chars: Annotated[int, "Max characters to return when reading (0 = full file)."] = 4000,
383
  create_dirs: Annotated[bool, "Create parent directories for write/append if missing."] = True,
384
  overwrite: Annotated[bool, "Allow overwrite for move/copy destinations."] = False,
 
385
  ) -> str:
386
  _log_call_start(
387
  "File_System",
@@ -395,13 +523,14 @@ def File_System(
395
  max_chars=max_chars,
396
  create_dirs=create_dirs,
397
  overwrite=overwrite,
 
398
  )
399
  action = (action or "").strip().lower()
400
- if action not in {"list", "read", "write", "append", "mkdir", "move", "copy", "delete", "info", "help"}:
401
  result = _err(
402
  "invalid_action",
403
  "Invalid action.",
404
- hint="Choose from: list, read, write, append, mkdir, move, copy, delete, info, help."
405
  )
406
  _log_call_end("File_System", _truncate_for_log(result))
407
  return result
@@ -445,6 +574,24 @@ def File_System(
445
  result = _move_copy(action, abs_path, abs_dst, overwrite=overwrite)
446
  elif action == "delete":
447
  result = _delete(abs_path, recursive=recursive)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  else: # info
449
  result = _info(abs_path)
450
  except Exception as exc:
@@ -460,25 +607,26 @@ def build_interface() -> gr.Interface:
460
  inputs=[
461
  gr.Radio(
462
  label="Action",
463
- choices=["list", "read", "write", "append", "mkdir", "move", "copy", "delete", "info", "help"],
464
  value="help",
465
  ),
466
  gr.Textbox(label="Path", placeholder=". or src/file.txt", max_lines=1, value="."),
467
- gr.Textbox(label="Content (for write/append)", lines=6, placeholder="Text to write..."),
468
  gr.Textbox(label="Destination (for move/copy)", max_lines=1),
469
- gr.Checkbox(label="Recursive (list/delete)", value=False),
470
- gr.Checkbox(label="Show hidden (list)", value=False),
471
- gr.Slider(minimum=10, maximum=5000, step=10, value=200, label="Max entries (list)"),
472
- gr.Slider(minimum=0, maximum=1_000_000, step=100, value=0, label="Offset (read)"),
473
  gr.Slider(minimum=0, maximum=100_000, step=500, value=4000, label="Max chars (read, 0=all)"),
474
  gr.Checkbox(label="Create parent dirs (write)", value=True),
475
  gr.Checkbox(label="Overwrite destination (move/copy)", value=False),
 
476
  ],
477
  outputs=gr.Textbox(label="Result", lines=20),
478
  title="File System",
479
  description=(
480
- "<div id=\"fs-desc\" style=\"text-align:center; overflow:hidden;\">Browse and interact with a filesystem. "
481
- "Actions are required, fill other fields as needed."
482
  "</div>"
483
  ),
484
  api_description=TOOL_SUMMARY,
 
2
 
3
  import json
4
  import os
5
+ import re
6
  import shutil
7
  import stat
8
  from datetime import datetime
9
  from typing import Annotated, Optional
 
10
 
11
  import gradio as gr
12
 
 
15
 
16
 
17
  TOOL_SUMMARY = (
18
+ "Browse, search, and manage files within a safe root. "
19
+ "Actions: list, read, write, append, mkdir, move, copy, delete, info, search, help. "
20
  "Fill other fields as needed. "
21
  "Use paths like `.` because all paths are relative to the root (`/`). "
22
  "Use 'help' to see action-specific required fields and examples."
 
27
  "Root: paths resolve under Nymbo-Tools/Filesystem by default (or NYMBO_TOOLS_ROOT if set). "
28
  "Absolute paths are disabled unless UNSAFE_ALLOW_ABS_PATHS=1.\n\n"
29
  "Actions and fields:\n"
30
+ "- list: path='.' (default), recursive=false, show_hidden=false, max_entries=20\n"
31
  "- read: path, offset=0, max_chars=4000 (shows next_cursor when truncated)\n"
32
  "- write: path, content (UTF-8), create_dirs=true\n"
33
  "- append: path, content (UTF-8), create_dirs=true\n"
 
36
  "- copy: path (src), dest_path (dst), overwrite=false\n"
37
  "- delete: path, recursive=true (required for directories)\n"
38
  "- info: path\n"
39
+ "- search: path (dir or file), content=query text, recursive=false, show_hidden=false, max_entries=20, case_sensitive=false, offset=0\n"
40
  "- help: show this guide\n\n"
41
  "Errors are returned as JSON with fields: {status:'error', code, message, path?, hint?, data?}.\n\n"
42
  "Examples:\n"
 
46
  "- read file: action=read, path='notes/todo.txt', max_chars=200\n"
47
  "- move file: action=move, path='notes/todo.txt', dest_path='notes/todo-old.txt', overwrite=true\n"
48
  "- delete dir: action=delete, path='notes', recursive=true\n"
49
+ "- search text: action=search, path='notes', content='TODO', recursive=true, max_entries=50\n"
50
+ "- page search results: action=search, content='TODO', offset=10\n"
51
+ "- case-sensitive search: action=search, content='TODO', case_sensitive=true\n"
52
  )
53
 
54
 
 
244
  return (header + "\n" + "\n".join(lines)).strip()
245
 
246
 
247
+ def _search_text(
248
+ abs_path: str,
249
+ query: str,
250
+ *,
251
+ recursive: bool,
252
+ show_hidden: bool,
253
+ max_results: int,
254
+ case_sensitive: bool,
255
+ start_index: int,
256
+ ) -> str:
257
+ if not os.path.exists(abs_path):
258
+ return _err("path_not_found", f"Path not found: {_display_path(abs_path)}", path=_display_path(abs_path))
259
+
260
+ query = query or ""
261
+ normalized_query = query if case_sensitive else query.lower()
262
+ if normalized_query == "":
263
+ return _err(
264
+ "missing_search_query",
265
+ "Search query is required for the search action.",
266
+ hint="Provide text in the Content field to search for.",
267
+ )
268
+
269
+ max_results = max(1, int(max_results) if max_results is not None else 20)
270
+ start_index = max(0, int(start_index) if start_index is not None else 0)
271
+ matches: list[tuple[str, int, str]] = []
272
+ errors: list[str] = []
273
+ files_scanned = 0
274
+ truncated = False
275
+ total_matches = 0
276
+
277
+ def _should_skip(name: str) -> bool:
278
+ return not show_hidden and name.startswith('.')
279
+
280
+ def _handle_match(file_path: str, line_no: int, line_text: str) -> bool:
281
+ nonlocal truncated, total_matches
282
+ total_matches += 1
283
+ if total_matches <= start_index:
284
+ return False
285
+ if len(matches) < max_results:
286
+ snippet = line_text.strip()
287
+ if len(snippet) > 200:
288
+ snippet = snippet[:197] + "…"
289
+ matches.append((_display_path(file_path), line_no, snippet))
290
+ return False
291
+ truncated = True
292
+ return True
293
+
294
+ def _search_file(file_path: str) -> bool:
295
+ nonlocal files_scanned
296
+ files_scanned += 1
297
+ try:
298
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as handle:
299
+ for line_no, line in enumerate(handle, start=1):
300
+ haystack = line if case_sensitive else line.lower()
301
+ if normalized_query in haystack:
302
+ if _handle_match(file_path, line_no, line):
303
+ return True
304
+ except Exception as exc:
305
+ errors.append(f"{_display_path(file_path)} ({_safe_err(exc)})")
306
+ return truncated
307
+
308
+ if os.path.isfile(abs_path):
309
+ _search_file(abs_path)
310
+ else:
311
+ for root, dirs, files in os.walk(abs_path):
312
+ dirs[:] = [d for d in dirs if not _should_skip(d)]
313
+ visible_files = [f for f in files if show_hidden or not f.startswith('.')]
314
+ for name in visible_files:
315
+ file_path = os.path.join(root, name)
316
+ if _search_file(file_path):
317
+ break
318
+ if truncated:
319
+ break
320
+ if not recursive:
321
+ break
322
+
323
+ header_lines = [
324
+ f"Search results for {query!r}",
325
+ f"Scope: {_display_path(abs_path)}",
326
+ f"Recursive: {'yes' if recursive else 'no'}, Hidden: {'yes' if show_hidden else 'no'}, Case-sensitive: {'yes' if case_sensitive else 'no'}",
327
+ f"Start offset: {start_index}",
328
+ f"Matches returned: {len(matches)}" + (" (truncated)" if truncated else ""),
329
+ f"Files scanned: {files_scanned}",
330
+ ]
331
+
332
+ next_cursor = start_index + len(matches) if truncated else None
333
+
334
+ if truncated:
335
+ header_lines.append(f"Matches encountered before truncation: {total_matches}")
336
+ header_lines.append(f"Truncated: yes — re-run with offset={next_cursor} to continue.")
337
+ header_lines.append(f"Next cursor: {next_cursor}")
338
+ else:
339
+ header_lines.append(f"Total matches found: {total_matches}")
340
+ header_lines.append("Truncated: no — end of results.")
341
+ header_lines.append("Next cursor: None")
342
+
343
+ if not matches:
344
+ if total_matches > 0 and start_index >= total_matches:
345
+ hint_limit = max(total_matches - 1, 0)
346
+ body_lines = [
347
+ f"No matches found at or after offset {start_index}. Total matches available: {total_matches}.",
348
+ (f"Try a smaller offset (≤ {hint_limit})." if hint_limit >= 0 else ""),
349
+ ]
350
+ body_lines = [line for line in body_lines if line]
351
+ else:
352
+ body_lines = [
353
+ "No matches found.",
354
+ (f"Total matches encountered: {total_matches}." if total_matches else ""),
355
+ ]
356
+ body_lines = [line for line in body_lines if line]
357
+ else:
358
+ body_lines = [f"{idx}. {path}:{line_no}: {text}" for idx, (path, line_no, text) in enumerate(matches, start=1)]
359
+
360
+ if errors:
361
+ shown = errors[:5]
362
+ body_lines.extend(["", "Warnings:"])
363
+ body_lines.extend(shown)
364
+ if len(errors) > len(shown):
365
+ body_lines.append(f"… {len(errors) - len(shown)} additional files could not be read.")
366
+
367
+ return "\n".join(header_lines) + "\n\n" + "\n".join(body_lines)
368
+
369
+
370
  def _read_file(abs_path: str, *, offset: int, max_chars: int) -> str:
371
  if not os.path.exists(abs_path):
372
  return _err("file_not_found", f"File not found: {_display_path(abs_path)}", path=_display_path(abs_path))
 
498
 
499
  @autodoc(summary=TOOL_SUMMARY)
500
  def File_System(
501
+ action: Annotated[str, "Operation to perform: 'list', 'read', 'write', 'append', 'mkdir', 'move', 'copy', 'delete', 'info', 'search'."],
502
  path: Annotated[str, "Target path, relative to root unless UNSAFE_ALLOW_ABS_PATHS=1."] = ".",
503
+ content: Annotated[Optional[str], "Content for write/append actions or search query (UTF-8)."] = None,
504
  dest_path: Annotated[Optional[str], "Destination for move/copy (relative to root unless unsafe absolute allowed)."] = None,
505
+ recursive: Annotated[bool, "For list/search (recurse into subfolders) and delete (required for directories)."] = False,
506
+ show_hidden: Annotated[bool, "Include hidden files (dotfiles) for list/search."] = False,
507
+ max_entries: Annotated[int, "Max entries to list or matches to return (for list/search)."] = 20,
508
  offset: Annotated[int, "Start offset for reading files (for read)."] = 0,
509
  max_chars: Annotated[int, "Max characters to return when reading (0 = full file)."] = 4000,
510
  create_dirs: Annotated[bool, "Create parent directories for write/append if missing."] = True,
511
  overwrite: Annotated[bool, "Allow overwrite for move/copy destinations."] = False,
512
+ case_sensitive: Annotated[bool, "Match case when searching text."] = False,
513
  ) -> str:
514
  _log_call_start(
515
  "File_System",
 
523
  max_chars=max_chars,
524
  create_dirs=create_dirs,
525
  overwrite=overwrite,
526
+ case_sensitive=case_sensitive,
527
  )
528
  action = (action or "").strip().lower()
529
+ if action not in {"list", "read", "write", "append", "mkdir", "move", "copy", "delete", "info", "search", "help"}:
530
  result = _err(
531
  "invalid_action",
532
  "Invalid action.",
533
+ hint="Choose from: list, read, write, append, mkdir, move, copy, delete, info, search, help."
534
  )
535
  _log_call_end("File_System", _truncate_for_log(result))
536
  return result
 
574
  result = _move_copy(action, abs_path, abs_dst, overwrite=overwrite)
575
  elif action == "delete":
576
  result = _delete(abs_path, recursive=recursive)
577
+ elif action == "search":
578
+ query_text = content or ""
579
+ if query_text.strip() == "":
580
+ result = _err(
581
+ "missing_search_query",
582
+ "Search query is required for the search action.",
583
+ hint="Provide text in the Content field to search for.",
584
+ )
585
+ else:
586
+ result = _search_text(
587
+ abs_path,
588
+ query_text,
589
+ recursive=recursive,
590
+ show_hidden=show_hidden,
591
+ max_results=max_entries,
592
+ case_sensitive=case_sensitive,
593
+ start_index=offset,
594
+ )
595
  else: # info
596
  result = _info(abs_path)
597
  except Exception as exc:
 
607
  inputs=[
608
  gr.Radio(
609
  label="Action",
610
+ choices=["list", "read", "write", "append", "mkdir", "move", "copy", "delete", "info", "search", "help"],
611
  value="help",
612
  ),
613
  gr.Textbox(label="Path", placeholder=". or src/file.txt", max_lines=1, value="."),
614
+ gr.Textbox(label="Content (write/append/search)", lines=6, placeholder="Text to write or search for..."),
615
  gr.Textbox(label="Destination (for move/copy)", max_lines=1),
616
+ gr.Checkbox(label="Recursive (list/delete/search)", value=False),
617
+ gr.Checkbox(label="Show hidden (list/search)", value=False),
618
+ gr.Slider(minimum=10, maximum=5000, step=10, value=20, label="Max entries / matches (list/search)"),
619
+ gr.Slider(minimum=0, maximum=1_000_000, step=100, value=0, label="Offset (read/search start)"),
620
  gr.Slider(minimum=0, maximum=100_000, step=500, value=4000, label="Max chars (read, 0=all)"),
621
  gr.Checkbox(label="Create parent dirs (write)", value=True),
622
  gr.Checkbox(label="Overwrite destination (move/copy)", value=False),
623
+ gr.Checkbox(label="Case sensitive search", value=False),
624
  ],
625
  outputs=gr.Textbox(label="Result", lines=20),
626
  title="File System",
627
  description=(
628
+ "<div id=\"fs-desc\" style=\"text-align:center; overflow:hidden;\">Browse, search, and interact with a filesystem. "
629
+ "Choose an action and fill optional fields as needed."
630
  "</div>"
631
  ),
632
  api_description=TOOL_SUMMARY,