~launchpad-pqm/launchpad/devel

10637.3.1 by Guilherme Salgado
Use the default python version instead of a hard-coded version
1
#!/usr/bin/python
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
2
#
3
# Copyright 2009 Canonical Ltd.  This software is licensed under the
4
# GNU Affero General Public License version 3 (see the file LICENSE).
5
6
"""Migrate modules from the old LP directory structure to the new using
7
a control file and the exising mover script that Francis wrote.
8
"""
9
10
import errno
11
import os
12
import re
13
14
from find import find_files, find_matches
15
from optparse import OptionParser
16
from rename_module import (
17
    bzr_add, bzr_move_file, bzr_remove_file, rename_module, update_references)
18
from rename_zcml import handle_zcml
19
from utils import log, run, spew
20
21
22
MOVER = os.path.join(os.path.dirname(__file__), 'rename_module.py')
23
24
TLA_MAP = dict(
25
    ans='answers',
26
    app='app',
27
    blu='blueprints',
28
    bug='bugs',
29
    cod='code',
30
    reg='registry',
31
    sha='shared',
32
    soy='soyuz',
33
    svc='services',
34
    tes='testing',
35
    tra='translations',
9668.6.2 by Curtis Hovey
Fixed errors in migrater.
36
    pkg='registry',
10234.3.7 by Curtis Hovey
updated code per review.
37
    hdb='hardwaredb',
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
38
    )
39
40
RENAME_MAP = dict(
41
    components='adapters',
42
    database='model',
43
    ftests='tests',
44
    pagetests='stories',
45
    )
46
47
OLD_TOP = 'lib/canonical/launchpad'
48
NEW_TOP = 'lib/lp'
49
50
APP_DIRECTORIES = [
51
    'adapters',
52
    'browser',
53
    'doc',
54
    'emailtemplates',
55
    'event',
56
    'feed',
57
    'interfaces',
58
    'model',
59
    'notifications',
60
    'scripts',
61
    'stories',
62
    'subscribers',
63
    'templates',
64
    'tests',
65
    'browser/tests',
66
    ]
67
68
TEST_PATHS = set(('doc', 'tests', 'ftests', 'pagetests'))
69
# Ripped straight from GNU touch(1)
70
FLAGS = os.O_WRONLY | os.O_CREAT | os.O_NONBLOCK | os.O_NOCTTY
71
72
73
def parse_args():
74
    """Return a tuple of parser, option, and arguments."""
75
    usage = """\
76
%prog [options] controlfile app_codes+
77
78
controlfile is the file containing the list of files to be moved.  Each file
79
is prefixed with a TLA identifying the apps.
80
81
app_codes is a list of TLAs identifying the apps to migrate.
82
"""
83
    parser = OptionParser(usage)
84
    parser.add_option(
85
        '--dryrun',
86
        action='store_true', default=False, dest='dry_run',
87
        help=("If this option is used actions will be printed "
88
              "but not executed."))
89
    parser.add_option(
90
        '--no-move',
91
        action='store_false', default=True, dest='move',
92
        help="Don't actually move any files, just set up the app's tree.")
93
94
    options, arguments = parser.parse_args()
95
    return parser, options, arguments
96
97
98
def convert_ctl_data(data):
99
    """Return a dict of files, each keyed to an app."""
100
    app_data = {}
101
    for line in data:
102
        try:
103
            tla, fn = line.split()
104
        except ValueError:
105
            continue
106
        if not tla in app_data:
107
            app_data[tla] = []
108
        app_data[tla].append(fn[2:])
109
    return app_data
110
111
COLLIDED = []
112
9668.6.2 by Curtis Hovey
Fixed errors in migrater.
113
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
114
def move_it(old_path, new_path):
115
    """Move a versioned file without colliding with another file."""
116
    # Move the file and fix the imports.  LBYL.
117
    if os.path.exists(new_path):
118
        if os.path.getsize(new_path) == 0:
119
            # We must remove the file since bzr refuses to clobber existing
120
            # files.
121
            bzr_remove_file(new_path)
122
        else:
123
            log('COLLISION! target already exists: %s', new_path)
124
            COLLIDED.append(new_path)
125
            # Try to find an alternative.  I seriously doubt we'll ever have
126
            # more than two collisions.
