| #!/usr/bin/python3 |
| |
| ## |
| # A good background read on how Android handles alternative resources is here: |
| # https://developer.android.com/guide/topics/resources/providing-resources.html |
| # |
| # This uses lxml so you may need to install it manually if your distribution |
| # does not ordinarily ship with it. On Ubuntu, you can run: |
| # |
| # sudo apt-get install python-lxml |
| # |
| # Example invocation: |
| # ./resource_generator.py --csv specs/keylines.csv --resdir car-stream-ui-lib/res --type dimens |
| ## |
| |
| import argparse |
| import csv |
| import datetime |
| import os |
| import pprint |
| |
| import lxml.etree as et |
| |
| DBG = False |
| |
| class ResourceGenerator: |
| def __init__(self): |
| self.COLORS = "colors" |
| self.DIMENS = "dimens" |
| |
| self.TAG_DIMEN = "dimen" |
| |
| self.resource_handlers = { |
| self.COLORS : self.HandleColors, |
| self.DIMENS : self.HandleDimens, |
| } |
| |
| self.ENCODING = "utf-8" |
| self.XML_HEADER = '<?xml version="1.0" encoding="%s"?>' % self.ENCODING |
| # The indentation looks off but it needs to be otherwise the indentation will end up in the |
| # string itself, which we don't want. So much for pythons indentation policy. |
| self.AOSP_HEADER = """ |
| <!-- Copyright (C) %d The Android Open Source Project |
| |
| Licensed under the Apache License, Version 2.0 (the "License"); |
| you may not use this file except in compliance with the License. |
| You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| --> |
| """ % datetime.datetime.now().year |
| self.EMPTY_XML = "<resources/>" |
| |
| |
| def HandleColors(self, reader, resource_dir): |
| raise Exception("Not yet implemented") |
| |
| |
| ## |
| # Validate the header row of the csv. Getting this wrong would mean that the resources wouldn't |
| # actually work, so find any mistakes ahead of time. |
| ## |
| def ValidateHeader(self, header): |
| # TODO: Validate the header values based on the ordering of modifiers in table 2. |
| pass |
| |
| |
| ## |
| # Given a list of resource modifers, create the appropriate directories and xml files for |
| # them to be populated in. |
| # Returns a tuple of maps of the form ({ modifier : xml file } , { modifier : xml object }) |
| ## |
| def CreateOrOpenResourceFiles(self, resource_dir, resource_type, modifiers): |
| filenames = { } |
| xmltrees = { } |
| dir_prefix = "values" |
| qualifier_separator = "-" |
| file_extension = ".xml" |
| for modifier in modifiers: |
| # We're using the keyword none to specify that there are no modifiers and so the |
| # values specified here goes into the default file. |
| directory = resource_dir + os.path.sep + dir_prefix |
| if modifier != "none": |
| directory = directory + qualifier_separator + modifier |
| |
| if not os.path.exists(directory): |
| if DBG: |
| print("Creating directory %s" % directory) |
| os.mkdir(directory) |
| |
| filename = directory + os.path.sep + resource_type + file_extension |
| if not os.path.exists(filename): |
| if DBG: |
| print("Creating file %s" % filename) |
| with open(filename, "w") as xmlfile: |
| xmlfile.write(self.XML_HEADER) |
| xmlfile.write(self.AOSP_HEADER) |
| xmlfile.write(self.EMPTY_XML) |
| |
| filenames[modifier] = filename |
| if DBG: |
| print("Parsing %s" % (filename)) |
| parser = et.XMLParser(remove_blank_text=True) |
| xmltrees[modifier] = et.parse(filename, parser) |
| return filenames, xmltrees |
| |
| |
| ## |
| # Updates a resource value in the xmltree if it exists, adds it in if not. |
| ## |
| def AddOrUpdateValue(self, xmltree, tag, resource_name, resource_value): |
| root = xmltree.getroot() |
| found = False |
| resource_node = None |
| attr_name = "name" |
| # Look for the value that we want. |
| for elem in root: |
| if elem.tag == tag and elem.attrib[attr_name] == resource_name: |
| resource_node = elem |
| found = True |
| break |
| # If it doesn't exist yet, create one. |
| if not found: |
| resource_node = et.SubElement(root, tag) |
| resource_node.attrib[attr_name] = resource_name |
| # Update the value. |
| resource_node.text = resource_value |
| |
| |
| ## |
| # lxml formats xml with 2 space indentation. Android convention says 4 spaces. Multiply any |
| # leading spaces by 2 and re-generate the string. |
| ## |
| def FixupIndentation(self, xml_string): |
| reformatted_xml = "" |
| for line in xml_string.splitlines(): |
| stripped = line.lstrip() |
| # Special case for multiline comments. These usually are hand aligned with something |
| # so we don't want to reformat those. |
| if not stripped.startswith("<"): |
| leading_spaces = 0 |
| else: |
| leading_spaces = len(line) - len(stripped) |
| reformatted_xml += " " * leading_spaces + line + os.linesep |
| return reformatted_xml |
| |
| |
| ## |
| # Read all the lines that appear before the <resources.*> tag so that they can be replicated |
| # while writing out the file again. We can't simply re-generate the aosp header because it's |
| # apparently not a good thing to change the date on a copyright notice to something more |
| # recent. |
| # Returns a string of the lines that appear before the resources tag. |
| ## |
| def ReadStartingLines(self, filename): |
| with open(filename) as f: |
| starting_lines = "" |
| for line in f.readlines(): |
| # Yes, this will fail if you start a line inside a comment with <resources>. |
| # It's more work than it's worth to handle that case. |
| if line.lstrip().startswith("<resources"): |
| break; |
| starting_lines += line |
| return starting_lines |
| |
| ## |
| # Take a map of resources and a directory and update the xml files within it with the new |
| # values. Will create any directories and files as necessary. |
| ## |
| def ModifyXml(self, resources, resource_type, resource_dir, tag): |
| # Create a deduplicated list of the resource modifiers that we will need. |
| modifiers = set() |
| for resource_values in resources.values(): |
| for modifier in resource_values.keys(): |
| modifiers.add(modifier) |
| if DBG: |
| pp = pprint.PrettyPrinter() |
| pp.pprint(modifiers) |
| pp.pprint(resources) |
| |
| # Update each of the trees with their respective values. |
| filenames, xmltrees = self.CreateOrOpenResourceFiles(resource_dir, resource_type, modifiers) |
| for resource_name, resource_values in resources.items(): |
| if DBG: |
| print(resource_name) |
| print(resource_values) |
| for modifier, value in resource_values.items(): |
| xmltree = xmltrees[modifier] |
| self.AddOrUpdateValue(xmltree, tag, resource_name, value) |
| |
| # Finally write out all the trees. |
| for modifier, xmltree in xmltrees.items(): |
| if DBG: |
| print("Writing out %s" % filenames[modifier]) |
| # ElementTree.write() doesn't allow us to place the aosp header at the top |
| # of the file so bounce it through a string. |
| starting_lines = self.ReadStartingLines(filenames[modifier]) |
| with open(filenames[modifier], "wt", encoding=self.ENCODING) as xmlfile: |
| xml = et.tostring(xmltree.getroot(), pretty_print=True).decode("utf-8") |
| formatted_xml = self.FixupIndentation(xml) |
| if DBG: |
| print(formatted_xml) |
| xmlfile.write(starting_lines) |
| xmlfile.write(formatted_xml) |
| |
| |
| ## |
| # Read in a csv file that contains dimensions and update the resources, creating any necessary |
| # files and directories along the way. |
| ## |
| def HandleDimens(self, reader, resource_dir): |
| read_header = False |
| header = [] |
| resources = { } |
| # Create nested maps of the form { resource_name : { modifier : value } } |
| for row in reader: |
| # Skip any empty lines. |
| if len(row) == 0: |
| continue |
| |
| trimmed = [cell.strip() for cell in row] |
| # Skip any comment lines. |
| if trimmed[0].startswith("#"): |
| continue |
| |
| # Store away the header row. We'll need it later to create and/or modify the xml files. |
| if not read_header: |
| self.ValidateHeader(trimmed) # Will raise if it fails. |
| header = trimmed |
| read_header = True |
| continue |
| |
| if (len(trimmed) != len(header)): |
| raise ValueError("Missing commas in csv file!") |
| |
| var_name = trimmed[0] |
| var_values = { } |
| for idx in range(1, len(trimmed)): |
| cell = trimmed[idx] |
| # Only deal with cells that actually have content in them. |
| if len(cell) > 0: |
| var_values[header[idx]] = cell |
| |
| if len(var_values.keys()) > 0: |
| resources[var_name] = var_values |
| |
| self.ModifyXml(resources, self.DIMENS, resource_dir, self.TAG_DIMEN) |
| |
| |
| ## |
| # Validate the command line arguments that we have been passed. Will raise an exception if |
| # there are any invalid arguments. |
| ## |
| def ValidateArgs(self, csv, resource_dir, resource_type): |
| if not os.path.isfile(csv): |
| raise ValueError("%s is not a valid path" % csv) |
| if not os.path.isdir(resource_dir): |
| raise ValueError("%s is not a valid resource directory" % resource_dir) |
| if not resource_type in self.resource_handlers: |
| raise ValueError("%s is not a supported resource type" % resource_type) |
| |
| |
| ## |
| # The logical entry point of this application. |
| ## |
| def Main(self, csv_file, resource_dir, resource_type): |
| self.ValidateArgs(csv_file, resource_dir, resource_type) # Will raise if it fails. |
| with open(csv_file, 'r') as handle: |
| reader = csv.reader(handle) # Defaults to the excel dialect of csv. |
| self.resource_handlers[resource_type](reader, resource_dir) |
| print("Done!") |
| |
| |
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser(description='Convert a CSV into android resources') |
| parser.add_argument('--csv', action='store', dest='csv') |
| parser.add_argument('--resdir', action='store', dest='resdir') |
| parser.add_argument('--type', action='store', dest='type') |
| args = parser.parse_args() |
| app = ResourceGenerator() |
| app.Main(args.csv, args.resdir, args.type) |