1#!/usr/bin/env python3
2
3import argparse
4import configparser
5import os
6import re
7import sys
8import datetime
9from subprocess import check_call, check_output
10
11from weboob.tools.misc import to_unicode
12
13WORKTREE = 'release_tmp'
14
15
16def make_tarball(tag, wheel):
17    # Create and enter a temporary worktree
18    if os.path.isdir(WORKTREE):
19        check_call(['git', 'worktree', 'remove', '--force', WORKTREE])
20    check_call(['git', 'worktree', 'add', WORKTREE, tag])
21    assert os.path.isdir(WORKTREE)
22    os.chdir(WORKTREE)
23
24    check_call([sys.executable, 'setup.py'] +
25               ['sdist',
26                '--keep',
27                '--dist-dir', '../dist'])
28    if wheel:
29        check_call([sys.executable, 'setup.py'] +
30                   ['bdist_wheel',
31                    '--keep',
32                    '--dist-dir', '../dist'])
33
34    # Clean up the temporary worktree
35    os.chdir(os.pardir)
36    check_call(['git', 'worktree', 'remove', '--force', WORKTREE])
37    assert not os.path.isdir(WORKTREE)
38
39    files = ['dist/weboob-qt-%s.tar.gz' % tag]
40    if wheel:
41        files.append('dist/weboob_qt-%s-py2.py3-none-any.whl' % tag)
42    for f in files:
43        if not os.path.exists(f):
44            raise Exception('Generated file not found at %s' % f)
45        else:
46            print('Generated file: %s' % f)
47    print('To upload to PyPI, run: twine upload -s %s' % ' '.join(files))
48
49
50def changed_modules(changes, changetype):
51    for change in changes:
52        change = change.decode('utf-8').split()
53        if change[0] == changetype:
54            m = re.match(r'modules/([^/]+)/__init__\.py', change[1])
55            if m:
56                yield m.group(1)
57
58
59def get_caps(module, config):
60    try:
61        return sorted(c for c in config[module]['capabilities'].split() if c != 'CapCollection')
62    except KeyError:
63        return ['**** FILL ME **** (running weboob update could help)']
64
65def new_modules(start, end):
66    os.chdir(os.path.join(os.path.dirname(__file__), os.path.pardir))
67    modules_info = configparser.ConfigParser()
68    try:
69        with open('modules/modules.list') as f:
70            modules_info.read_file(f)
71    except FileNotFoundError:
72        modules_info = {}
73    git_cmd = ['git', 'diff', '--no-renames', '--name-status', '%s..%s' % (start, end), '--', 'modules/']
74
75    added_modules = sorted(changed_modules(check_output(git_cmd).splitlines(), 'A'))
76    deleted_modules = sorted(changed_modules(check_output(git_cmd).splitlines(), 'D'))
77
78    for added_module in added_modules:
79        yield 'New %s module (%s)' % (added_module, ', '.join(get_caps(added_module, modules_info)))
80    for deleted_module in deleted_modules:
81        yield 'Deleted %s module' % deleted_module
82
83
84def changelog(start, end='HEAD'):
85    def sortkey(d):
86        """Put the commits with multiple domains at the end"""
87        return (len(d), d)
88
89    commits = {}
90    for commithash in check_output(['git', 'rev-list', '{}..{}'.format(start, end)]).splitlines():
91        title, domains = commitinfo(commithash)
92        commits.setdefault(domains, []).append(title)
93
94    for line in new_modules(start, end):
95        commits.setdefault(('General',), []).append(line)
96
97    cl = ''
98    for domains in sorted(commits.keys(), key=sortkey):
99        cl += '\n\n\t' + '\n\t'.join(domains)
100        for title in commits[domains]:
101            cl += '\n\t* ' + title
102
103    return cl.lstrip('\n')
104
105
106def domain(path):
107    dirs = os.path.dirname(path).split('/')
108    if dirs == ['']:
109        return 'General: Core'
110    if dirs[0] == 'man' or path == 'tools/py3-compatible.modules':
111        return None
112    if dirs[0] == 'weboob':
113        try:
114            if dirs[1] in ('core', 'tools'):
115                return 'General: Core'
116            elif dirs[1] == 'capabilities':
117                return 'Capabilities'
118            elif dirs[1] == 'browser':
119                try:
120                    if dirs[2] == 'filters':
121                        return 'Browser: Filters'
122                except IndexError:
123                    return 'Browser'
124            elif dirs[1] == 'applications':
125                try:
126                    return 'Applications: {}'.format(dirs[2])
127                except IndexError:
128                    return 'Applications'
129            elif dirs[1] == 'application':
130                try:
131                    return 'Applications: {}'.format(dirs[2].title())
132                except IndexError:
133                    return 'Applications'
134        except IndexError:
135            return 'General: Core'
136    if dirs[0] in ('contrib', 'tools'):
137        return 'Tools'
138    if dirs[0] in ('docs', 'icons'):
139        return 'Documentation'
140    if dirs[0] == 'modules':
141        try:
142            return 'Modules: {}'.format(dirs[1])
143        except IndexError:
144            return 'General: Core'
145    return 'Unknown'
146
147
148def commitinfo(commithash):
149    info = check_output(['git', 'show', '--format=%s', '--name-only', commithash]).decode('utf-8').splitlines()
150    title = to_unicode(info[0])
151    domains = set([domain(p) for p in info[2:] if domain(p)])
152    if 'Unknown' in domains and len(domains) > 1:
153        domains.remove('Unknown')
154    if not domains or len(domains) > 5:
155        domains = set(['Unknown'])
156
157    if 'Unknown' not in domains:
158        # When the domains are known, hide the title prefixes
159        title = re.sub(r'^(?:[\w\./\s]+:|\[[\w\./\s]+\])\s*', '', title, flags=re.UNICODE)
160
161    return title, tuple(sorted(domains))
162
163
164def previous_version():
165    """
166    Get the highest version tag
167    """
168    for v in check_output(['git', 'tag', '-l', '*.*', '--sort=-v:refname']).splitlines():
169        return v.decode()
170
171
172def prepare(start, end, version):
173    print('Weboob %s (%s)\n' % (version, datetime.date.today().strftime('%Y-%m-%d')))
174    print(changelog(start, end))
175
176
177if __name__ == '__main__':
178    parser = argparse.ArgumentParser(
179        description="Prepare and export a release.",
180        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
181        epilog='This is mostly meant to be called from release.sh for now.',
182    )
183
184    subparsers = parser.add_subparsers()
185
186    prepare_parser = subparsers.add_parser(
187        'prepare',
188        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
189    prepare_parser.add_argument('version')
190    prepare_parser.add_argument('--start', default=previous_version(), help='Commit of the previous release')
191    prepare_parser.add_argument('--end', default='HEAD', help='Last commit before the new release')
192    prepare_parser.set_defaults(mode='prepare')
193
194    tarball_parser = subparsers.add_parser(
195        'tarball',
196        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
197    tarball_parser.add_argument('tag')
198    tarball_parser.add_argument('--no-wheel', action='store_false', dest='wheel')
199    tarball_parser.set_defaults(mode='tarball')
200
201    args = parser.parse_args()
202    if args.mode == 'prepare':
203        prepare(args.start, args.end, args.version)
204    elif args.mode == 'tarball':
205        make_tarball(args.tag, args.wheel)
206