Home | History | Annotate | Download | only in msi
      1 '''
      2 Processes a CSV file containing a list of files into a WXS file with
      3 components for each listed file.
      4 
      5 The CSV columns are:
      6     source of file, target for file, group name
      7 
      8 Usage::
      9     py txt_to_wxs.py [path to file list .csv] [path to destination .wxs]
     10 
     11 This is necessary to handle structures where some directories only
     12 contain other directories. MSBuild is not able to generate the
     13 Directory entries in the WXS file correctly, as it operates on files.
     14 Python, however, can easily fill in the gap.
     15 '''
     16 
     17 __author__ = "Steve Dower <steve.dower (at] microsoft.com>"
     18 
     19 import csv
     20 import re
     21 import sys
     22 
     23 from collections import defaultdict
     24 from itertools import chain, zip_longest
     25 from pathlib import PureWindowsPath
     26 from uuid import uuid1
     27 
     28 ID_CHAR_SUBS = {
     29     '-': '_',
     30     '+': '_P',
     31 }
     32 
     33 def make_id(path):
     34     return re.sub(
     35         r'[^A-Za-z0-9_.]',
     36         lambda m: ID_CHAR_SUBS.get(m.group(0), '_'),
     37         str(path).rstrip('/\\'),
     38         flags=re.I
     39     )
     40 
     41 DIRECTORIES = set()
     42 
     43 def main(file_source, install_target):
     44     with open(file_source, 'r', newline='') as f:
     45         files = list(csv.reader(f))
     46 
     47     assert len(files) == len(set(make_id(f[1]) for f in files)), "Duplicate file IDs exist"
     48 
     49     directories = defaultdict(set)
     50     cache_directories = defaultdict(set)
     51     groups = defaultdict(list)
     52     for source, target, group, disk_id, condition in files:
     53         target = PureWindowsPath(target)
     54         groups[group].append((source, target, disk_id, condition))
     55 
     56         if target.suffix.lower() in {".py", ".pyw"}:
     57             cache_directories[group].add(target.parent)
     58 
     59         for dirname in target.parents:
     60             parent = make_id(dirname.parent)
     61             if parent and parent != '.':
     62                 directories[parent].add(dirname.name)
     63 
     64     lines = [
     65         '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">',
     66         '    <Fragment>',
     67     ]
     68     for dir_parent in sorted(directories):
     69         lines.append('        <DirectoryRef Id="{}">'.format(dir_parent))
     70         for dir_name in sorted(directories[dir_parent]):
     71             lines.append('            <Directory Id="{}_{}" Name="{}" />'.format(dir_parent, make_id(dir_name), dir_name))
     72         lines.append('        </DirectoryRef>')
     73     for dir_parent in (make_id(d) for group in cache_directories.values() for d in group):
     74         lines.append('        <DirectoryRef Id="{}">'.format(dir_parent))
     75         lines.append('            <Directory Id="{}___pycache__" Name="__pycache__" />'.format(dir_parent))
     76         lines.append('        </DirectoryRef>')
     77     lines.append('    </Fragment>')
     78 
     79     for group in sorted(groups):
     80         lines.extend([
     81             '    <Fragment>',
     82             '        <ComponentGroup Id="{}">'.format(group),
     83         ])
     84         for source, target, disk_id, condition in groups[group]:
     85             lines.append('            <Component Id="{}" Directory="{}" Guid="*">'.format(make_id(target), make_id(target.parent)))
     86             if condition:
     87                 lines.append('                <Condition>{}</Condition>'.format(condition))
     88 
     89             if disk_id:
     90                 lines.append('                <File Id="{}" Name="{}" Source="{}" DiskId="{}" />'.format(make_id(target), target.name, source, disk_id))
     91             else:
     92                 lines.append('                <File Id="{}" Name="{}" Source="{}" />'.format(make_id(target), target.name, source))
     93             lines.append('            </Component>')
     94 
     95         create_folders = {make_id(p) + "___pycache__" for p in cache_directories[group]}
     96         remove_folders = {make_id(p2) for p1 in cache_directories[group] for p2 in chain((p1,), p1.parents)}
     97         create_folders.discard(".")
     98         remove_folders.discard(".")
     99         if create_folders or remove_folders:
    100             lines.append('            <Component Id="{}__pycache__folders" Directory="TARGETDIR" Guid="{}">'.format(group, uuid1()))
    101             lines.extend('                <CreateFolder Directory="{}" />'.format(p) for p in create_folders)
    102             lines.extend('                <RemoveFile Id="Remove_{0}_files" Name="*" On="uninstall" Directory="{0}" />'.format(p) for p in create_folders)
    103             lines.extend('                <RemoveFolder Id="Remove_{0}_folder" On="uninstall" Directory="{0}" />'.format(p) for p in create_folders | remove_folders)
    104             lines.append('            </Component>')
    105 
    106         lines.extend([
    107             '        </ComponentGroup>',
    108             '    </Fragment>',
    109         ])
    110     lines.append('</Wix>')
    111 
    112     # Check if the file matches. If so, we don't want to touch it so
    113     # that we can skip rebuilding.
    114     try:
    115         with open(install_target, 'r') as f:
    116             if all(x.rstrip('\r\n') == y for x, y in zip_longest(f, lines)):
    117                 print('File is up to date')
    118                 return
    119     except IOError:
    120         pass
    121 
    122     with open(install_target, 'w') as f:
    123         f.writelines(line + '\n' for line in lines)
    124     print('Wrote {} lines to {}'.format(len(lines), install_target))
    125 
    126 if __name__ == '__main__':
    127     main(sys.argv[1], sys.argv[2])
    128