127
            for counter in range(10):
128
                fn, ext = os.path.splitext(new_path)
129
                new_target = fn + '_%d' % counter + ext
130
                log('    new target: %s', new_target)
131
                if not os.path.exists(new_target):
132
                    new_path = new_target
133
                    break
134
            else:
135
                raise AssertionError('Too many collisions: ' + new_path)
136
    rename_module(old_path, new_path)
137
138
139
def make_tree(app):
140
    """Make the official tree structure."""
141
    if not os.path.exists(NEW_TOP):
142
        os.mkdir(NEW_TOP)
143
    tld = os.path.join(NEW_TOP, TLA_MAP[app])
144
145
    for directory in [''] + APP_DIRECTORIES:
146
        d = os.path.join(tld, directory)
147
        try:
148
            os.mkdir(d)
149
            bzr_add(d)
150
            print "created", d
151
        except OSError, e:
152
            if e.errno == errno.EEXIST:
153
                # The directory already exists, so assume that the __init__.py
154
                # file also exists.
155
                continue
156
            else:
157
                raise
158
        else:
159
            # Touch an empty __init__.py to make the thing a package.
160
            init_file = os.path.join(d, '__init__.py')
11666.3.1 by Curtis Hovey
Merged apocalypse-0 into this branch to fix translations and codehosting.
161
            fd = os.open(init_file, FLAGS, 0666)
162
            os.close(fd)
163
            bzr_add(init_file)
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
164
    # Add the whole directory.
165
    bzr_add(tld)
166
167
168
def file2module(module_file):
169
    """From a filename, return the python module name."""
170
    start_path = 'lib' + os.path.sep
171
    module_file, dummy = os.path.splitext(module_file)
172
    module = module_file[len(start_path):].replace(os.path.sep, '.')
173
    return module
174
175
176
def handle_script(old_path, new_path):
177
    """Move a script or directory and update references in cronscripts."""
178
    parts = old_path.split(os.path.sep)
179
    if (len(parts) - parts.index('scripts')) > 2:
180
        # The script is a directory not a single-file module.
181
        # Just get the directory portion and move everything at once.
182
        old_path = os.path.join(*parts[:-1])
183
        new_full_path = new_path
184
    else:
185
        # The script is a single-file module.  Add the script name to the end
186
        # of new_path.
187
        new_full_path = os.path.join(new_path, parts[-1])
188
189
    # Move the file or directory
190
    bzr_move_file(old_path, new_path)
191
    # Update references, but only in the cronscripts directory.
192
    source_module = file2module(old_path)
193
    target_module = file2module(new_full_path)
194
    update_references(source_module, target_module)
195
    update_helper_imports(old_path, new_full_path)
196
197
198
def map_filename(path):
199
    """Return the renamed file name."""
200
    fn, dummy = os.path.splitext(path)
201
    if fn.endswith('-pages'):
202
        # Don't remap -pages doctests here.
203
        return path
204
    else:
205
        return os.sep.join(RENAME_MAP.get(path_part, path_part)
206
                           for path_part in path.split(os.sep))
207
208
209
def handle_test(old_path, new_path):
210
    """Migrate tests."""
211
    spew('handle_test(%s, %s)', old_path, new_path)
212
    unsupported_dirs = [
213
        'components',
214
        'daemons',
215
        'model',
216
        'interfaces',
217
        'mail',
218
        'mailout',
219
        'translationformat',
220
        'utilities',
221
        'validators',
222
        'vocabularies',
223
        'webapp',
224
        'xmlrpc',
225
        ]
226
    new_path = map_filename(new_path)
227
    # Do target -pages.txt doctest remapping.
228
    file_name, ext = os.path.splitext(new_path)
229
    if file_name.endswith('-pages'):
230
        new_path = file_name[:-6] + '-views' + ext
231
        parts = new_path.split(os.sep)
232
        index = parts.index('doc')
233
        parts[index:index + 1] = ['browser', 'tests']
234
        new_path = os.sep.join(parts)
235
    if '/tests/' in new_path and '/browser/tests/' not in new_path:
236
        # All unit tests except to browser unit tests move to the app
237
        # tests dir.
