@@ -108,16 +108,30 @@ def find_executable(executable_name: str, target: Optional[str]) -> List[str]:
108108
109109
110110def gather_additional_files (
111- extra_files : Optional [str ], changes_file : Optional [str ]
111+ extra_files_globs : Optional [str ], changes_file : Optional [str ]
112112) -> List [str ]:
113113 """Gather additional files to include in the archive."""
114- if extra_files :
115- return list (filter (None , map (str .strip , extra_files .splitlines ())))
114+ if not extra_files_globs :
115+ return glob .glob ("README*" ) + [changes_file ] if changes_file else []
116+
117+ from os .path import isdir
118+
119+ globs = []
120+ for g in extra_files_globs .splitlines ():
121+ stripped = g .strip ()
122+ if not stripped :
123+ continue
124+
125+ if isdir (g ):
126+ globs .append (g .removesuffix ("/" ) + "/**" )
127+ else :
128+ globs .append (g )
129+
130+ # Collect all matching files
131+ files = [changes_file ] if changes_file else []
132+ for g in globs :
133+ files .extend (glob .glob (g , recursive = True ))
116134
117- files = []
118- if changes_file :
119- files .append (changes_file )
120- files .extend (glob .glob ("README*" ))
121135 return files
122136
123137
@@ -204,6 +218,216 @@ def target_to_archive_name(target: str) -> str:
204218 return f"{ os_name } -{ cpu } "
205219
206220
221+ class TestExtraFilesGlobs (unittest .TestCase ):
222+ """Test cases for handling extra files."""
223+
224+ from unittest .mock import patch
225+ from collections import namedtuple
226+
227+ ExtraFilesTestDefinition = namedtuple (
228+ "ExtraFilesTestDefinition" , ["name" , "arguments" , "expected" ]
229+ )
230+
231+ @patch ("os.path.isdir" )
232+ @patch ("glob.glob" )
233+ def test_extra_files_empty (self , mocked_glob , mocked_isdir ):
234+ mocked_glob .side_effect = lambda g , * kargs , ** kwargs : []
235+ mocked_isdir .return_value = False
236+
237+ test_definitions = [
238+ self .ExtraFilesTestDefinition (
239+ name = "empty arguments" , arguments = ("\n " .join ([]), None ), expected = []
240+ ),
241+ self .ExtraFilesTestDefinition (
242+ name = "keeps changes_file" ,
243+ arguments = ("\n " .join ([]), "CHANGES.md" ),
244+ expected = ["CHANGES.md" ],
245+ ),
246+ ]
247+
248+ for test_input in test_definitions :
249+ with self .subTest (test_input .name ):
250+ self .assertCountEqual (
251+ gather_additional_files (* test_input .arguments ), test_input .expected
252+ )
253+
254+ @patch ("os.path.isdir" )
255+ @patch ("glob.glob" )
256+ def test_extra_files_literals (self , mocked_glob , mocked_isdir ):
257+ mocked_glob .side_effect = lambda g , * args , ** kwargs : {
258+ "README.md" : ["README.md" ],
259+ "CHANGES.md" : ["CHANGES.md" ],
260+ "dir/file.txt" : ["dir/file.txt" ],
261+ }.get (g , [])
262+ mocked_isdir .return_value = False
263+
264+ test_definitions = [
265+ self .ExtraFilesTestDefinition (
266+ name = "keeps singular literal file" ,
267+ arguments = ("\n " .join (["README.md" ]), None ),
268+ expected = ["README.md" ],
269+ ),
270+ self .ExtraFilesTestDefinition (
271+ name = "keeps singular literal nested file" ,
272+ arguments = ("\n " .join (["dir/file.txt" ]), None ),
273+ expected = ["dir/file.txt" ],
274+ ),
275+ self .ExtraFilesTestDefinition (
276+ name = "combines singular literal nested file and changes file" ,
277+ arguments = ("\n " .join (["dir/file.txt" ]), "CHANGES.md" ),
278+ expected = ["dir/file.txt" , "CHANGES.md" ],
279+ ),
280+ self .ExtraFilesTestDefinition (
281+ "combines un- and nested file with changes file" ,
282+ arguments = ("\n " .join (["dir/file.txt" , "README.md" ]), "CHANGES.md" ),
283+ expected = ["README.md" , "dir/file.txt" , "CHANGES.md" ],
284+ ),
285+ ]
286+
287+ for test_input in test_definitions :
288+ with self .subTest (test_input .name ):
289+ self .assertCountEqual (
290+ gather_additional_files (* test_input .arguments ), test_input .expected
291+ )
292+
293+ @patch ("os.path.isdir" )
294+ @patch ("glob.glob" )
295+ def test_extra_files_directory (self , mocked_glob , mocked_isdir ):
296+ mocked_glob .side_effect = lambda g , * args , ** kwargs : {
297+ "README.md" : ["README.md" ],
298+ "dir/file.txt" : ["dir/file.txt" ],
299+ "directory/**" : ["directory/file1.txt" , "directory/file2.txt" ],
300+ }.get (g , [])
301+ mocked_isdir .side_effect = lambda g , * args , ** kwargs : g in [
302+ "directory/" ,
303+ "directory" ,
304+ "dir" ,
305+ "dir/" ,
306+ ]
307+
308+ test_definitions = [
309+ self .ExtraFilesTestDefinition (
310+ name = "handles directory (no trailing slash), combines with changes file" ,
311+ arguments = ("\n " .join (["directory" ]), "CHANGES.md" ),
312+ expected = ["directory/file1.txt" , "directory/file2.txt" , "CHANGES.md" ],
313+ ),
314+ self .ExtraFilesTestDefinition (
315+ name = "handles directory (trailing slash), combines with changes file" ,
316+ arguments = ("\n " .join (["directory/" ]), "CHANGES.md" ),
317+ expected = ["directory/file1.txt" , "directory/file2.txt" , "CHANGES.md" ],
318+ ),
319+ ]
320+
321+ for test_input in test_definitions :
322+ with self .subTest (test_input .name ):
323+ self .assertCountEqual (
324+ gather_additional_files (* test_input .arguments ), test_input .expected
325+ )
326+
327+ @patch ("os.path.isdir" )
328+ @patch ("glob.glob" )
329+ def test_extra_files_globs (self , mocked_glob , mocked_isdir ):
330+ mocked_glob .side_effect = lambda g , * args , ** kwargs : {
331+ "README.md" : ["README.md" ],
332+ "dir/**" : ["dir/file1.txt" , "dir/file2.txt" ],
333+ "dir/*/file*.txt" : ["dir/a/file1.txt" , "dir/b/file2.txt" ],
334+ }.get (g , [])
335+ mocked_isdir .return_value = False
336+
337+ test_definitions = [
338+ self .ExtraFilesTestDefinition (
339+ name = "handles globs, combines with changes file" ,
340+ arguments = ("\n " .join (["dir/**" ]), "CHANGES.md" ),
341+ expected = ["dir/file1.txt" , "dir/file2.txt" , "CHANGES.md" ],
342+ ),
343+ self .ExtraFilesTestDefinition (
344+ name = "handles globs, combines with literal files" ,
345+ arguments = ("\n " .join (["README.md" , "dir/**" ]), None ),
346+ expected = ["README.md" , "dir/file1.txt" , "dir/file2.txt" ],
347+ ),
348+ self .ExtraFilesTestDefinition (
349+ name = "handles more complex globs, combines with changes file" ,
350+ arguments = ("\n " .join (["dir/*/file*.txt" ]), "CHANGES.md" ),
351+ expected = ["dir/a/file1.txt" , "dir/b/file2.txt" , "CHANGES.md" ],
352+ ),
353+ ]
354+
355+ for test_input in test_definitions :
356+ with self .subTest (test_input .name ):
357+ self .assertCountEqual (
358+ gather_additional_files (* test_input .arguments ), test_input .expected
359+ )
360+
361+ @patch ("os.path.isdir" )
362+ @patch ("glob.glob" )
363+ def test_extra_files_excludes_hidden (self , mocked_glob , mocked_isdir ):
364+ # glob.glob by default doesn't match hidden files/dirs
365+ mocked_glob .side_effect = lambda g , * args , ** kwargs : {
366+ "dir/**" : ["dir/file1.txt" , "dir/subdir/file2.txt" ], # No .hidden files
367+ "**/*.txt" : ["file.txt" , "dir/visible.txt" ], # No .hidden.txt
368+ }.get (g , [])
369+ mocked_isdir .return_value = False
370+
371+ test_definitions = [
372+ self .ExtraFilesTestDefinition (
373+ name = "recursive glob excludes hidden files and directories" ,
374+ arguments = ("\n " .join (["dir/**" ]), None ),
375+ expected = ["dir/file1.txt" , "dir/subdir/file2.txt" ],
376+ ),
377+ self .ExtraFilesTestDefinition (
378+ name = "pattern glob excludes hidden files" ,
379+ arguments = ("\n " .join (["**/*.txt" ]), None ),
380+ expected = ["file.txt" , "dir/visible.txt" ],
381+ ),
382+ self .ExtraFilesTestDefinition (
383+ name = "multiple globs exclude hidden files, includes changes file" ,
384+ arguments = ("\n " .join (["dir/**" , "**/*.txt" ]), "CHANGES.md" ),
385+ expected = [
386+ "dir/file1.txt" ,
387+ "dir/subdir/file2.txt" ,
388+ "file.txt" ,
389+ "dir/visible.txt" ,
390+ "CHANGES.md" ,
391+ ],
392+ ),
393+ ]
394+
395+ for test_input in test_definitions :
396+ with self .subTest (test_input .name ):
397+ self .assertCountEqual (
398+ gather_additional_files (* test_input .arguments ), test_input .expected
399+ )
400+
401+ @patch ("os.path.isdir" )
402+ @patch ("glob.glob" )
403+ def test_extra_files_includes_explicit_hidden (self , mocked_glob , mocked_isdir ):
404+ # When explicitly specified, hidden files should be included
405+ mocked_glob .side_effect = lambda g , * args , ** kwargs : {
406+ ".gitignore" : [".gitignore" ],
407+ ".config/settings.json" : [".config/settings.json" ],
408+ }.get (g , [])
409+ mocked_isdir .return_value = False
410+
411+ test_definitions = [
412+ self .ExtraFilesTestDefinition (
413+ name = "includes explicitly specified nested hidden file" ,
414+ arguments = ("\n " .join ([".config/settings.json" ]), None ),
415+ expected = [".config/settings.json" ],
416+ ),
417+ self .ExtraFilesTestDefinition (
418+ name = "combines explicit hidden files with changes file" ,
419+ arguments = ("\n " .join ([".gitignore" ]), "CHANGES.md" ),
420+ expected = [".gitignore" , "CHANGES.md" ],
421+ ),
422+ ]
423+
424+ for test_input in test_definitions :
425+ with self .subTest (test_input .name ):
426+ self .assertCountEqual (
427+ gather_additional_files (* test_input .arguments ), test_input .expected
428+ )
429+
430+
207431class TestTargetToArchiveName (unittest .TestCase ):
208432 """Test cases for target_to_archive_name function."""
209433
0 commit comments