11import logging
2+ from fnmatch import fnmatchcase
3+ from pathlib import Path
24from typing import Any
35
46from 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