238
        new_path = os.sep.join(
9668.6.2 by Curtis Hovey
Fixed errors in migrater.
239
            path_part for path_part in new_path.split(os.sep)
240
            if path_part not in unsupported_dirs)
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
241
    # Create new_path's directory if it doesn't exist yet.
242
    try:
243
        test_dir, dummy = os.path.split(new_path)
244
        os.makedirs(test_dir)
245
        spew('created: %s', test_dir)
246
    except OSError, error:
247
        if error.errno != errno.EEXIST:
248
            raise
249
    else:
250
        # Add the whole directory.
251
        run('bzr', 'add', test_dir)
252
    move_it(old_path, new_path)
253
    dir_path, file_name = os.path.split(old_path)
254
    if file_name.endswith('py') and not file_name.startswith('test_'):
255
        update_helper_imports(old_path, new_path)
256
257
258
def update_helper_imports(old_path, new_path):
259
    """Fix the references to the test helper."""
260
    old_dir_path, file_name = os.path.split(old_path)
261
    old_module_path = file2module(old_dir_path).replace('.', '\\.')
262
    module_name, dummy = os.path.splitext(file_name)
263
    new_module_path = file2module(os.path.dirname(new_path))
264
    source = r'\b%s(\.| import )%s\b' % (old_module_path, module_name)
265
    target = r'%s\1%s' % (new_module_path, module_name)
266
    root_dirs = ['cronscripts', 'lib/canonical', 'lib/lp']
267
    file_pattern = '\.(py|txt|zcml)$'
268
    print source, target
269
    print "    Updating references:"
270
    for root_dir in root_dirs:
271
        for summary in find_matches(
272
            root_dir, file_pattern, source, substitution=target):
273
            print "        * %(file_path)s" % summary
274
275
276
def setup_test_harnesses(app_name):
277
    """Create the doctest harnesses."""
278
    app_path = os.path.join(NEW_TOP, app_name)
279
    doctest_path = os.path.join(app_path, 'doc')
280
    doctests = [file_name
281
                for file_name in os.listdir(doctest_path)
282
                if file_name.endswith('.txt')]
283
    print 'Installing doctest harnesses'
284
    install_doctest_suite(
285
        'test_doc.py', os.path.join(app_path, 'tests'), doctests=doctests)
286
    install_doctest_suite(
287
        'test_views.py', os.path.join(app_path, 'browser', 'tests'))
288
289
290
def install_doctest_suite(file_name, dir_path, doctests=None):
291
    """Copy the simple doctest builder."""
292
    test_doc_path = os.path.join(
293
        os.path.dirname(__file__), file_name)
294
    test_doc_file = open(test_doc_path, 'r')
295
    try:
296
        test_doc = test_doc_file.read()
297
    finally:
298
        test_doc_file.close()
299
    if doctests is not None:
300
        test_doc = test_doc.replace('special = {}', get_special(doctests))
301
    test_doc_path = os.path.join(dir_path, file_name)
302
    if os.path.isfile(test_doc_path):
303
        # This harness was made in a previous run.
304
        print "    Skipping %s, it was made in a previous run" % test_doc_path
305
        return
306
    test_doc_file = open(test_doc_path, 'w')
307
    try:
308
        test_doc_file.write(test_doc)
309
    finally:
310
        test_doc_file.close()
311
    bzr_add([test_doc_path])
312
313
314
def get_special(doctests):
315
    """extract the special setups from test_system_documentation."""
316
    system_doc_lines = []
317
    special_lines = []
318
    doctest_pattern = re.compile(r"^    '(%s)[^']*':" % '|'.join(doctests))
319
    system_doc_path = os.path.join(
320
        OLD_TOP, 'ftests', 'test_system_documentation.py')
321
    system_doc = open(system_doc_path)
322
    try:
323
        in_special = False
324
        for line in system_doc:
325
            match = doctest_pattern.match(line)
326
            if match is not None:
327
                in_special = True
328
                print '    * Extracting special test for %s' % match.group(1)
329
            if in_special:
330
                special_lines.append(line.replace('        ', '    '))
331
            else:
332
                system_doc_lines.append(line)
333
            if in_special and '),' in line:
334
                in_special = False
335
    finally:
336
        system_doc.close()
337
    if len(special_lines) == 0:
338
        # There was nothing to extract.
339
        return 'special = {}'
