Home | History | Annotate | Download | only in libs
      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)
    288