1717import shutil
1818import subprocess
1919import tempfile
20+ import networkx as nx
21+ from collections import defaultdict
2022
2123BLACK_COMMAND = "black --line-length=80"
2224CLASS_NAME_REGEX = r"vn\d+(_t\d+\w*)?"
@@ -262,9 +264,12 @@ def __init__(self, tag, cname, version, apps, core, jules):
262264 self .version = re .search (r".*vn(\d+\.\d+)(_.*)?" , tag ).group (1 )
263265 else :
264266 self .version = version
265- self .ticket_number = None
266267 self .author = None
267- self .parsed_macros = {}
268+ self .ticket_number = None
269+ # All parsed macros per metadata section
270+ self .parsed_macros = defaultdict (list )
271+ # Parsed macro with desired after tag, per metadata section
272+ self .target_macros = {}
268273 self .meta_dirs = set ()
269274 self .sections_with_macro = []
270275 self .python_imports = set ()
@@ -439,29 +444,33 @@ def parse_macro(self, macro, meta_dir):
439444 - macro, an upgrade macro that matches the class name we are
440445 looking for
441446 - meta_dir, the path to the rose metadata directory
447+ Returns:
448+ - dictionary of the parsed macro, containing before tag, after tag and
449+ commands
442450 """
443451
444452 version_file = os .path .join (meta_dir , "versions.py" )
445453
446- # The ticket number and author will always be the same across all
447- # macros for this ticket, so only grab these once
448- # These are not vital so don't fail if not found
449- if self . ticket_number is None or self . author is None :
450- ticket_details = re . search ( r"Upgrade .* (#\d+) by (\S+.*)" , macro )
451- try :
452- self . ticket_number = ticket_details . group ( 1 )
453- self . author = ticket_details . group ( 2 ). rstrip ( '".' )
454- self . author = self . author . strip ( "<>" )
455- except AttributeError :
456- pass
454+ ticket_details = re . search ( r"Upgrade .* (#\d+) by (\S+.*)" , macro )
455+ try :
456+ ticket_number = ticket_details . group ( 1 )
457+ author = ticket_details . group ( 2 ). rstrip ( '".' )
458+ author = author . strip ( "<>" )
459+ except AttributeError :
460+ ticket_number = "TTTT"
461+ author = "Unknown"
462+ pass
463+
464+ class_name = re . search ( r"class (vn\d+_t\d+)" , macro ). group ( 1 )
457465
458466 # Search for the before tag
459467 # Raise an exception if these are missing
460468 try :
461469 before_tag = re .search (rf"BEFORE_TAG{ TAG_REGEX } " , macro ).group (1 )
470+ after_tag = re .search (rf"AFTER_TAG{ TAG_REGEX } " , macro ).group (1 )
462471 except AttributeError as exc :
463472 raise Exception (
464- "Couldn't find a Before tag for the requested "
473+ "Couldn't find a Before/After tag for the requested "
465474 f"macro in the file { version_file } "
466475 ) from exc
467476
@@ -486,9 +495,13 @@ def parse_macro(self, macro, meta_dir):
486495 commands += line + "\n "
487496
488497 # Record macro details
489- self . parsed_macros [ meta_dir ] = {
498+ return {
490499 "before_tag" : before_tag ,
500+ "after_tag" : after_tag ,
491501 "commands" : commands ,
502+ "ticket_number" : ticket_number ,
503+ "author" : author ,
504+ "class_name" : class_name ,
492505 }
493506
494507 def remove_macro (self , contents , meta_dir ):
@@ -689,6 +702,9 @@ def write_python_imports(self, meta_dir):
689702 imp_str = f"from { '.' .join (m for m in mod )} { imp_str } "
690703 if alias :
691704 imp_str += f" as { alias } "
705+ # Don't add versionXX_YY import statements
706+ if re .match (r"from \.?version\d+_\d+ import \*" , imp_str ):
707+ continue
692708 versions_file .insert (insertion_index , imp_str )
693709
694710 with open (temppath , "w" ) as f :
@@ -700,7 +716,7 @@ def write_python_imports(self, meta_dir):
700716 def determine_import_order (self , app ):
701717 """
702718 Work out what order metadata is imported. This recursively works through
703- import statements recorded in self.parsed_macros ["imports"]. Produces a
719+ import statements recorded in self.target_macros ["imports"]. Produces a
704720 list of the order in which macro commands should be applied (this should
705721 be the same order as the imports)
706722 Inputs:
@@ -719,7 +735,7 @@ def determine_import_order(self, app):
719735 import_list = [app_name ]
720736
721737 try :
722- imports = self .parsed_macros [app ]["imports" ]
738+ imports = self .target_macros [app ]["imports" ]
723739 except KeyError :
724740 # Jules Shared directories will produce a key error - these are
725741 # guaranteed to not import anything
@@ -744,41 +760,51 @@ def combine_macros(self, import_order):
744760 for meta_import in import_order :
745761 meta_import = self .get_full_import_path (meta_import )
746762 if (
747- meta_import in self .parsed_macros
748- and self .parsed_macros [meta_import ][ "commands" ]
763+ meta_import in self .target_macros
764+ and "commands" in self .target_macros [meta_import ]
749765 ):
750766 # Add a comment labelling where these commands came from
751767 full_command += (
752768 " # Commands From: "
753769 f"{ self .parse_application_section (meta_import )} \n "
754770 )
755- if self .parsed_macros [meta_import ]["commands" ].strip ("\n " ):
756- full_command += self .parsed_macros [meta_import ]["commands" ] + "\n "
771+ if self .target_macros [meta_import ]["commands" ].strip ("\n " ):
772+ full_command += self .target_macros [meta_import ]["commands" ] + "\n "
757773 else :
758774 full_command += " # Blank Upgrade Macro\n "
775+
776+ if self .target_macros [meta_import ]["author" ] != "Unknown" :
777+ self .author = self .target_macros [meta_import ]["author" ]
778+ if self .target_macros [meta_import ]["ticket_number" ] != "TTTT" :
779+ self .ticket_number = self .target_macros [meta_import ][
780+ "ticket_number"
781+ ]
759782 return full_command
760783
761- def write_new_macro (self , meta_dir , full_command ):
784+ def write_new_macro (self , meta_dir , full_command , macro ):
762785 """
763786 Write out the new macro with all relevant commands to the versions.py
764787 file
765788 Inputs:
766789 - meta_dir, path to the metadata directory with a versions.py file
767790 - full_command, str of the combined macro commands
791+ - macro, the parsed macro being written
768792 """
769793
770- parsed_macro = self .parsed_macros [meta_dir ]
771794 filepath = os .path .join (meta_dir , "versions.py" )
772795 temppath = os .path .join (meta_dir , ".versions.py" )
773796 shutil .copy (filepath , temppath )
774797
798+ author = macro ["author" ] or self .author
799+ ticket_number = macro ["ticket_number" ] or self .ticket_number
800+
775801 with open (temppath , "a" ) as f :
776802 f .write (
777- f" class { self . class_name } (MacroUpgrade):\n "
778- f' """Upgrade macro for ticket { self . ticket_number } '
779- f'by { self . author } ."""\n \n '
780- f' BEFORE_TAG = "{ parsed_macro ["before_tag" ]} "\n '
781- f' AFTER_TAG = "{ self . tag } "\n \n '
803+ f' class { macro [ " class_name" ] } (MacroUpgrade):\n '
804+ f' """Upgrade macro for ticket { ticket_number } '
805+ f'by { author } ."""\n \n '
806+ f' BEFORE_TAG = "{ macro ["before_tag" ]} "\n '
807+ f' AFTER_TAG = "{ macro [ "after_tag" ] } "\n \n '
782808 " def upgrade(self, config, meta_config=None):\n "
783809 f"{ full_command } " # this variable contains required whitespace
784810 " return config, self.reports\n "
@@ -788,6 +814,140 @@ def write_new_macro(self, meta_dir, full_command):
788814
789815 os .rename (temppath , filepath )
790816
817+ def check_missing_macros (self , meta_dir , meta_imports ):
818+ """
819+ Check through macros of imported metadata sections, returning list of any that
820+ aren't in the current section (identified by the after tag)
821+ Inputs:
822+ - meta_dir, the current metadata section
823+ - meta_imports, a list of imported metadata sections
824+ Returns:
825+ - list of macros that need adding to the current section
826+ """
827+
828+ after_tags = [m ["after_tag" ] for m in self .parsed_macros [meta_dir ]]
829+
830+ missing_macros = []
831+ for section in meta_imports :
832+ section = self .get_full_import_path (section )
833+ section_missing = []
834+ len_section_macros = len (self .parsed_macros [section ])
835+ for macro in self .parsed_macros [section ]:
836+ after_tag = macro ["after_tag" ]
837+ # Ignore the macro being upgraded - this is expected to be missing
838+ if after_tag == self .tag :
839+ len_section_macros -= 1
840+ continue
841+ if after_tag not in after_tags :
842+ section_missing .append (after_tag )
843+ # Check that if macros are missing ALL of them are missing (this is the
844+ # case that a new metadata section has been added)
845+ # Otherwise raise an error as the macro chain is broken
846+ if section_missing and len_section_macros != len (section_missing ):
847+ raise RuntimeError (
848+ f"The versions.py file for section { meta_dir } is missing macros "
849+ "from inherited metadata sections. This suggests something has "
850+ "gone wrong in the macro chain and should be investigated."
851+ )
852+ for after_tag in section_missing :
853+ if after_tag not in missing_macros :
854+ missing_macros .append (after_tag )
855+
856+ return missing_macros
857+
858+ def combine_missing_macros (self , meta_imports , missing_macros ):
859+ """
860+ Combine missing macro commands
861+ Inputs:
862+ - meta_imports, a list of imported metadata sections
863+ - missing_macros, a list of after tags missing
864+ Returns:
865+ - dictionary of parsed macros with corrected before tag. Key is after tag
866+ """
867+
868+ new_macros = {}
869+
870+ # Loop over all metadata imports
871+ for meta_import in meta_imports :
872+ # For each missing after tag check whether it exists in this imported sect
873+ for after_tag in missing_macros :
874+ macro = None
875+ for m in self .parsed_macros [meta_import ]:
876+ if m ["after_tag" ] == after_tag :
877+ macro = m
878+ break
879+ # if the macro exists then save it
880+ if macro :
881+ # if the macro not already saved, that's all that's required
882+ if after_tag not in new_macros :
883+ new_macros [after_tag ] = macro
884+ continue
885+ # if the macro is already saved, then combine macros
886+ existing = new_macros [after_tag ]
887+ existing ["commands" ] += macro ["commands" ]
888+ # if the before tags are the same, we don't need to modify the chain
889+ if existing ["before_tag" ] == macro ["before_tag" ]:
890+ continue
891+ # if the existing before tag is in the current metadata macro chain
892+ # then we want to use the new before tag
893+ for item in self .parsed_macros [meta_import ]:
894+ if item ["before_tag" ] == existing ["before_tag" ]:
895+ existing ["before_tag" ] = macro ["before_tag" ]
896+
897+ return new_macros
898+
899+ def fix_missing_macros (self , meta_dir , meta_imports ):
900+ """
901+ Function to handle checking and fixing of missing upgrade macros
902+ Inputs:
903+ - meta_dir, the current metadata section
904+ - meta_imports, a list of imported metadata sections
905+ Returns:
906+ - the final after tag in the newly written macro chain if macros are
907+ missing, otherwise None
908+ """
909+
910+ missing_macros = self .check_missing_macros (meta_dir , meta_imports )
911+
912+ if missing_macros :
913+ print (
914+ "[INFO] Writing missing macros to" ,
915+ self .parse_application_section (meta_dir ),
916+ )
917+ macros = self .combine_missing_macros (meta_imports , missing_macros )
918+ # Record the identified missing macros for this metadata section
919+ self .parsed_macros [meta_dir ] = [m for m in macros .values ()]
920+ macro_strings = []
921+ for macro in macros .values ():
922+ self .write_new_macro (meta_dir , macro ["commands" ], macro )
923+ macro_strings .append (
924+ f"BEFORE_TAG = '{ macro ['before_tag' ]} '\n "
925+ f"AFTER_TAG = '{ macro ['after_tag' ]} '"
926+ )
927+ self .parsed_macros [meta_dir ].insert (0 , macro )
928+ return self .find_last_macro (macro_strings , meta_dir )
929+
930+ return None
931+
932+ def order_meta_dirs (self ):
933+ """
934+ Order the self.meta_dirs list by metadata import order, such that sections
935+ higher up the import tree come first
936+ Create a networkx ordered graph, with nodes as the import tree and edges as the
937+ import statements. Then recreate list from this
938+ """
939+
940+ import_graph = nx .DiGraph ()
941+
942+ for meta_dir in self .meta_dirs :
943+ import_graph .add_node (meta_dir )
944+ for imp in self .target_macros [meta_dir ]["imports" ]:
945+ import_graph .add_edge (imp , meta_dir )
946+
947+ # Return an ordered list of nodes. This requires non-circular edges, but this is
948+ # guaranteed for valid rose metadata
949+ return list (nx .topological_sort (import_graph ))
950+
791951 def preprocess_macros (self ):
792952 """
793953 Overarching function to pre-process added macros
@@ -817,27 +977,35 @@ def preprocess_macros(self):
817977 # info and delete the macro from the file
818978 parsed_versions = read_versions_file (meta_dir )
819979 macros = split_macros (parsed_versions )
980+
981+ # Record all macros in this metadata section
982+ for macro in macros :
983+ self .parsed_macros [meta_dir ].append (self .parse_macro (macro , meta_dir ))
984+
985+ # Check if target macro exists in this section
820986 found_macro = self .find_macro (meta_dir , macros )
821987 if not found_macro :
822988 # If we reach here then the new macro hasn't been added to
823989 # this versions file - in this case work out the final after
824- # tag in the chain - if we import other commands for this
990+ # tag in the chain - if we import other commands for this
825991 # versions file, this final after tag will be the before tag of
826992 # that new macro.
827993 last_after_tag = self .find_last_macro (macros , meta_dir )
828- self .parsed_macros [meta_dir ] = {
994+ self .target_macros [meta_dir ] = {
829995 "before_tag" : last_after_tag ,
830- "commands " : "" ,
996+ "after_tag " : self . tag ,
831997 "imports" : "" ,
998+ "class_name" : self .class_name ,
999+ "author" : None ,
1000+ "ticket_number" : None ,
8321001 }
8331002 else :
834- self .parse_macro (found_macro , meta_dir )
1003+ self .target_macros [ meta_dir ] = self . parse_macro (found_macro , meta_dir )
8351004 # Remove the macro from the file
8361005 self .remove_macro (parsed_versions , meta_dir )
8371006
838- # Read through rose-meta files for import statements
839- # of other metadata
840- self .parsed_macros [meta_dir ]["imports" ] = self .read_meta_imports (meta_dir )
1007+ # Read through rose-meta files for import statements of other metadata
1008+ self .target_macros [meta_dir ]["imports" ] = self .read_meta_imports (meta_dir )
8411009
8421010 # Read through the versions.py file for python import statements
8431011 self .python_imports .update (
@@ -846,18 +1014,33 @@ def preprocess_macros(self):
8461014
8471015 # Now reconstruct the macro for all applications which have the newly
8481016 # added macro or import metadata with the new macro
849- for meta_dir in self .meta_dirs :
1017+ # The macro sections need to be processed in the order of import
1018+ for meta_dir in self .order_meta_dirs ():
8501019 import_order = self .determine_import_order (meta_dir )
8511020 full_command = self .combine_macros (import_order )
1021+
8521022 # If there are commands to write out, do so and record this
8531023 # application as having the macro
8541024 if full_command :
1025+ # Check if there are any macros in imported metadata versions.py files
1026+ # that aren't in the current section.
1027+ # If there are, then combine these and write them out first
1028+ last_after_tag = None
1029+ last_after_tag = self .fix_missing_macros (
1030+ meta_dir , self .target_macros [meta_dir ]["imports" ]
1031+ )
1032+
1033+ if last_after_tag :
1034+ self .target_macros [meta_dir ]["before_tag" ] = last_after_tag
1035+
8551036 print (
8561037 "[INFO] Writing macros to" ,
8571038 self .parse_application_section (meta_dir ),
8581039 )
8591040 self .write_python_imports (meta_dir )
860- self .write_new_macro (meta_dir , full_command )
1041+ self .write_new_macro (
1042+ meta_dir , full_command , self .target_macros [meta_dir ]
1043+ )
8611044 self .sections_with_macro .append (meta_dir )
8621045
8631046 ############################################################################
0 commit comments