340
    # Get the setup and teardown functions.
341
    special_lines.insert(0, 'special = {\n')
342
    special_lines.append('    }')
343
    code = ''.join(special_lines)
344
    helper_pattern = re.compile(r'\b(setUp|tearDown)=(\w*)\b')
345
    helpers = set(match.group(2) for match in helper_pattern.finditer(code))
10234.3.7 by Curtis Hovey
updated code per review.
346
    if 'setUp' in helpers:
10234.3.3 by Curtis Hovey
Migrated hardward database to lp. Updated test_doc to run the hwddb test.
347
        helpers.remove('setUp')
10234.3.7 by Curtis Hovey
updated code per review.
348
    if 'tearDown' in helpers:
10234.3.3 by Curtis Hovey
Migrated hardward database to lp. Updated test_doc to run the hwddb test.
349
        helpers.remove('tearDown')
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
350
    # Extract the setup and teardown functions.
351
    lines = list(system_doc_lines)
352
    system_doc_lines = []
353
    helper_lines = []
354
    helper_pattern = re.compile(r'^def (%s)\b' % '|'.join(helpers))
355
    in_helper = False
356
    for line in lines:
357
        if in_helper and len(line) > 1 and line[0] != ' ':
358
            in_helper = False
359
        match = helper_pattern.match(line)
360
        if match is not None:
361
            in_helper = True
362
            print '    * Extracting special function for %s' % match.group(1)
363
        if in_helper:
364
            helper_lines.append(line)
365
        else:
366
            system_doc_lines.append(line)
367
    if len(helper_lines) > 0:
368
        code = ''.join(helper_lines) + code
369
    # Write the smaller test_system_documentation.py.
370
    system_doc = open(system_doc_path, 'w')
371
    try:
372
        system_doc.write(''.join(system_doc_lines))
373
    finally:
374
        system_doc.close()
375
    # Return the local app's specials code.
376
    special_lines.insert(0, 'special = {\n')
377
    special_lines.append('    }')
378
    return code
379
380
381
def handle_py_file(old_path, new_path, subdir):
382
    """Migrate python files."""
383
    if subdir in APP_DIRECTORIES:
384
        # We need the full path, including file name.
385
        move_it(old_path, new_path)
386
        return True
387
    else:
388
        return False
389
390
391
def get_all_module_members(app_name):
392
    """Return a dict of dicts of lists; package, module, members."""
393
    all_members = {}
394
    package_names = ['interfaces', 'model', 'browser', 'components']
395
    member_pattern = r'^(?:class|def) (?P<name>[\w]*)'
396
    for package_name in package_names:
397
        root_dir = os.path.join(NEW_TOP, app_name, package_name)
398
        module_names = {}
399
        for summary in find_matches(root_dir, 'py$', member_pattern):
400
            members = []
401
            for line in summary['lines']:
402
                members.append(line['match'].group('name'))
403
            module_name, dummy = os.path.splitext(
404
                os.path.basename(summary['file_path']))
405
            # Reverse sorting avoids false-positive matches in REs.
406
            module_names[module_name] = sorted(members, reverse=True)
407
        all_members[package_name] = module_names
408
    return all_members
409
410
411
def one_true_import(app_name, all_members):
412
    """Replace glob inports from interfaces to avoid circular imports."""
413
    app_path = os.path.join(NEW_TOP, app_name)
414
    print "Replace glob inports from interfaces to avoid circular imports."
415
    all_interfaces = get_all_interfaces()
416
    for file_path in find_files(app_path, file_pattern='py$'):
417
        fix_file_true_import(file_path, all_interfaces)
418
419
420
def fix_file_true_import(file_path, all_interfaces):
421
    """Fix the interface imports in a file."""
422
    from textwrap import fill
423
    bad_pattern = 'from canonical.launchpad.interfaces import'
424
    delimiters_pattern = re.compile(r'[,()]+')
425
    import_lines = []
426
    content = []
427
    in_import = False
428
    changed = False
429
    file_ = open(file_path, 'r')
430
    try:
431
        for line in file_:
432
            if in_import and len(line) > 1 and line[0] != ' ':
433
                in_import = False
434
                # Build a dict of interfaces used.
