Rakesh Iyer | 16606af | 2017-03-30 16:24:57 -0700 | [diff] [blame] | 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 | |
| 16 | import argparse |
| 17 | import csv |
| 18 | import datetime |
| 19 | import os |
| 20 | import pprint |
| 21 | |
| 22 | import lxml.etree as et |
| 23 | |
| 24 | DBG = False |
| 25 | |
| 26 | class 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 | |
| 45 | Licensed under the Apache License, Version 2.0 (the "License"); |
| 46 | you may not use this file except in compliance with the License. |
| 47 | You may obtain a copy of the License at |
| 48 | |
| 49 | http://www.apache.org/licenses/LICENSE-2.0 |
| 50 | |
| 51 | Unless required by applicable law or agreed to in writing, software |
| 52 | distributed under the License is distributed on an "AS IS" BASIS, |
| 53 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 54 | See the License for the specific language governing permissions and |
| 55 | limitations 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 | |
| 280 | if __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) |