@@ -218,6 +218,26 @@ def __init__(self, filename=None):
218218 area .tags = area_dict .get ("tags" , [])
219219 area .description = area_dict .get ("description" )
220220
221+ # Initialize file groups if present
222+ area .file_groups = []
223+ if "file-groups" in area_dict :
224+ for group_dict in area_dict ["file-groups" ]:
225+ file_group = FileGroup ()
226+ file_group .name = group_dict .get ("name" , "Unnamed Group" )
227+ file_group .description = group_dict .get ("description" )
228+ file_group .collaborators = group_dict .get ("collaborators" , [])
229+
230+ # Create match functions for this file group
231+ file_group ._match_fn = \
232+ _get_match_fn (group_dict .get ("files" ),
233+ group_dict .get ("files-regex" ))
234+
235+ file_group ._exclude_match_fn = \
236+ _get_match_fn (group_dict .get ("files-exclude" ),
237+ group_dict .get ("files-regex-exclude" ))
238+
239+ area .file_groups .append (file_group )
240+
221241 # area._match_fn(path) tests if the path matches files and/or
222242 # files-regex
223243 area ._match_fn = \
@@ -260,6 +280,32 @@ def path2areas(self, path):
260280 return [area for area in self .areas .values ()
261281 if area ._contains (path )]
262282
283+ def path2area_info (self , path ):
284+ """
285+ Returns a list of tuples (Area, FileGroup) for the areas that contain 'path'.
286+ FileGroup will be None if the path matches the area's general files rather
287+ than a specific file group.
288+ """
289+ areas = self .path2areas (path )
290+ result = []
291+
292+ # Make directory paths end in '/' so that foo/bar matches foo/bar/.
293+ is_dir = os .path .isdir (path )
294+
295+ # Make 'path' relative to the repository root and normalize it.
296+ path = os .path .normpath (os .path .join (
297+ os .path .relpath (os .getcwd (), self ._toplevel ),
298+ path ))
299+
300+ if is_dir :
301+ path += "/"
302+
303+ for area in areas :
304+ file_group = area .get_file_group_for_path (path )
305+ result .append ((area , file_group ))
306+
307+ return result
308+
263309 def commits2areas (self , commits ):
264310 """
265311 Returns a set() of Area instances for the areas that contain files that
@@ -420,6 +466,30 @@ def _orphaned_cmd(self, args):
420466 print (path ) # We get here if we never hit the 'break'
421467
422468
469+ class FileGroup :
470+ """
471+ Represents a file group within an area in MAINTAINERS.yml.
472+
473+ These attributes are available:
474+
475+ name:
476+ The name of the file group, as specified in the 'name' key
477+
478+ description:
479+ Text from 'description' key, or None if the group has no 'description'
480+
481+ collaborators:
482+ List of collaborators specific to this file group
483+ """
484+ def _contains (self , path ):
485+ # Returns True if the file group contains 'path', and False otherwise
486+ return self ._match_fn and self ._match_fn (path ) and not \
487+ (self ._exclude_match_fn and self ._exclude_match_fn (path ))
488+
489+ def __repr__ (self ):
490+ return "<FileGroup {}>" .format (self .name )
491+
492+
423493class Area :
424494 """
425495 Represents an entry for an area in MAINTAINERS.yml.
@@ -447,13 +517,46 @@ class Area:
447517 description:
448518 Text from 'description' key, or None if the area has no 'description'
449519 key
520+
521+ file_groups:
522+ List of FileGroup instances for any file-groups defined in the area.
523+ Empty if the area has no 'file-groups' key.
450524 """
451525 def _contains (self , path ):
452526 # Returns True if the area contains 'path', and False otherwise
527+ # First check if path matches any file groups - they take precedence
528+ for file_group in self .file_groups :
529+ if file_group ._contains (path ):
530+ return True
453531
532+ # If no file group matches, check area-level patterns
454533 return self ._match_fn and self ._match_fn (path ) and not \
455534 (self ._exclude_match_fn and self ._exclude_match_fn (path ))
456535
536+ def get_collaborators_for_path (self , path ):
537+ """
538+ Returns a list of collaborators for a specific path.
539+ If the path matches a file group, returns the file group's collaborators.
540+ Otherwise, returns the area's general collaborators.
541+ """
542+ # Check file groups first
543+ for file_group in self .file_groups :
544+ if file_group ._contains (path ):
545+ return file_group .collaborators
546+
547+ # Return general area collaborators if no file group matches
548+ return self .collaborators
549+
550+ def get_file_group_for_path (self , path ):
551+ """
552+ Returns the FileGroup instance that contains the given path,
553+ or None if the path doesn't match any file group.
554+ """
555+ for file_group in self .file_groups :
556+ if file_group ._contains (path ):
557+ return file_group
558+ return None
559+
457560 def __repr__ (self ):
458561 return "<Area {}>" .format (self .name )
459562
@@ -484,6 +587,17 @@ def _print_areas(areas):
484587 ", " .join (area .tags ),
485588 area .description or "" ))
486589
590+ # Print file groups if any exist
591+ if area .file_groups :
592+ print ("\t file-groups:" )
593+ for file_group in area .file_groups :
594+ print ("\t \t {}: {}" .format (
595+ file_group .name ,
596+ ", " .join (file_group .collaborators ) if file_group .collaborators else "no collaborators"
597+ ))
598+ if file_group .description :
599+ print ("\t \t description: {}" .format (file_group .description ))
600+
487601
488602def _get_match_fn (globs , regexes ):
489603 # Constructs a single regex that tests for matches against the globs in
@@ -552,7 +666,7 @@ def ferr(msg):
552666
553667 ok_keys = {"status" , "maintainers" , "collaborators" , "inform" , "files" ,
554668 "files-exclude" , "files-regex" , "files-regex-exclude" ,
555- "labels" , "description" , "tests" , "tags" }
669+ "labels" , "description" , "tests" , "tags" , "file-groups" }
556670
557671 ok_status = {"maintained" , "odd fixes" , "unmaintained" , "obsolete" }
558672 ok_status_s = ", " .join ('"' + s + '"' for s in ok_status ) # For messages
@@ -572,8 +686,8 @@ def ferr(msg):
572686 ferr ("bad 'status' key on area '{}', should be one of {}"
573687 .format (area_name , ok_status_s ))
574688
575- if not area_dict .keys () & {"files" , "files-regex" }:
576- ferr ("either 'files' or 'files-regex' (or both ) must be specified "
689+ if not area_dict .keys () & {"files" , "files-regex" , "file-groups" }:
690+ ferr ("either 'files', 'files-regex', or 'file-groups' (or combinations ) must be specified "
577691 "for area '{}'" .format (area_name ))
578692
579693 if not area_dict .get ("maintainers" ) and area_dict .get ("status" ) == "maintained" :
@@ -617,6 +731,64 @@ def ferr(msg):
617731 "'{}': {}" .format (regex , files_regex_key ,
618732 area_name , e .msg ))
619733
734+ # Validate file-groups structure
735+ if "file-groups" in area_dict :
736+ file_groups = area_dict ["file-groups" ]
737+ if not isinstance (file_groups , list ):
738+ ferr ("malformed 'file-groups' value for area '{}' -- should be a list"
739+ .format (area_name ))
740+
741+ ok_group_keys = {"name" , "description" , "collaborators" , "files" ,
742+ "files-exclude" , "files-regex" , "files-regex-exclude" }
743+
744+ for i , group_dict in enumerate (file_groups ):
745+ if not isinstance (group_dict , dict ):
746+ ferr ("malformed file group {} in area '{}' -- should be a dict"
747+ .format (i , area_name ))
748+
749+ for key in group_dict :
750+ if key not in ok_group_keys :
751+ ferr ("unknown key '{}' in file group {} in area '{}'"
752+ .format (key , i , area_name ))
753+
754+ # Each file group must have either files or files-regex
755+ if not group_dict .keys () & {"files" , "files-regex" }:
756+ ferr ("file group {} in area '{}' must specify either 'files' or 'files-regex'"
757+ .format (i , area_name ))
758+
759+ # Validate string fields in file groups
760+ for str_field in ["name" , "description" ]:
761+ if str_field in group_dict and not isinstance (group_dict [str_field ], str ):
762+ ferr ("malformed '{}' in file group {} in area '{}' -- should be a string"
763+ .format (str_field , i , area_name ))
764+
765+ # Validate list fields in file groups
766+ for list_field in ["collaborators" , "files" , "files-exclude" , "files-regex" , "files-regex-exclude" ]:
767+ if list_field in group_dict :
768+ lst = group_dict [list_field ]
769+ if not (isinstance (lst , list ) and all (isinstance (elm , str ) for elm in lst )):
770+ ferr ("malformed '{}' in file group {} in area '{}' -- should be a list of strings"
771+ .format (list_field , i , area_name ))
772+
773+ # Validate file patterns in file groups
774+ for files_key in "files" , "files-exclude" :
775+ if files_key in group_dict :
776+ for glob_pattern in group_dict [files_key ]:
777+ paths = tuple (root .glob (glob_pattern ))
778+ if not paths :
779+ ferr ("glob pattern '{}' in '{}' in file group {} in area '{}' does not "
780+ "match any files" .format (glob_pattern , files_key , i , area_name ))
781+
782+ # Validate regex patterns in file groups
783+ for files_regex_key in "files-regex" , "files-regex-exclude" :
784+ if files_regex_key in group_dict :
785+ for regex in group_dict [files_regex_key ]:
786+ try :
787+ re .compile (regex )
788+ except re .error as e :
789+ ferr ("bad regular expression '{}' in '{}' in file group {} in area '{}': {}"
790+ .format (regex , files_regex_key , i , area_name , e .msg ))
791+
620792 if "description" in area_dict and \
621793 not isinstance (area_dict ["description" ], str ):
622794 ferr ("malformed 'description' value for area '{}' -- should be a "
0 commit comments