Quick code dump: if you download all your "report types" with Eclipse, this can help you put together "fresh" copies of them for use with the Workbench Deploy web site (just zip them up). Very handy if you just created a custom field on an object that's involved in dozens of report types.
This is an example of why it's good to be able to work with XML, too, in Python!
Hopefully I can clean up (or delete & replace) this post at a later date.
import os from xml.etree import ElementTree import xml.dom.minidom def stripNSFromET(etRoot): etRoot.tag = etRoot.tag.split('}', 1)[1] # strip all namespaces level 1 for el in etRoot: if '}' in el.tag: el.tag = el.tag.split('}', 1)[1] # strip all namespaces level 2 for el3 in el: if '}' in el3.tag: el3.tag = el3.tag.split('}', 1)[1] # strip all namespaces level 3 for el4 in el3: if '}' in el4.tag: el4.tag = el4.tag.split('}', 1)[1] # strip all namespaces level 4 for el5 in el4: if '}' in el5.tag: el5.tag = el5.tag.split('}', 1)[1] # strip all namespaces level 5 return etRoot def isTableOfInterest(tableTagText, fieldsDatabaseTableAPIName): if fieldsDatabaseTableAPIName == 'Contact': return tableTagText == 'Contact' or tableTagText.endswith('.Contacts') or tableTagText.endswith('OpportunityContactRoles') else: return tableTagText == fieldsDatabaseTableAPIName or tableTagText.endswith('.'+fieldsDatabaseTableAPIName) outpstr = 'C:\\example\\temppackagereporttypes\\' pstr = 'C:\\examplepath\\EclipseWorkspace\\orgfolder\\src\\reportTypes\\' fstr = 'C:\\examplepath\\EclipseWorkspace\\orgfolder\\src\\reportTypes\\Contact_With_Campaign_Members.reportType' metadataAPIVer = '40.0' fieldsDBTableAPIName = 'Contact' fieldAPINames = ['New_Field_1__c','New_Field_2__c'] fieldAPINames.sort() # SOME NOTES OF INTEREST: # Contact is not only referred to in the normal way, but also often as OpportunityContactRole coming off an "Opportunity." Not sure if ALL such OCRs are actually able to take Contact fields. # This could make it a bit difficult to decide into what "Section," exactly, to add a new database field when adding it to every ReportType: # A given "Section" tag within a ReportType's XML file is NOT limited to containing "Columns" elements with the same database "Table" value. For example, Graduate_Admissions.reportType, with a base object of Contact, has a section named "Recruit data" with fields from both the "Contact.Program_Interests__r" and "Contact" tables in it. # A given database "Table" value can appear in multiple "Section" tags within a ReportType's XML file (e.g. "Contact" fields could be spread across multiple sections). # CampaignMember often ends up repeated -- once as a top-level, once below itself -- in a ReportType where it is included (with fields duplicated and everything). Good thing I'm not yet adding any CampaignMember fields to reports. # SOME CODE TO REMEMBER: # The number of tables mentioned in a ReportType file: len({e.text for e in root.findall('./sections/columns/table')}) # The actual tables mentioned in a ReportType file: {e.text for e in root.findall('./sections/columns/table')} # The number of "sections" in a ReportType file: len(root.findall('./sections')) # The labels of the actual "sections" in a ReportType file: {e.find('masterLabel').text for e in root.findall('./sections')} # Iterating over a dict: for k, v in d.items(): """ Algorithm per ReportType file, parsed into an XML-ElementTree node called "root": - Figure out how many 1st-level nodes called "sections" there are. - For each 1st-level "section" node, if it's "of interest," set it aside as a key to a dict called "d", and a list of all its relevant child 2nd-level "columns" nodes as the values. - Loop through "d" and set aside any keys that have the most list-items as a value, of all keys in "d"; set aside a list of any such keys as "topSections" - If there was just 1 such "top section" key, set that 1st-level "section" node aside as the variable "se" (This is, visually to the end user, where we'll be adding the new field.) - If there was more than 1, arbitrarily pick 1 and set that 1st-level "section" node aside as the variable "se" - Once we've picked a 1st-level "section" node as "se," cache a dict "uniqueTableNames" of the distinct words appearing among its grandchild (3rd-level) "table" tag values as "key" and the count-of-table-tag-per-distinct-word as "value." - Presuming there were any (I guess this serves as a sort of dummy-check for the section), instantiate a new ElementTree "columns" node called "newColumn," append "newColumn" to "se" (make it 2nd-level) and flesh out "newColumn" with details of the field we want to add. (When fleshing it out, we arbitrarily pick a value for the 3rd-level "table" tag if "uniqueTableNames" had several keys.) """ changedThese = [] pkgroot = None for i in os.listdir(pstr): root = None if not i.startswith('wrt_'): with open(pstr+i, 'r', encoding='utf-8') as f: # Open a ReportType metadata XML file root = stripNSFromET(ElementTree.parse(f).getroot()) # Store a reference to the root of the XML file currently open in a variable d = {} allFieldsForTableOfInterestColsInAllSectionsOfInterest = [] for sec in root.findall('./sections'): columnsOfInterest = [e for e in sec.findall('columns/table/..') if isTableOfInterest(e.find('table').text, fieldsDBTableAPIName)] if len(columnsOfInterest) > 0: d[sec] = columnsOfInterest # Add to "d" any "section" and a list of applicable "columns" inside it if len(d) < 1: continue # This file is not of interest if nothing got added to "d" (if it's not a ReportType that includes any fields of the object of interest) -- move on to next file else: print('\r\n' + i + ', baseObject: ' + root.find('./baseObject').text) # Display which file we are working with and what its "Base Object" is se = None if len(d) > 0: # Why did I have this at ">1" when I found it 6 months later? Should it be >0? allFieldsForTableOfInterestColsInAllSectionsOfInterest = [item.find('field').text for sublist in d.values() for item in sublist] if all(fldNm in allFieldsForTableOfInterestColsInAllSectionsOfInterest for fldNm in fieldAPINames): continue # This file is not of interest if all fields are already in it topSections = {k for k, v in d.items() if len(v) == max([len(arr) for arr in d.values()])} if len(topSections) >= 1: if len(topSections) == 1: se = (next(iter(topSections))) # There was only 1 top-ranked section -- pick it else: topSecsWithLabelLikeTableNewFieldIsFrom = [s for s in topSections if s.find('masterLabel').text in [fieldsDBTableAPIName, fieldsDBTableAPIName+'s']] if len(topSecsWithLabelLikeTableNewFieldIsFrom) > 0: se = (next(iter(topSecsWithLabelLikeTableNewFieldIsFrom))) # I don't really care which it is, honestly else: se = (next(iter(topSections))) # I tried my best -- moving on. Just picking a section. else: se = (next(iter(d.keys()))) # It was a 1-section file -- just pick the one section if se: uniqueTableNames = {tbstr : [e.text for e in se.findall('columns/table')].count(tbstr) for tbstr in [e.text for e in se.findall('columns/table')]} if len(uniqueTableNames) >= 1: # We can just tack our new column onto the only section that already has other Contact values changedAnything = False for fieldAPIName in fieldAPINames: if fieldAPIName not in allFieldsForTableOfInterestColsInAllSectionsOfInterest: changedAnything = True newColumn = ElementTree.Element('columns') se.append(newColumn) newColumn.append(ElementTree.Element('checkedByDefault')) newColumn.find('checkedByDefault').text = 'false' newColumn.append(ElementTree.Element('field')) newColumn.find('field').text = fieldAPIName newColumn.append(ElementTree.Element('table')) if len(uniqueTableNames) == 1: newColumn.find('table').text = next(iter(uniqueTableNames)) elif len(uniqueTableNames) > 1: newColumn.find('table').text = max(uniqueTableNames, key=uniqueTableNames.get) if changedAnything: se[:] = sorted(se, key=lambda x: x.tag) # Put masterLabel tag back at the end of the section. We don't need to re-sort the fields because we might screw up where people were expecting to see them, if they weren't yet in alphabetical order. root.set('xmlns','http://soap.sforce.com/2006/04/metadata') if not os.path.exists(outpstr): os.makedirs(outpstr) if not os.path.exists(outpstr+'reportTypes'): os.makedirs(outpstr+'reportTypes') with open(outpstr+'reportTypes\\'+i, 'w', newline='') as fw: dom_string = xml.dom.minidom.parseString(ElementTree.tostring(root)).toprettyxml(encoding='UTF-8', indent=' ') dom_string = '\n'.join([s for s in dom_string.decode('UTF-8').splitlines() if s.strip()]) + '\n' fw.write(dom_string) changedThese.append(i[:-11]) if len(changedThese) > 0: pkgroot = ElementTree.Element('Package', attrib={'xmlns':'http://soap.sforce.com/2006/04/metadata'}) typesElem = ElementTree.Element('types') for x in changedThese: membersElem = ElementTree.Element('members') membersElem.text = x typesElem.append(membersElem) namesElem = ElementTree.Element('name') namesElem.text = 'ReportType' typesElem.append(namesElem) pkgroot.append(typesElem) verElem = ElementTree.Element('version') verElem.text = metadataAPIVer pkgroot.append(verElem) with open(outpstr+'package.xml', 'w', newline='') as fw: dom_string = xml.dom.minidom.parseString(ElementTree.tostring(pkgroot)).toprettyxml(encoding='UTF-8', indent=' ') dom_string = '\n'.join([s for s in dom_string.decode('UTF-8').splitlines() if s.strip()]) + '\n' fw.write(dom_string) print('all done')