blob: 290fa102cd6f5103e2513efe332d788c16b8a5b7 [file] [log] [blame]
Rakesh Iyer16606af2017-03-30 16:24:57 -07001#!/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)