1#!/usr/bin/python3
2
3##
4# A good background read on how Android handles alternative resources is here:
5# https://developer.android.com/guide/topics/resources/providing-resources.html
6#
7# This uses lxml so you may need to install it manually if your distribution
8# does not ordinarily ship with it. On Ubuntu, you can run:
9#
10# sudo apt-get install python-lxml
11#
12# Example invocation:
13# ./resource_generator.py --csv specs/keylines.csv --resdir car-stream-ui-lib/res --type dimens
14##
15
16import argparse
17import csv
18import datetime
19import os
20import pprint
21
22import lxml.etree as et
23
24DBG = False
25
26class ResourceGenerator:
27    def __init__(self):
28        self.COLORS = "colors"
29        self.DIMENS = "dimens"
30
31        self.TAG_DIMEN = "dimen"
32
33        self.resource_handlers = {
34            self.COLORS : self.HandleColors,
35            self.DIMENS : self.HandleDimens,
36        }
37
38        self.ENCODING = "utf-8"
39        self.XML_HEADER = '<?xml version="1.0" encoding="%s"?>' % self.ENCODING
40        # The indentation looks off but it needs to be otherwise the indentation will end up in the
41        # string itself, which we don't want. So much for pythons indentation policy.
42        self.AOSP_HEADER = """
43<!-- Copyright (C) %d The Android Open Source Project
44
45Licensed under the Apache License, Version 2.0 (the "License");
46you may not use this file except in compliance with the License.
47You may obtain a copy of the License at
48
49  http://www.apache.org/licenses/LICENSE-2.0
50
51Unless required by applicable law or agreed to in writing, software
52distributed under the License is distributed on an "AS IS" BASIS,
53WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
54See the License for the specific language governing permissions and
55limitations under the License.
56-->
57""" % datetime.datetime.now().year
58        self.EMPTY_XML = "<resources/>"
59
60
61    def HandleColors(self, reader, resource_dir):
62        raise Exception("Not yet implemented")
63
64
65    ##
66    # Validate the header row of the csv. Getting this wrong would mean that the resources wouldn't
67    # actually work, so find any mistakes ahead of time.
68    ##
69    def ValidateHeader(self, header):
70        # TODO: Validate the header values based on the ordering of modifiers in table 2.
71        pass
72
73
74    ##
75    # Given a list of resource modifers, create the appropriate directories and xml files for
76    # them to be populated in.
77    # Returns a tuple of maps of the form  ({ modifier : xml file } , { modifier : xml object })
78    ##
79    def CreateOrOpenResourceFiles(self, resource_dir, resource_type, modifiers):
80        filenames = { }
81        xmltrees = { }
82        dir_prefix = "values"
83        qualifier_separator = "-"
84        file_extension = ".xml"
85        for modifier in modifiers:
86            # We're using the keyword none to specify that there are no modifiers and so the
87            # values specified here goes into the default file.
88            directory = resource_dir + os.path.sep + dir_prefix
89            if modifier != "none":
90                directory = directory + qualifier_separator + modifier
91
92            if not os.path.exists(directory):
93                if DBG:
94                    print("Creating directory %s" % directory)
95                os.mkdir(directory)
96
97            filename = directory + os.path.sep + resource_type + file_extension
98            if not os.path.exists(filename):
99                if DBG:
100                    print("Creating file %s" % filename)
101                with open(filename, "w") as xmlfile:
102                    xmlfile.write(self.XML_HEADER)
103                    xmlfile.write(self.AOSP_HEADER)
104                    xmlfile.write(self.EMPTY_XML)
105
106            filenames[modifier] = filename
107            if DBG:
108                print("Parsing %s" % (filename))
109            parser = et.XMLParser(remove_blank_text=True)
110            xmltrees[modifier] = et.parse(filename, parser)
111        return filenames, xmltrees
112
113
114    ##
115    # Updates a resource value in the xmltree if it exists, adds it in if not.
116    ##
117    def AddOrUpdateValue(self, xmltree, tag, resource_name, resource_value):
118        root = xmltree.getroot()
119        found = False
120        resource_node = None
121        attr_name = "name"
122        # Look for the value that we want.
123        for elem in root:
124            if elem.tag == tag and elem.attrib[attr_name] == resource_name:
125                resource_node = elem
126                found = True
127                break
128        # If it doesn't exist yet, create one.
129        if not found:
130            resource_node = et.SubElement(root, tag)
131            resource_node.attrib[attr_name] = resource_name
132        # Update the value.
133        resource_node.text = resource_value
134
135
136    ##
137    # lxml formats xml with 2 space indentation. Android convention says 4 spaces. Multiply any
138    # leading spaces by 2 and re-generate the string.
139    ##
140    def FixupIndentation(self, xml_string):
141        reformatted_xml = ""
142        for line in xml_string.splitlines():
143            stripped = line.lstrip()
144            # Special case for multiline comments. These usually are hand aligned with something
145            # so we don't want to reformat those.
146            if not stripped.startswith("<"):
147                leading_spaces = 0
148            else:
149                leading_spaces = len(line) - len(stripped)
150            reformatted_xml += " " * leading_spaces + line + os.linesep
151        return reformatted_xml
152
153
154    ##
155    # Read all the lines that appear before the <resources.*> tag so that they can be replicated
156    # while writing out the file again. We can't simply re-generate the aosp header because it's
157    # apparently not a good thing to change the date on a copyright notice to something more
158    # recent.
159    # Returns a string of the lines that appear before the resources tag.
160    ##
161    def ReadStartingLines(self, filename):
162        with open(filename) as f:
163            starting_lines = ""
164            for line in f.readlines():
165                # Yes, this will fail if you start a line inside a comment with <resources>.
166                # It's more work than it's worth to handle that case.
167                if line.lstrip().startswith("<resources"):
168                    break;
169                starting_lines += line
170        return starting_lines
171
172    ##
173    # Take a map of resources and a directory and update the xml files within it with the new
174    # values. Will create any directories and files as necessary.
175    ##
176    def ModifyXml(self, resources, resource_type, resource_dir, tag):
177        # Create a deduplicated list of the resource modifiers that we will need.
178        modifiers = set()
179        for resource_values in resources.values():
180            for modifier in resource_values.keys():
181                modifiers.add(modifier)
182        if DBG:
183            pp = pprint.PrettyPrinter()
184            pp.pprint(modifiers)
185            pp.pprint(resources)
186
187        # Update each of the trees with their respective values.
188        filenames, xmltrees = self.CreateOrOpenResourceFiles(resource_dir, resource_type, modifiers)
189        for resource_name, resource_values in resources.items():
190            if DBG:
191                print(resource_name)
192                print(resource_values)
193            for modifier, value in resource_values.items():
194                xmltree = xmltrees[modifier]
195                self.AddOrUpdateValue(xmltree, tag, resource_name, value)
196
197        # Finally write out all the trees.
198        for modifier, xmltree in xmltrees.items():
199            if DBG:
200                print("Writing out %s" % filenames[modifier])
201            # ElementTree.write() doesn't allow us to place the aosp header at the top
202            # of the file so bounce it through a string.
203            starting_lines = self.ReadStartingLines(filenames[modifier])
204            with open(filenames[modifier], "wt", encoding=self.ENCODING) as xmlfile:
205                xml = et.tostring(xmltree.getroot(), pretty_print=True).decode("utf-8")
206                formatted_xml = self.FixupIndentation(xml)
207                if DBG:
208                    print(formatted_xml)
209                xmlfile.write(starting_lines)
210                xmlfile.write(formatted_xml)
211
212
213    ##
214    # Read in a csv file that contains dimensions and update the resources, creating any necessary
215    # files and directories along the way.
216    ##
217    def HandleDimens(self, reader, resource_dir):
218        read_header = False
219        header = []
220        resources = { }
221        # Create nested maps of the form { resource_name : { modifier : value } }
222        for row in reader:
223            # Skip any empty lines.
224            if len(row) == 0:
225                continue
226
227            trimmed = [cell.strip() for cell in row]
228            # Skip any comment lines.
229            if trimmed[0].startswith("#"):
230                continue
231
232            # Store away the header row. We'll need it later to create and/or modify the xml files.
233            if not read_header:
234                self.ValidateHeader(trimmed)  # Will raise if it fails.
235                header = trimmed
236                read_header = True
237                continue
238
239            if (len(trimmed) != len(header)):
240                raise ValueError("Missing commas in csv file!")
241
242            var_name = trimmed[0]
243            var_values = { }
244            for idx in range(1, len(trimmed)):
245                cell = trimmed[idx]
246                # Only deal with cells that actually have content in them.
247                if len(cell) > 0:
248                    var_values[header[idx]] = cell
249
250            if len(var_values.keys()) > 0:
251                resources[var_name] = var_values
252
253        self.ModifyXml(resources, self.DIMENS, resource_dir, self.TAG_DIMEN)
254
255
256    ##
257    # Validate the command line arguments that we have been passed. Will raise an exception if
258    # there are any invalid arguments.
259    ##
260    def ValidateArgs(self, csv, resource_dir, resource_type):
261        if not os.path.isfile(csv):
262            raise ValueError("%s is not a valid path" % csv)
263        if not os.path.isdir(resource_dir):
264            raise ValueError("%s is not a valid resource directory" % resource_dir)
265        if not resource_type in self.resource_handlers:
266            raise ValueError("%s is not a supported resource type" % resource_type)
267
268
269    ##
270    # The logical entry point of this application.
271    ##
272    def Main(self, csv_file, resource_dir, resource_type):
273        self.ValidateArgs(csv_file, resource_dir, resource_type)  # Will raise if it fails.
274        with open(csv_file, 'r') as handle:
275            reader = csv.reader(handle)  # Defaults to the excel dialect of csv.
276            self.resource_handlers[resource_type](reader, resource_dir)
277        print("Done!")
278
279
280if __name__ == "__main__":
281    parser = argparse.ArgumentParser(description='Convert a CSV into android resources')
282    parser.add_argument('--csv', action='store', dest='csv')
283    parser.add_argument('--resdir', action='store', dest='resdir')
284    parser.add_argument('--type', action='store', dest='type')
285    args = parser.parse_args()
286    app = ResourceGenerator()
287    app.Main(args.csv, args.resdir, args.type)
288