blob: 54cf46176bc29e5b6598ebadf4e1eaa5597e7b46 [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import codecs
import plistlib
import os
import re
import subprocess
import sys
import tempfile
import shlex
if sys.version_info.major < 3:
basestring_compat = basestring
else:
basestring_compat = str
# Xcode substitutes variables like ${PRODUCT_NAME} or $(PRODUCT_NAME) when
# compiling Info.plist. It also supports supports modifiers like :identifier
# or :rfc1034identifier. SUBSTITUTION_REGEXP_LIST is a list of regular
# expressions matching a variable substitution pattern with an optional
# modifier, while INVALID_CHARACTER_REGEXP matches all characters that are
# not valid in an "identifier" value (used when applying the modifier).
INVALID_CHARACTER_REGEXP = re.compile(r'[_/\s]')
SUBSTITUTION_REGEXP_LIST = (
re.compile(r'\$\{(?P<id>[^}]*?)(?P<modifier>:[^}]*)?\}'),
re.compile(r'\$\((?P<id>[^}]*?)(?P<modifier>:[^}]*)?\)'),
)
class SubstitutionError(Exception):
def __init__(self, key):
super(SubstitutionError, self).__init__()
self.key = key
def __str__(self):
return "SubstitutionError: {}".format(self.key)
def InterpolateString(value, substitutions):
"""Interpolates variable references into |value| using |substitutions|.
Inputs:
value: a string
substitutions: a mapping of variable names to values
Returns:
A new string with all variables references ${VARIABLES} replaced by their
value in |substitutions|. Raises SubstitutionError if a variable has no
substitution.
"""
def repl(match):
variable = match.group('id')
if variable not in substitutions:
raise SubstitutionError(variable)
# Some values need to be identifier and thus the variables references may
# contains :modifier attributes to indicate how they should be converted
# to identifiers ("identifier" replaces all invalid characters by '_' and
# "rfc1034identifier" replaces them by "-" to make valid URI too).
modifier = match.group('modifier')
if modifier == ':identifier':
return INVALID_CHARACTER_REGEXP.sub('_', substitutions[variable])
elif modifier == ':rfc1034identifier':
return INVALID_CHARACTER_REGEXP.sub('-', substitutions[variable])
else:
return substitutions[variable]
for substitution_regexp in SUBSTITUTION_REGEXP_LIST:
value = substitution_regexp.sub(repl, value)
return value
def Interpolate(value, substitutions):
"""Interpolates variable references into |value| using |substitutions|.
Inputs:
value: a value, can be a dictionary, list, string or other
substitutions: a mapping of variable names to values
Returns:
A new value with all variables references ${VARIABLES} replaced by their
value in |substitutions|. Raises SubstitutionError if a variable has no
substitution.
"""
if isinstance(value, dict):
return {k: Interpolate(v, substitutions) for k, v in value.items()}
if isinstance(value, list):
return [Interpolate(v, substitutions) for v in value]
if isinstance(value, basestring_compat):
return InterpolateString(value, substitutions)
return value
def LoadPList(path):
"""Loads Plist at |path| and returns it as a dictionary."""
if sys.version_info.major == 2:
fd, name = tempfile.mkstemp()
try:
subprocess.check_call(['plutil', '-convert', 'xml1', '-o', name, path])
with os.fdopen(fd, 'rb') as f:
return plistlib.readPlist(f)
finally:
os.unlink(name)
else:
with open(path, 'rb') as f:
return plistlib.load(f)
def SavePList(path, format, data):
"""Saves |data| as a Plist to |path| in the specified |format|."""
# The below does not replace the destination file but update it in place,
# so if more than one hardlink points to destination all of them will be
# modified. This is not what is expected, so delete destination file if
# it does exist.
if os.path.exists(path):
os.unlink(path)
if sys.version_info.major == 2:
fd, name = tempfile.mkstemp()
try:
with os.fdopen(fd, 'wb') as f:
plistlib.writePlist(data, f)
subprocess.check_call(['plutil', '-convert', format, '-o', path, name])
finally:
os.unlink(name)
else:
with open(path, 'wb') as f:
plist_format = {'binary1': plistlib.FMT_BINARY, 'xml1': plistlib.FMT_XML}
plistlib.dump(data, f, fmt=plist_format[format])
def MergePList(plist1, plist2):
"""Merges |plist1| with |plist2| recursively.
Creates a new dictionary representing a Property List (.plist) files by
merging the two dictionary |plist1| and |plist2| recursively (only for
dictionary values). List value will be concatenated.
Args:
plist1: a dictionary representing a Property List (.plist) file
plist2: a dictionary representing a Property List (.plist) file
Returns:
A new dictionary representing a Property List (.plist) file by merging
|plist1| with |plist2|. If any value is a dictionary, they are merged
recursively, otherwise |plist2| value is used. If values are list, they
are concatenated.
"""
result = plist1.copy()
for key, value in plist2.items():
if isinstance(value, dict):
old_value = result.get(key)
if isinstance(old_value, dict):
value = MergePList(old_value, value)
if isinstance(value, list):
value = plist1.get(key, []) + plist2.get(key, [])
result[key] = value
return result
class Action(object):
"""Class implementing one action supported by the script."""
@classmethod
def Register(cls, subparsers):
parser = subparsers.add_parser(cls.name, help=cls.help)
parser.set_defaults(func=cls._Execute)
cls._Register(parser)
class MergeAction(Action):
"""Class to merge multiple plist files."""
name = 'merge'
help = 'merge multiple plist files'
@staticmethod
def _Register(parser):
parser.add_argument('-o',
'--output',
required=True,
help='path to the output plist file')
parser.add_argument('-f',
'--format',
required=True,
choices=('xml1', 'binary1'),
help='format of the plist file to generate')
parser.add_argument(
'-x',
'--xcode-version',
help='version of Xcode, ignored (can be used to force rebuild)')
parser.add_argument('path', nargs="+", help='path to plist files to merge')
@staticmethod
def _Execute(args):
data = {}
for filename in args.path:
data = MergePList(data, LoadPList(filename))
SavePList(args.output, args.format, data)
class SubstituteAction(Action):
"""Class implementing the variable substitution in a plist file."""
name = 'substitute'
help = 'perform pattern substitution in a plist file'
@staticmethod
def _Register(parser):
parser.add_argument('-o',
'--output',
required=True,
help='path to the output plist file')
parser.add_argument('-t',
'--template',
required=True,
help='path to the template file')
parser.add_argument('-s',
'--substitution',
action='append',
default=[],
help='substitution rule in the format key=value')
parser.add_argument('-f',
'--format',
required=True,
choices=('xml1', 'binary1'),
help='format of the plist file to generate')
parser.add_argument(
'-x',
'--xcode-version',
help='version of Xcode, ignored (can be used to force rebuild)')
@staticmethod
def _Execute(args):
substitutions = {}
for substitution in args.substitution:
key, value = substitution.split('=', 1)
substitutions[key] = value
data = Interpolate(LoadPList(args.template), substitutions)
SavePList(args.output, args.format, data)
def Main():
# Cache this codec so that plistlib can find it. See
# https://crbug.com/1005190#c2 for more details.
codecs.lookup('utf-8')
parser = argparse.ArgumentParser(description='manipulate plist files')
subparsers = parser.add_subparsers()
for action in [MergeAction, SubstituteAction]:
action.Register(subparsers)
args = parser.parse_args()
args.func(args)
if __name__ == '__main__':
# TODO(https://crbug.com/941669): Temporary workaround until all scripts use
# python3 by default.
if sys.version_info[0] < 3:
os.execvp('python3', ['python3'] + sys.argv)
sys.exit(Main())