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