Skip to content

Commit 3d76b10

Browse files
authored
adds ability to pattern match and list recursive (#116)
1 parent e9988ef commit 3d76b10

1 file changed

Lines changed: 157 additions & 30 deletions

File tree

griptape_nodes_library/files/list_files.py

Lines changed: 157 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import logging
2+
from fnmatch import fnmatchcase
3+
from pathlib import Path
24
from typing import Any
35

46
from griptape_nodes.exe_types.core_types import Parameter
@@ -46,6 +48,23 @@ def __init__(
4648
)
4749
)
4850

51+
self.match_pattern = ParameterString(
52+
name="match_pattern",
53+
default_value="",
54+
tooltip="Optional shell-style glob for entry names (e.g. *.jpg, report_*.txt). Empty includes all. Applies to both files and folders when listed.",
55+
)
56+
self.match_pattern_case_sensitive = ParameterBool(
57+
name="match_pattern_case_sensitive",
58+
default_value=True,
59+
tooltip="When match_pattern is set: if True, letter case must match; if False, matching is case-insensitive (e.g. *.jpg matches .JPG). Ignored when match_pattern is empty.",
60+
)
61+
self.show_hidden = ParameterBool(
62+
name="show_hidden",
63+
allow_output=False,
64+
default_value=False,
65+
tooltip="Whether to show hidden files/folders.",
66+
)
67+
4968
self.list_options = ParameterString(
5069
name="list_options",
5170
allow_output=False,
@@ -54,11 +73,11 @@ def __init__(
5473
traits={Options(choices=LIST_OPTIONS)},
5574
)
5675

57-
self.show_hidden = ParameterBool(
58-
name="show_hidden",
76+
self.recursive = ParameterBool(
77+
name="recursive",
5978
allow_output=False,
6079
default_value=False,
61-
tooltip="Whether to show hidden files/folders.",
80+
tooltip="If True, walk subdirectories and include matches at every depth. If False, only the directory_path level is listed.",
6281
)
6382

6483
self.use_absolute_paths = ParameterBool(
@@ -69,8 +88,11 @@ def __init__(
6988
)
7089

7190
self.add_parameter(self.directory_path)
72-
self.add_parameter(self.show_hidden)
91+
self.add_parameter(self.match_pattern)
92+
self.add_parameter(self.match_pattern_case_sensitive)
7393
self.add_parameter(self.list_options)
94+
self.add_parameter(self.recursive)
95+
self.add_parameter(self.show_hidden)
7496
self.add_parameter(self.use_absolute_paths)
7597

7698
# Add output parameters
@@ -81,7 +103,7 @@ def __init__(
81103
allow_property=False,
82104
output_type="list",
83105
default_value=[],
84-
tooltip="List of full file paths found in the directory.",
106+
tooltip="Paths for matched entries. When recursive is True, includes nested files/folders under directory_path.",
85107
)
86108
)
87109

@@ -92,7 +114,7 @@ def __init__(
92114
allow_property=False,
93115
output_type="list",
94116
default_value=[],
95-
tooltip="List of file names (without path) found in the directory.",
117+
tooltip="Base names for matched entries only (no parent path). Same for nested matches when recursive is True.",
96118
)
97119
)
98120

@@ -110,14 +132,45 @@ def __init__(
110132
result_details_placeholder="Details on the list file attempt will be presented here.",
111133
)
112134

113-
def _filter_entries(self, entries: list, *, include_files: bool, include_folders: bool) -> list:
114-
"""Filter entries based on include_files and include_folders."""
135+
@staticmethod
136+
def _directory_visit_key(directory_path: str | None) -> str:
137+
"""Stable key for visited dirs (avoids repeated work on symlink cycles)."""
138+
if not directory_path:
139+
return "\x00<list_directory_root>\x00"
140+
try:
141+
return str(Path(directory_path).resolve())
142+
except (OSError, RuntimeError):
143+
return str(Path(directory_path).absolute())
144+
145+
def _name_matches_pattern(self, name: str, pattern: str, *, case_sensitive: bool) -> bool:
146+
if case_sensitive:
147+
return fnmatchcase(name, pattern)
148+
return fnmatchcase(name.casefold(), pattern.casefold())
149+
150+
def _filter_entries(
151+
self,
152+
entries: list,
153+
*,
154+
include_files: bool,
155+
include_folders: bool,
156+
match_pattern: str,
157+
match_pattern_case_sensitive: bool,
158+
) -> list:
159+
"""Filter entries based on include_files, include_folders, and optional fnmatch pattern on entry.name."""
160+
pattern = match_pattern.strip()
161+
use_pattern = bool(pattern)
115162
filtered_entries = []
116163
for entry in entries:
117164
if entry.is_dir:
118-
if include_folders:
165+
if include_folders and (
166+
not use_pattern
167+
or self._name_matches_pattern(entry.name, pattern, case_sensitive=match_pattern_case_sensitive)
168+
):
119169
filtered_entries.append(entry)
120-
elif include_files:
170+
elif include_files and (
171+
not use_pattern
172+
or self._name_matches_pattern(entry.name, pattern, case_sensitive=match_pattern_case_sensitive)
173+
):
121174
filtered_entries.append(entry)
122175
return filtered_entries
123176

@@ -127,14 +180,71 @@ def _convert_paths(self, entries: list, *, use_absolute_paths: bool) -> list[str
127180
return [entry.absolute_path for entry in entries]
128181
return [entry.path for entry in entries]
129182

183+
def _collect_entries_recursive(
184+
self,
185+
root_directory_path: str | None,
186+
*,
187+
show_hidden: bool,
188+
include_files: bool,
189+
include_folders: bool,
190+
match_pattern: str,
191+
match_pattern_case_sensitive: bool,
192+
) -> tuple[list, str | None]:
193+
"""Walk subdirectories depth-first. Returns (filtered entries, error_message)."""
194+
collected: list = []
195+
stack: list[str | None] = [root_directory_path]
196+
visited: set[str] = set()
197+
198+
while stack:
199+
current = stack.pop()
200+
visit_key = self._directory_visit_key(current)
201+
if visit_key in visited:
202+
continue
203+
visited.add(visit_key)
204+
205+
request = ListDirectoryRequest(
206+
directory_path=current,
207+
show_hidden=show_hidden,
208+
workspace_only=False,
209+
)
210+
result = GriptapeNodes.handle_request(request)
211+
212+
if isinstance(result, ListDirectoryResultFailure):
213+
error_msg = getattr(result, "error_message", "Unknown error occurred")
214+
return [], str(error_msg)
215+
if not isinstance(result, ListDirectoryResultSuccess):
216+
return [], "unexpected result type from directory listing"
217+
218+
# Descend into subdirs in a stable order (reverse so first listed dir is processed next on pop).
219+
subdirs: list[str] = []
220+
for entry in result.entries:
221+
collected.extend(
222+
self._filter_entries(
223+
[entry],
224+
include_files=include_files,
225+
include_folders=include_folders,
226+
match_pattern=match_pattern,
227+
match_pattern_case_sensitive=match_pattern_case_sensitive,
228+
)
229+
)
230+
if entry.is_dir:
231+
subdirs.append(entry.path)
232+
for d in reversed(subdirs):
233+
stack.append(d)
234+
235+
return collected, None
236+
130237
def process(self) -> None:
131238
self._clear_execution_status()
132239
directory_path = self.get_parameter_value("directory_path")
133240
# Clean directory path to remove newlines/carriage returns that cause Windows errors
134241
if directory_path:
135242
directory_path = GriptapeNodes.OSManager().sanitize_path_string(directory_path)
136243
show_hidden = self.get_parameter_value("show_hidden")
244+
match_pattern = self.get_parameter_value("match_pattern") or ""
245+
match_pattern_case_sensitive = self.get_parameter_value("match_pattern_case_sensitive")
137246
list_options = self.get_parameter_value("list_options")
247+
recursive = self.get_parameter_value("recursive")
138248
use_absolute_paths = self.get_parameter_value("use_absolute_paths")
139249

140250
# Determine include_files and include_folders based on list_options
@@ -154,30 +264,47 @@ def process(self) -> None:
154264
include_files = True
155265
include_folders = True
156266

157-
# Create the os_events request
158-
request = ListDirectoryRequest(
159-
directory_path=directory_path if directory_path else None,
160-
show_hidden=show_hidden,
161-
workspace_only=False, # Allow system-wide browsing
162-
)
267+
root = directory_path if directory_path else None
163268

164-
# Send request through GriptapeNodes.handle_request
165-
result = GriptapeNodes.handle_request(request)
269+
if recursive:
270+
filtered_entries, list_error = self._collect_entries_recursive(
271+
root,
272+
show_hidden=show_hidden,
273+
include_files=include_files,
274+
include_folders=include_folders,
275+
match_pattern=match_pattern,
276+
match_pattern_case_sensitive=match_pattern_case_sensitive,
277+
)
278+
if list_error:
279+
msg = f"{self.name} failed to list directory: {list_error}"
280+
self._set_status_results(was_successful=False, result_details=f"Failure: {msg}")
281+
return
282+
else:
283+
request = ListDirectoryRequest(
284+
directory_path=root,
285+
show_hidden=show_hidden,
286+
workspace_only=False, # Allow system-wide browsing
287+
)
288+
result = GriptapeNodes.handle_request(request)
166289

167-
if isinstance(result, ListDirectoryResultFailure):
168-
error_msg = getattr(result, "error_message", "Unknown error occurred")
169-
msg = f"{self.name} failed to list directory: {error_msg}"
170-
self._set_status_results(was_successful=False, result_details=f"Failure: {msg}")
171-
return
290+
if isinstance(result, ListDirectoryResultFailure):
291+
error_msg = getattr(result, "error_message", "Unknown error occurred")
292+
msg = f"{self.name} failed to list directory: {error_msg}"
293+
self._set_status_results(was_successful=False, result_details=f"Failure: {msg}")
294+
return
172295

173-
if not isinstance(result, ListDirectoryResultSuccess):
174-
msg = f"{self.name} received unexpected result type from directory listing"
175-
self._set_status_results(was_successful=False, result_details=f"Failure: {msg}")
176-
return
296+
if not isinstance(result, ListDirectoryResultSuccess):
297+
msg = f"{self.name} received unexpected result type from directory listing"
298+
self._set_status_results(was_successful=False, result_details=f"Failure: {msg}")
299+
return
177300

178-
filtered_entries = self._filter_entries(
179-
result.entries, include_files=include_files, include_folders=include_folders
180-
)
301+
filtered_entries = self._filter_entries(
302+
result.entries,
303+
include_files=include_files,
304+
include_folders=include_folders,
305+
match_pattern=match_pattern,
306+
match_pattern_case_sensitive=match_pattern_case_sensitive,
307+
)
181308
file_paths = self._convert_paths(filtered_entries, use_absolute_paths=use_absolute_paths)
182309
file_names = [entry.name for entry in filtered_entries]
183310

0 commit comments

Comments
 (0)