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