435
                bad_import = delimiters_pattern.sub(
436
                    ' ', ''.join(import_lines))
437
                identifiers = bad_import.split()[3:]
438
                modules = {}
439
                for identifier in identifiers:
440
                    if identifier not in all_interfaces:
441
                        print '        * missing %s' % identifier
442
                        continue
443
                    modules.setdefault(
444
                        all_interfaces[identifier], []).append(identifier)
445
                good_imports = []
446
                # Build the import code from the dict.
447
                for module_path in sorted(modules):
448
                    symbols = ', '.join(sorted(modules[module_path]))
449
                    if len(symbols) > 78 - len(bad_pattern):
450
                        symbols = '(\n%s)' % fill(
451
                            symbols, width=78,
452
                            initial_indent='    ', subsequent_indent='    ')
453
                    good_imports.append(
454
                        'from %s import %s\n' % (module_path, symbols))
455
                # Insert the good imports into the module.
456
                content.extend(good_imports)
457
            if line.startswith(bad_pattern):
458
                in_import = True
459
                changed = True
460
                import_lines = []
461
                print '    Fixing interface imports in %s' % file_path
462
            if in_import:
463
                import_lines.append(line)
464
            else:
465
                content.append(line)
466
    finally:
467
        file_.close()
468
    if changed:
469
        file_ = open(file_path, 'w')
470
        try:
471
            file_.write(''.join(content))
472
        finally:
473
            file_.close()
474
475
476
def get_all_interfaces():
477
    """return a dict of interface member and module path."""
478
    # {'IPersonSet', 'lp.registrty.interfaces.person'}
479
    all_interfaces = {}
480
    member_pattern = r'^(?:class |def )*(?P<name>[\w]*)'
481
    for summary in find_matches(
482
        '.', '(canonical|lp)/.*/interfaces.*\.py$', member_pattern):
483
        module_path, dummy = os.path.splitext(summary['file_path'])
484
        module_path = module_path.replace('./lib/', '')
485
        assert module_path.startswith('lp') or module_path.startswith('ca'), (
486
            '!! Bad module path.')
487
        module_path = module_path.replace('/', '.')
488
        for line in summary['lines']:
489
            all_interfaces[line['match'].group('name')] = module_path
490
    return all_interfaces
491
492
493
def handle_templates(app):
494
    """Migrate the page templates referenced in the zcml."""
495
    new_browser_path = os.path.join(NEW_TOP, TLA_MAP[app], 'browser')
496
    new_template_path = os.path.join(NEW_TOP, TLA_MAP[app], 'templates')
497
    templates = set()
498
    missing_templates = []
499
    shared_templates = []
500
    for summary in find_matches(
501
        new_browser_path, '\.zcml$', r'template="\.\./([^"]+)"'):
502
        for line in summary['lines']:
503
            file_name = line['match'].group(1)
504
            templates.add(os.path.join(OLD_TOP, file_name))
505
    # Some views have the template file in the code.
506
    for summary in find_matches(
507
        new_browser_path, '\.py$', r"""\.\./(templates/[^"']+)"""):
508
        for line in summary['lines']:
509
            file_name = line['match'].group(1)
510
            if 'xrds' in file_name:
511
                # xrds files belong to OpenID and account. Fix the single
512
                # reference in the registry tree.
513
                old_template = (
514
                    "../../../canonical/launchpad/templates/person-xrds.pt")
515
                for dummy in find_matches(
516
                    new_browser_path, 'person.py',
517
                    '../templates/person-xrds.pt', substitution=old_template):
518
                    pass
519
                continue
520
            templates.add(os.path.join(OLD_TOP, file_name))
521
    print "Processing templates"
522
    for template_path in templates:
523
        if not os.path.isfile(template_path):
524
            missing_templates.append(template_path)
525
            continue
526
        if is_shared_template(template_path):
527
            shared_templates.append(template_path)
528
            continue
529
        bzr_move_file(template_path, new_template_path)
530
    if len(missing_templates) > 0:
531
        print "zcml references unknown templates:"
532
        for file_path in missing_templates:
533
            print '    %s' % file_path
534
    if len(shared_templates) > 0:
535
        print "Warning: many apps reference these templates (fix by hand):"
536
        for template_path in shared_templates:
537
            file_name = os.path.basename(template_path)
