1# Copyright 2016 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import argparse 6import codecs 7import plistlib 8import os 9import re 10import subprocess 11import sys 12import tempfile 13import shlex 14 15# Xcode substitutes variables like ${PRODUCT_NAME} or $(PRODUCT_NAME) when 16# compiling Info.plist. It also supports supports modifiers like :identifier 17# or :rfc1034identifier. SUBSTITUTION_REGEXP_LIST is a list of regular 18# expressions matching a variable substitution pattern with an optional 19# modifier, while INVALID_CHARACTER_REGEXP matches all characters that are 20# not valid in an "identifier" value (used when applying the modifier). 21INVALID_CHARACTER_REGEXP = re.compile(r'[_/\s]') 22SUBSTITUTION_REGEXP_LIST = ( 23 re.compile(r'\$\{(?P<id>[^}]*?)(?P<modifier>:[^}]*)?\}'), 24 re.compile(r'\$\((?P<id>[^}]*?)(?P<modifier>:[^}]*)?\)'), 25) 26 27 28class SubstitutionError(Exception): 29 def __init__(self, key): 30 super(SubstitutionError, self).__init__() 31 self.key = key 32 33 def __str__(self): 34 return "SubstitutionError: {}".format(self.key) 35 36 37def InterpolateString(value, substitutions): 38 """Interpolates variable references into |value| using |substitutions|. 39 40 Inputs: 41 value: a string 42 substitutions: a mapping of variable names to values 43 44 Returns: 45 A new string with all variables references ${VARIABLES} replaced by their 46 value in |substitutions|. Raises SubstitutionError if a variable has no 47 substitution. 48 """ 49 50 def repl(match): 51 variable = match.group('id') 52 if variable not in substitutions: 53 raise SubstitutionError(variable) 54 # Some values need to be identifier and thus the variables references may 55 # contains :modifier attributes to indicate how they should be converted 56 # to identifiers ("identifier" replaces all invalid characters by '_' and 57 # "rfc1034identifier" replaces them by "-" to make valid URI too). 58 modifier = match.group('modifier') 59 if modifier == ':identifier': 60 return INVALID_CHARACTER_REGEXP.sub('_', substitutions[variable]) 61 elif modifier == ':rfc1034identifier': 62 return INVALID_CHARACTER_REGEXP.sub('-', substitutions[variable]) 63 else: 64 return substitutions[variable] 65 66 for substitution_regexp in SUBSTITUTION_REGEXP_LIST: 67 value = substitution_regexp.sub(repl, value) 68 return value 69 70 71def Interpolate(value, substitutions): 72 """Interpolates variable references into |value| using |substitutions|. 73 74 Inputs: 75 value: a value, can be a dictionary, list, string or other 76 substitutions: a mapping of variable names to values 77 78 Returns: 79 A new value with all variables references ${VARIABLES} replaced by their 80 value in |substitutions|. Raises SubstitutionError if a variable has no 81 substitution. 82 """ 83 if isinstance(value, dict): 84 return {k: Interpolate(v, substitutions) for k, v in value.items()} 85 if isinstance(value, list): 86 return [Interpolate(v, substitutions) for v in value] 87 if isinstance(value, str): 88 return InterpolateString(value, substitutions) 89 return value 90 91 92def LoadPList(path): 93 """Loads Plist at |path| and returns it as a dictionary.""" 94 with open(path, 'rb') as f: 95 return plistlib.load(f) 96 97 98def SavePList(path, format, data): 99 """Saves |data| as a Plist to |path| in the specified |format|.""" 100 # The open() call does not replace the destination file but updates it 101 # in place, so if more than one hardlink points to destination all of them 102 # will be modified. This is not what is expected, so delete destination file 103 # if it does exist. 104 try: 105 os.unlink(path) 106 except FileNotFoundError: 107 pass 108 with open(path, 'wb') as f: 109 plist_format = {'binary1': plistlib.FMT_BINARY, 'xml1': plistlib.FMT_XML} 110 plistlib.dump(data, f, fmt=plist_format[format]) 111 112 113def MergePList(plist1, plist2): 114 """Merges |plist1| with |plist2| recursively. 115 116 Creates a new dictionary representing a Property List (.plist) files by 117 merging the two dictionary |plist1| and |plist2| recursively (only for 118 dictionary values). List value will be concatenated. 119 120 Args: 121 plist1: a dictionary representing a Property List (.plist) file 122 plist2: a dictionary representing a Property List (.plist) file 123 124 Returns: 125 A new dictionary representing a Property List (.plist) file by merging 126 |plist1| with |plist2|. If any value is a dictionary, they are merged 127 recursively, otherwise |plist2| value is used. If values are list, they 128 are concatenated. 129 """ 130 result = plist1.copy() 131 for key, value in plist2.items(): 132 if isinstance(value, dict): 133 old_value = result.get(key) 134 if isinstance(old_value, dict): 135 value = MergePList(old_value, value) 136 if isinstance(value, list): 137 value = plist1.get(key, []) + plist2.get(key, []) 138 result[key] = value 139 return result 140 141 142class Action(object): 143 """Class implementing one action supported by the script.""" 144 145 @classmethod 146 def Register(cls, subparsers): 147 parser = subparsers.add_parser(cls.name, help=cls.help) 148 parser.set_defaults(func=cls._Execute) 149 cls._Register(parser) 150 151 152class MergeAction(Action): 153 """Class to merge multiple plist files.""" 154 155 name = 'merge' 156 help = 'merge multiple plist files' 157 158 @staticmethod 159 def _Register(parser): 160 parser.add_argument('-o', 161 '--output', 162 required=True, 163 help='path to the output plist file') 164 parser.add_argument('-f', 165 '--format', 166 required=True, 167 choices=('xml1', 'binary1'), 168 help='format of the plist file to generate') 169 parser.add_argument( 170 '-x', 171 '--xcode-version', 172 help='version of Xcode, ignored (can be used to force rebuild)') 173 parser.add_argument('path', nargs="+", help='path to plist files to merge') 174 175 @staticmethod 176 def _Execute(args): 177 data = {} 178 for filename in args.path: 179 data = MergePList(data, LoadPList(filename)) 180 SavePList(args.output, args.format, data) 181 182 183class SubstituteAction(Action): 184 """Class implementing the variable substitution in a plist file.""" 185 186 name = 'substitute' 187 help = 'perform pattern substitution in a plist file' 188 189 @staticmethod 190 def _Register(parser): 191 parser.add_argument('-o', 192 '--output', 193 required=True, 194 help='path to the output plist file') 195 parser.add_argument('-t', 196 '--template', 197 required=True, 198 help='path to the template file') 199 parser.add_argument('-s', 200 '--substitution', 201 action='append', 202 default=[], 203 help='substitution rule in the format key=value') 204 parser.add_argument('-f', 205 '--format', 206 required=True, 207 choices=('xml1', 'binary1'), 208 help='format of the plist file to generate') 209 parser.add_argument( 210 '-x', 211 '--xcode-version', 212 help='version of Xcode, ignored (can be used to force rebuild)') 213 214 @staticmethod 215 def _Execute(args): 216 substitutions = {} 217 for substitution in args.substitution: 218 key, value = substitution.split('=', 1) 219 substitutions[key] = value 220 data = Interpolate(LoadPList(args.template), substitutions) 221 SavePList(args.output, args.format, data) 222 223 224def Main(): 225 parser = argparse.ArgumentParser(description='manipulate plist files') 226 subparsers = parser.add_subparsers() 227 228 for action in [MergeAction, SubstituteAction]: 229 action.Register(subparsers) 230 231 args = parser.parse_args() 232 args.func(args) 233 234 235if __name__ == '__main__': 236 sys.exit(Main()) 237