1'''
2Processes a CSV file containing a list of files into a WXS file with
3components for each listed file.
4
5The CSV columns are:
6    source of file, target for file, group name
7
8Usage::
9    py txt_to_wxs.py [path to file list .csv] [path to destination .wxs]
10
11This is necessary to handle structures where some directories only
12contain other directories. MSBuild is not able to generate the
13Directory entries in the WXS file correctly, as it operates on files.
14Python, however, can easily fill in the gap.
15'''
16
17__author__ = "Steve Dower <steve.dower@microsoft.com>"
18
19import csv
20import re
21import sys
22
23from collections import defaultdict
24from itertools import chain, zip_longest
25from pathlib import PureWindowsPath
26from uuid import uuid1
27
28ID_CHAR_SUBS = {
29    '-': '_',
30    '+': '_P',
31}
32
33def 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
41DIRECTORIES = set()
42
43def 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
126if __name__ == '__main__':
127    main(sys.argv[1], sys.argv[2])
128