538
            print '    %s' % file_name
539
            # Update the template reference in the browser/*zcml.
540
            pattern = r'(template=")\.\./(templates/%s)"' % file_name
541
            substitution = r'\1../../../canonical/launchpad/\2"'
542
            for summary in find_matches(
543
                new_browser_path, '\.zcml$', pattern,
544
                substitution=substitution):
545
                pass
546
547
548
def is_shared_template(template_path):
549
    """Return true if the template is referenced in the old zcml."""
550
    old_zcml_path = os.path.join(OLD_TOP, 'zcml')
551
    file_name = os.path.basename(template_path)
552
    for dummy in find_matches(old_zcml_path, '\.zcml$', file_name):
553
        return True
554
    return False
555
556
557
def main(ctl_data, apps, opts):
558
    """Migrate applications."""
559
    # Get a dict keyed by app TLA with all files for that app.
560
    app_to_files = convert_ctl_data(ctl_data)
561
562
    if len(apps) == 1 and apps[0] == 'all':
563
        apps = app_to_files.keys()
564
565
    not_moved = []
566
    for app in apps:
567
        if app not in app_to_files:
568
            print "No files tagged for app", app
569
            continue
570
        if app not in TLA_MAP:
571
            print 'Unknown file owner:', app
572
            continue
573
574
        app_name = TLA_MAP[app]
575
        make_tree(app)
576
        if not opts.move:
577
            continue
578
579
        app_files = app_to_files[app]
580
        for fpath in app_files:
581
            if fpath.endswith('.zcml'):
582
                # ZCML is processed after modules are moved.
583
                not_moved.append(fpath)
584
                continue
585
            print "Processing:", fpath
586
            full_path = os.path.join(OLD_TOP, fpath)
587
            if not os.path.exists(full_path):
588
                # The module has already been moved, ignore.
589
                continue
590
            to_path = map_filename(fpath)
591
            path, file_name = os.path.split(to_path)
592
            spew("    to_path = %s", to_path)
593
            new_path_to_dir = os.path.join(NEW_TOP, app_name, path)
9668.6.2 by Curtis Hovey
Fixed errors in migrater.
594
            new_path_to_fn = os.path.join(NEW_TOP, app_name, to_path)
9492.1.1 by Karl Fogel
Add utilities/formatdoctest.py and utilities/migrater/, both brought
595
            # Special cases.
596
            if set(fpath.split(os.sep)) & TEST_PATHS:
597
                handle_test(full_path, new_path_to_fn)
598
                continue
599
            subdir = to_path.split(os.sep)[0]
600
            if subdir in ['scripts']:
601
                handle_script(full_path, new_path_to_dir)
602
                continue
603
            # Only process python modules.
604
            if file_name.endswith('.py'):
605
                if handle_py_file(full_path, new_path_to_fn, subdir):
606
                    continue
607
608
            not_moved.append(fpath)
609
610
        # Create the test harnesses for the moved tests.
611
        setup_test_harnesses(app_name)
612
        # Replace glob imports from interfaces to avoid circular nonsense.
613
        app_members = get_all_module_members(app_name)
614
        one_true_import(app_name, app_members)
615
        # Migrate the zcml.
616
        handle_zcml(
617
            app_name, OLD_TOP, NEW_TOP, app_files, app_members, not_moved)
618
        # Move templates referenced by the moved zcml and view classes.
619
        handle_templates(app)
620
621
        # Warn about the files that weren't moved.
622
        if len(not_moved) > 0:
623
            print ("Warning:  the following did not have a rule to "
624
                   "move them.  They are left unchanged.")
625
            for nm in not_moved:
626
                print "  ", nm
627
        # Warn about collisions.
628
        if len(COLLIDED) > 0:
629
            print ("Warning:  the following files collided and need to be "
630
                   "manually fixed.")
631
            for pow in COLLIDED:
632
                print "  ", pow
633
634
635
if __name__ == '__main__':
636
    parser, opts, args = parse_args()
637
    if len(args) < 2:
638
        parser.error('Control file and at least one app is required')
639
640
    ctl_fn = args[0]
641
    apps = args[1:]
642
643
    ctl_data = open(ctl_fn, 'r').readlines()
644
    main(ctl_data, apps, opts)