Kill docker image on SIGTERM.
[cdist.git] / cdist
1 #!/usr/bin/python3
2
3 #    Copyright (C) 2012-2022 Carl Hetherington <cth@carlh.net>
4 #
5 #    This program is free software; you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License as published by
7 #    the Free Software Foundation; either version 2 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program; if not, write to the Free Software
17 #    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
18
19 from __future__ import print_function
20
21 import argparse
22 import copy
23 import datetime
24 import getpass
25 import glob
26 import inspect
27 import multiprocessing
28 import os
29 from pathlib import Path
30 import platform
31 import re
32 import signal
33 import shlex
34 import shutil
35 import subprocess
36 import sys
37 import tempfile
38 import time
39
40 class Error(Exception):
41     def __init__(self, value):
42         self.value = value
43     def __str__(self):
44         return self.value
45     def __repr__(self):
46         return str(self)
47
48 class Trees:
49     """
50     Store for Tree objects which re-uses already-created objects
51     and checks for requests for different versions of the same thing.
52     """
53
54     def __init__(self):
55         self.trees = []
56
57     def get(self, name, commit_ish, target, required_by=None):
58         for t in self.trees:
59             if t.name == name and t.commit_ish == commit_ish and t.target == target:
60                 return t
61             elif t.name == name and t.commit_ish != commit_ish:
62                 a = commit_ish if commit_ish is not None else "[Any]"
63                 if required_by is not None:
64                     a += ' by %s' % required_by
65                 b = t.commit_ish if t.commit_ish is not None else "[Any]"
66                 if t.required_by is not None:
67                     b += ' by %s' % t.required_by
68                 raise Error('conflicting versions of %s required (%s versus %s)' % (name, a, b))
69
70         nt = Tree(name, commit_ish, target, required_by)
71         self.trees.append(nt)
72         return nt
73
74     def add_built(self, name, commit_ish, target):
75         self.trees.append(Tree(name, commit_ish, target, None, built=True))
76
77
78 class Globals:
79     quiet = False
80     command = None
81     dry_run = False
82     trees = Trees()
83
84 globals = Globals()
85
86
87 #
88 # Configuration
89 #
90
91 class Option(object):
92     def __init__(self, key, default=None):
93         self.key = key
94         self.value = default
95
96     def offer(self, key, value):
97         if key == self.key:
98             self.value = value
99
100 class BoolOption(object):
101     def __init__(self, key):
102         self.key = key
103         self.value = False
104
105     def offer(self, key, value):
106         if key == self.key:
107             self.value = value in ['yes', '1', 'true']
108
109 class Config:
110     def __init__(self):
111         self.options = [ Option('mxe_prefix'),
112                          Option('git_prefix'),
113                          Option('git_reference'),
114                          Option('osx_environment_prefix'),
115                          Option('osx_sdk_prefix'),
116                          Option('osx_sdk'),
117                          Option('osx_intel_deployment'),
118                          Option('osx_arm_deployment'),
119                          Option('osx_old_deployment'),
120                          Option('osx_keychain_file'),
121                          Option('osx_keychain_password'),
122                          Option('apple_id'),
123                          Option('apple_password'),
124                          BoolOption('docker_sudo'),
125                          BoolOption('docker_no_user'),
126                          Option('docker_hub_repository'),
127                          Option('flatpak_state_dir'),
128                          Option('parallel', multiprocessing.cpu_count()),
129                          Option('temp', '/var/tmp')]
130
131         config_dir = '%s/.config' % os.path.expanduser('~')
132         if not os.path.exists(config_dir):
133             os.mkdir(config_dir)
134         config_file = '%s/cdist' % config_dir
135         if not os.path.exists(config_file):
136             f = open(config_file, 'w')
137             for o in self.options:
138                 print('# %s ' % o.key, file=f)
139             f.close()
140             print('Template config file written to %s; please edit and try again.' % config_file, file=sys.stderr)
141             sys.exit(1)
142
143         try:
144             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
145             while True:
146                 l = f.readline()
147                 if l == '':
148                     break
149
150                 if len(l) > 0 and l[0] == '#':
151                     continue
152
153                 s = l.strip().split()
154                 if len(s) == 2:
155                     for k in self.options:
156                         k.offer(s[0], s[1])
157         except:
158             raise
159
160     def has(self, k):
161         for o in self.options:
162             if o.key == k and o.value is not None:
163                 return True
164         return False
165
166     def get(self, k):
167         for o in self.options:
168             if o.key == k:
169                 if o.value is None:
170                     raise Error('Required setting %s not found' % k)
171                 return o.value
172
173     def set(self, k, v):
174         for o in self.options:
175             o.offer(k, v)
176
177     def docker(self):
178         if self.get('docker_sudo'):
179             return 'sudo docker'
180         else:
181             return 'docker'
182
183 config = Config()
184
185 #
186 # Utility bits
187 #
188
189 def log_normal(m):
190     if not globals.quiet:
191         print('\x1b[33m* %s\x1b[0m' % m)
192
193 def log_verbose(m):
194     if globals.verbose:
195         print('\x1b[35m* %s\x1b[0m' % m)
196
197 def escape_spaces(s):
198     return s.replace(' ', '\\ ')
199
200 def scp_escape(n):
201     """Escape a host:filename string for use with an scp command"""
202     s = n.split(':')
203     assert(len(s) == 1 or len(s) == 2)
204     if len(s) == 2:
205         return '%s:"\'%s\'"' % (s[0], s[1])
206     else:
207         return '\"%s\"' % s[0]
208
209 def mv_escape(n):
210     return '\"%s\"' % n.substr(' ', '\\ ')
211
212 def copytree(a, b):
213     log_normal('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
214     if b.startswith('s3://'):
215         command('s3cmd -P -r put "%s" "%s"' % (a, b))
216     else:
217         command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
218
219 def copyfile(a, b):
220     log_normal('copy %s -> %s with cwd %s' % (scp_escape(a), scp_escape(b), os.getcwd()))
221     if b.startswith('s3://'):
222         command('s3cmd -P put "%s" "%s"' % (a, b))
223     else:
224         bc = b.find(":")
225         if bc != -1:
226             host = b[:bc]
227             path = b[bc+1:]
228             temp_path = os.path.join(os.path.dirname(path), ".tmp." + os.path.basename(path))
229             command('scp %s %s' % (scp_escape(a), scp_escape(host + ":" + temp_path)))
230             command('ssh %s -- mv "%s" "%s"' % (host, escape_spaces(temp_path), escape_spaces(path)))
231         else:
232             command('scp %s %s' % (scp_escape(a), scp_escape(b)))
233
234 def makedirs(d):
235     """
236     Make directories either locally or on a remote host; remotely if
237     d includes a colon, otherwise locally.
238     """
239     if d.startswith('s3://'):
240         # No need to create folders on S3
241         return
242
243     if d.find(':') == -1:
244         try:
245             os.makedirs(d)
246         except OSError as e:
247             if e.errno != 17:
248                 raise e
249     else:
250         s = d.split(':')
251         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
252
253 def rmdir(a):
254     log_normal('remove %s' % a)
255     os.rmdir(a)
256
257 def rmtree(a):
258     log_normal('remove %s' % a)
259     shutil.rmtree(a, ignore_errors=True)
260
261 def command(c):
262     log_normal(c)
263     try:
264         r = subprocess.run(c, shell=True)
265         if r.returncode != 0:
266             raise Error('command %s failed (%d)' % (c, r.returncode))
267     except Exception as e:
268         raise Error('command %s failed (%s)' % (c, e))
269
270 def command_and_read(c):
271     log_normal(c)
272     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
273     (out, err) = p.communicate()
274     if p.returncode != 0:
275         raise Error('command %s failed (%s)' % (c, err))
276     return str(out, 'utf-8').splitlines()
277
278 def read_wscript_variable(directory, variable):
279     f = open('%s/wscript' % directory, 'r')
280     while True:
281         l = f.readline()
282         if l == '':
283             break
284
285         s = l.split()
286         if len(s) == 3 and s[0] == variable:
287             f.close()
288             return s[2][1:-1]
289
290     f.close()
291     return None
292
293
294 def devel_to_git(git_commit, filename):
295     if git_commit is not None:
296         filename = filename.replace('devel', '-%s' % git_commit)
297     return filename
298
299
300 def get_command_line_options(args):
301     """Get the options specified by --option on the command line"""
302     options = dict()
303     if args.option is not None:
304         for o in args.option:
305             b = o.split(':')
306             if len(b) != 2:
307                 raise Error("Bad option `%s'" % o)
308             if b[1] == 'False':
309                 options[b[0]] = False
310             elif b[1] == 'True':
311                 options[b[0]] = True
312             else:
313                 options[b[0]] = b[1]
314     return options
315
316
317 class TreeDirectory:
318     def __init__(self, tree):
319         self.tree = tree
320     def __enter__(self):
321         self.cwd = os.getcwd()
322         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
323     def __exit__(self, type, value, traceback):
324         os.chdir(self.cwd)
325
326 #
327 # Version
328 #
329
330 class Version:
331     def __init__(self, s):
332         self.devel = False
333
334         if s.startswith("'"):
335             s = s[1:]
336         if s.endswith("'"):
337             s = s[0:-1]
338
339         if s.endswith('devel'):
340             s = s[0:-5]
341             self.devel = True
342
343         if s.endswith('pre'):
344             s = s[0:-3]
345
346         p = s.split('.')
347         self.major = int(p[0])
348         self.minor = int(p[1])
349         if len(p) == 3:
350             self.micro = int(p[2])
351         else:
352             self.micro = 0
353
354     @classmethod
355     def from_git_tag(cls, tag):
356         bits = tag.split('-')
357         c = cls(bits[0])
358         if len(bits) > 1 and int(bits[1]) > 0:
359             c.devel = True
360         return c
361
362     def bump_minor(self):
363         self.minor += 1
364         self.micro = 0
365
366     def bump_micro(self):
367         self.micro += 1
368
369     def to_devel(self):
370         self.devel = True
371
372     def to_release(self):
373         self.devel = False
374
375     def __str__(self):
376         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
377         if self.devel:
378             s += 'devel'
379
380         return s
381
382 #
383 # Targets
384 #
385
386 class Target(object):
387     """
388     Class representing the target that we are building for.  This is exposed to cscripts,
389     though not all of it is guaranteed 'API'.  cscripts may expect:
390
391     platform: platform string (e.g. 'windows', 'linux', 'osx')
392     parallel: number of parallel jobs to run
393     directory: directory to work in
394     variables: dict of environment variables
395     debug: True to build a debug version, otherwise False
396     ccache: True to use ccache, False to not
397     set(a, b): set the value of variable 'a' to 'b'
398     unset(a): unset the value of variable 'a'
399     command(c): run the command 'c' in the build environment
400
401     """
402
403     def __init__(self, platform, directory=None):
404         """
405         platform -- platform string (e.g. 'windows', 'linux', 'osx')
406         directory -- directory to work in; if None we will use a temporary directory
407         Temporary directories will be removed after use; specified directories will not.
408         """
409         self.platform = platform
410         self.parallel = int(config.get('parallel'))
411
412         # Environment variables that we will use when we call cscripts
413         self.variables = {}
414         self.debug = False
415         self._ccache = False
416         # True to build our dependencies ourselves; False if this is taken care
417         # of in some other way
418         self.build_dependencies = True
419
420         if directory is None:
421             self.directory = tempfile.mkdtemp('', 'tmp', config.get('temp'))
422             self.rmdir = True
423             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
424             self.set('CCACHE_NOHASHDIR', '')
425         else:
426             self.directory = os.path.realpath(directory)
427             self.rmdir = False
428
429
430     def setup(self):
431         pass
432
433     def _cscript_package(self, tree, options):
434         """
435         Call package() in the cscript and return what it returns, except that
436         anything not in a list will be put into one.
437         """
438         if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
439             packages = tree.call('package', tree.version, options)
440         else:
441             log_normal("Deprecated cscript package() method with no options parameter")
442             packages = tree.call('package', tree.version)
443
444         return packages if isinstance(packages, list) else [packages]
445
446     def _copy_packages(self, tree, packages, output_dir):
447         for p in packages:
448             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p))))
449
450     def package(self, project, checkout, output_dir, options, notarize):
451         tree = self.build(project, checkout, options, for_package=True)
452         tree.add_defaults(options)
453         p = self._cscript_package(tree, options)
454         self._copy_packages(tree, p, output_dir)
455
456     def build(self, project, checkout, options, for_package=False):
457         tree = globals.trees.get(project, checkout, self)
458         if self.build_dependencies:
459             tree.build_dependencies(options)
460         tree.build(options, for_package=for_package)
461         return tree
462
463     def test(self, project, checkout, target, test, options):
464         """test is the test case to run, or None"""
465         tree = globals.trees.get(project, checkout, target)
466
467         tree.add_defaults(options)
468         with TreeDirectory(tree):
469             if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
470                 return tree.call('test', options, test)
471             else:
472                 log_normal('Deprecated cscript test() method with no options parameter')
473                 return tree.call('test', test)
474
475     def set(self, a, b):
476         self.variables[a] = b
477
478     def unset(self, a):
479         del(self.variables[a])
480
481     def get(self, a):
482         return self.variables[a]
483
484     def append(self, k, v, s):
485         if (not k in self.variables) or len(self.variables[k]) == 0:
486             self.variables[k] = '"%s"' % v
487         else:
488             e = self.variables[k]
489             if e[0] == '"' and e[-1] == '"':
490                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
491             else:
492                 self.variables[k] = '"%s%s%s"' % (e, s, v)
493
494     def append_with_space(self, k, v):
495         return self.append(k, v, ' ')
496
497     def append_with_colon(self, k, v):
498         return self.append(k, v, ':')
499
500     def variables_string(self, escaped_quotes=False):
501         e = ''
502         for k, v in self.variables.items():
503             if escaped_quotes:
504                 v = v.replace('"', '\\"')
505             e += '%s=%s ' % (k, v)
506         return e
507
508     def cleanup(self):
509         if self.rmdir:
510             rmtree(self.directory)
511
512     def mount(self, m):
513         pass
514
515     @property
516     def ccache(self):
517         return self._ccache
518
519     @ccache.setter
520     def ccache(self, v):
521         self._ccache = v
522
523
524 class DockerTarget(Target):
525     def __init__(self, platform, directory):
526         super(DockerTarget, self).__init__(platform, directory)
527         self.mounts = []
528         self.privileged = False
529
530     def _user_tag(self):
531         if config.get('docker_no_user'):
532             return ''
533         return '-u %s' % getpass.getuser()
534
535     def _mount_option(self, d):
536         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
537
538     def setup(self):
539         opts = self._mount_option(self.directory)
540         for m in self.mounts:
541             opts += self._mount_option(m)
542         if config.has('git_reference'):
543             opts += self._mount_option(config.get('git_reference'))
544         if self.privileged:
545             opts += '--privileged=true '
546         if self.ccache:
547             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache" % (self.image, os.getuid())
548
549         tag = self.image
550         if config.has('docker_hub_repository'):
551             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
552
553         def signal_handler(signum, frame):
554             raise Error('Killed')
555         signal.signal(signal.SIGTERM, signal_handler)
556
557         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
558
559     def command(self, cmd):
560         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
561         interactive_flag = '-i ' if sys.stdin.isatty() else ''
562         command('%s exec %s %s -t %s /bin/bash -c \'export %s; cd %s; %s\'' % (config.docker(), self._user_tag(), interactive_flag, self.container, self.variables_string(), dir, cmd))
563
564     def cleanup(self):
565         super(DockerTarget, self).cleanup()
566         command('%s kill %s' % (config.docker(), self.container))
567
568     def mount(self, m):
569         self.mounts.append(m)
570
571
572 class FlatpakTarget(Target):
573     def __init__(self, project, checkout):
574         super(FlatpakTarget, self).__init__('flatpak')
575         self.build_dependencies = False
576         self.project = project
577         self.checkout = checkout
578
579     def setup(self):
580         pass
581
582     def command(self, cmd):
583         command(cmd)
584
585     def checkout_dependencies(self):
586         tree = globals.trees.get(self.project, self.checkout, self)
587         return tree.checkout_dependencies()
588
589     def flatpak(self):
590         return 'flatpak'
591
592     def flatpak_builder(self):
593         b = 'flatpak-builder'
594         if config.has('flatpak_state_dir'):
595             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
596         return b
597
598
599 class WindowsDockerTarget(DockerTarget):
600     """
601     This target exposes the following additional API:
602
603     version: Windows version ('xp' or None)
604     bits: bitness of Windows (32 or 64)
605     name: name of our target e.g. x86_64-w64-mingw32.shared
606     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
607     tool_path: path to 32- and 64-bit tools
608     """
609     def __init__(self, windows_version, bits, directory, environment_version):
610         super(WindowsDockerTarget, self).__init__('windows', directory)
611         self.version = windows_version
612         self.bits = bits
613
614         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
615         if self.bits == 32:
616             self.name = 'i686-w64-mingw32.shared'
617         else:
618             self.name = 'x86_64-w64-mingw32.shared'
619         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
620
621         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
622         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
623         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
624         self.set('LD', '%s-ld' % self.name)
625         self.set('RANLIB', '%s-ranlib' % self.name)
626         self.set('WINRC', '%s-windres' % self.name)
627         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
628         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
629         self.set('CXXFLAGS', '"%s"' % cxx)
630         self.set('CPPFLAGS', '')
631         self.set('LINKFLAGS', '"%s"' % link)
632         self.set('LDFLAGS', '"%s"' % link)
633
634         self.image = 'windows'
635         if environment_version is not None:
636             self.image += '_%s' % environment_version
637
638     def setup(self):
639         super().setup()
640         if self.ccache:
641             self.set('CC', '"ccache %s-gcc"' % self.name)
642             self.set('CXX', '"ccache %s-g++"' % self.name)
643         else:
644             self.set('CC', '%s-gcc' % self.name)
645             self.set('CXX', '%s-g++' % self.name)
646
647     @property
648     def library_prefix(self):
649         log_normal('Deprecated property library_prefix: use environment_prefix')
650         return self.environment_prefix
651
652     @property
653     def windows_prefix(self):
654         log_normal('Deprecated property windows_prefix: use environment_prefix')
655         return self.environment_prefix
656
657     @property
658     def mingw_prefixes(self):
659         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
660         return [self.environment_prefix]
661
662     @property
663     def mingw_path(self):
664         log_normal('Deprecated property mingw_path: use tool_path')
665         return self.tool_path
666
667     @property
668     def mingw_name(self):
669         log_normal('Deprecated property mingw_name: use name')
670         return self.name
671
672
673 class WindowsNativeTarget(Target):
674     """
675     This target exposes the following additional API:
676
677     version: Windows version ('xp' or None)
678     bits: bitness of Windows (32 or 64)
679     name: name of our target e.g. x86_64-w64-mingw32.shared
680     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
681     """
682     def __init__(self, directory):
683         super().__init__('windows', directory)
684         self.version = None
685         self.bits = 64
686
687         self.environment_prefix = config.get('windows_native_environmnet_prefix')
688
689         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
690
691     def command(self, cmd):
692         command(cmd)
693
694
695 class LinuxTarget(DockerTarget):
696     """
697     Build for Linux in a docker container.
698     This target exposes the following additional API:
699
700     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
701     version: distribution version (e.g. '12.04', '8', '6.5')
702     bits: bitness of the distribution (32 or 64)
703     detail: None or 'appimage' if we are building for appimage
704     """
705
706     def __init__(self, distro, version, bits, directory=None):
707         super(LinuxTarget, self).__init__('linux', directory)
708         self.distro = distro
709         self.version = version
710         self.bits = bits
711         self.detail = None
712
713         self.set('CXXFLAGS', '-I%s/include' % self.directory)
714         self.set('CPPFLAGS', '')
715         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
716         self.set('PKG_CONFIG_PATH',
717                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
718         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
719
720         if self.version is None:
721             self.image = '%s-%s' % (self.distro, self.bits)
722         else:
723             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
724
725     def setup(self):
726         super(LinuxTarget, self).setup()
727         if self.ccache:
728             self.set('CC', '"ccache gcc"')
729             self.set('CXX', '"ccache g++"')
730
731     def test(self, project, checkout, target, test, options):
732         self.append_with_colon('PATH', '%s/bin' % self.directory)
733         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
734         super(LinuxTarget, self).test(project, checkout, target, test, options)
735
736
737 class AppImageTarget(LinuxTarget):
738     def __init__(self, work):
739         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
740         self.detail = 'appimage'
741         self.privileged = True
742
743
744 def notarize_dmg(dmg, bundle_id):
745     p = subprocess.run(
746         ['xcrun', 'altool', '--notarize-app', '-t', 'osx', '-f', dmg, '--primary-bundle-id', bundle_id, '-u', config.get('apple_id'), '-p', config.get('apple_password'), '--output-format', 'xml'],
747         capture_output=True
748         )
749
750     def string_after(process, key):
751         lines = p.stdout.decode('utf-8').splitlines()
752         for i in range(0, len(lines)):
753             if lines[i].find(key) != -1:
754                 return lines[i+1].strip().replace('<string>', '').replace('</string>', '')
755
756     request_uuid = string_after(p, "RequestUUID")
757     if request_uuid is None:
758         print("Looking for upload ID")
759         message = string_after(p, "message")
760         print("Looking in %s" % message)
761         if message:
762             m = re.match('.*The upload ID is ([0-9a-f\-]*)', message)
763             if m:
764                 request_uuid = m.groups()[0]
765     if request_uuid is None:
766         print("Response: %s" % p)
767         raise Error('No RequestUUID found in response from Apple')
768
769     for i in range(0, 30):
770         print('%s: checking up on %s' % (datetime.datetime.now(), request_uuid))
771         p = subprocess.run(['xcrun', 'altool', '--notarization-info', request_uuid, '-u', config.get('apple_id'), '-p', config.get('apple_password'), '--output-format', 'xml'], capture_output=True)
772         status = string_after(p, 'Status')
773         print('%s: got status %s' % (datetime.datetime.now(), status))
774         if status == 'invalid':
775             raise Error("Notarization failed")
776         elif status == 'success':
777             subprocess.run(['xcrun', 'stapler', 'staple', dmg])
778             return
779         elif status != "in progress":
780             print("Could not understand xcrun response")
781             print(p)
782         time.sleep(30)
783
784     raise Error("Notarization timed out")
785
786
787 class OSXTarget(Target):
788     def __init__(self, directory=None):
789         super(OSXTarget, self).__init__('osx', directory)
790         self.sdk_prefix = config.get('osx_sdk_prefix')
791         self.environment_prefix = config.get('osx_environment_prefix')
792         self.apple_id = config.get('apple_id')
793         self.apple_password = config.get('apple_password')
794         self.osx_keychain_file = config.get('osx_keychain_file')
795         self.osx_keychain_password = config.get('osx_keychain_password')
796
797     def command(self, c):
798         command('%s %s' % (self.variables_string(False), c))
799
800     def unlock_keychain(self):
801         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
802
803     def _copy_packages(self, tree, packages, output_dir):
804         for p in packages:
805             dest = os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p)))
806             copyfile(p, dest)
807             if os.path.exists(p + ".id"):
808                 copyfile(p + ".id", dest + ".id")
809
810     def _cscript_package_and_notarize(self, tree, options, notarize):
811         """
812         Call package() in the cscript and notarize the .dmgs that are returned, if notarize == True
813         """
814         p = self._cscript_package(tree, options)
815         for x in p:
816             if not isinstance(x, tuple):
817                 raise Error('macOS packages must be returned from cscript as tuples of (dmg-filename, bundle-id)')
818             if notarize:
819                 notarize_dmg(x[0], x[1])
820             else:
821                 with open(x[0] + '.id', 'w') as f:
822                     print(x[1], file=f)
823         return [x[0] for x in p]
824
825
826 class OSXSingleTarget(OSXTarget):
827     def __init__(self, arch, sdk, deployment, directory=None, can_notarize=True):
828         super(OSXSingleTarget, self).__init__(directory)
829         self.arch = arch
830         self.sdk = sdk
831         self.deployment = deployment
832         self.can_notarize = can_notarize
833         self.sub_targets = [self]
834
835         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
836         if arch == 'x86_64':
837             host_enviro = '%s/x86_64/%s' % (config.get('osx_environment_prefix'), deployment)
838         else:
839             host_enviro = '%s/x86_64/10.10' % config.get('osx_environment_prefix')
840         target_enviro = '%s/%s/%s' % (config.get('osx_environment_prefix'), arch, deployment)
841
842         self.bin = '%s/bin' % target_enviro
843
844         # Environment variables
845         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
846         self.set('CPPFLAGS', '')
847         self.set('CXXFLAGS', '"-I%s/include -I%s/include -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
848         self.set('LDFLAGS', '"-L%s/lib -L%s/lib -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
849         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
850         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
851         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
852         self.set('MACOSX_DEPLOYMENT_TARGET', self.deployment)
853         self.set('CCACHE_BASEDIR', self.directory)
854
855     @Target.ccache.setter
856     def ccache(self, v):
857         Target.ccache.fset(self, v)
858         if v:
859             self.set('CC', '"ccache gcc"')
860             self.set('CXX', '"ccache g++"')
861
862     def package(self, project, checkout, output_dir, options, notarize):
863         tree = self.build(project, checkout, options, for_package=True)
864         tree.add_defaults(options)
865         self.unlock_keychain()
866         p = self._cscript_package_and_notarize(tree, options, self.can_notarize and notarize)
867         self._copy_packages(tree, p, output_dir)
868
869
870 class OSXUniversalTarget(OSXTarget):
871     def __init__(self, directory=None):
872         super(OSXUniversalTarget, self).__init__(directory)
873         self.sdk = config.get('osx_sdk')
874         self.sub_targets = []
875         for arch, deployment in (('x86_64', config.get('osx_intel_deployment')), ('arm64', config.get('osx_arm_deployment'))):
876             target = OSXSingleTarget(arch, self.sdk, deployment, os.path.join(self.directory, arch, deployment))
877             target.ccache = self.ccache
878             self.sub_targets.append(target)
879
880     def package(self, project, checkout, output_dir, options, notarize):
881         for target in self.sub_targets:
882             tree = globals.trees.get(project, checkout, target)
883             tree.build_dependencies(options)
884             tree.build(options, for_package=True)
885
886         self.unlock_keychain()
887         tree = globals.trees.get(project, checkout, self)
888         with TreeDirectory(tree):
889             p = self._cscript_package_and_notarize(tree, options, notarize)
890             self._copy_packages(tree, p, output_dir)
891
892 class SourceTarget(Target):
893     """Build a source .tar.bz2"""
894     def __init__(self):
895         super(SourceTarget, self).__init__('source')
896
897     def command(self, c):
898         log_normal('host -> %s' % c)
899         command('%s %s' % (self.variables_string(), c))
900
901     def cleanup(self):
902         rmtree(self.directory)
903
904     def package(self, project, checkout, output_dir, options, notarize):
905         tree = globals.trees.get(project, checkout, self)
906         with TreeDirectory(tree):
907             name = read_wscript_variable(os.getcwd(), 'APPNAME')
908             command('./waf dist')
909             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
910             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p))))
911
912 # @param s Target string:
913 #       windows-{32,64}
914 #    or ubuntu-version-{32,64}
915 #    or debian-version-{32,64}
916 #    or centos-version-{32,64}
917 #    or fedora-version-{32,64}
918 #    or mageia-version-{32,64}
919 #    or osx
920 #    or source
921 #    or flatpak
922 #    or appimage
923 def target_factory(args):
924     s = args.target
925     target = None
926     if s.startswith('windows-'):
927         x = s.split('-')
928         if platform.system() == "Windows":
929             target = WindowsNativeTarget(args.work)
930         else:
931             if len(x) == 2:
932                 target = WindowsDockerTarget(None, int(x[1]), args.work, args.environment_version)
933             elif len(x) == 3:
934                 target = WindowsDockerTarget(x[1], int(x[2]), args.work, args.environment_version)
935             else:
936                 raise Error("Bad Windows target name `%s'")
937     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
938         p = s.split('-')
939         if len(p) != 3:
940             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
941         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
942     elif s.startswith('arch-'):
943         p = s.split('-')
944         if len(p) != 2:
945             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
946         target = LinuxTarget(p[0], None, int(p[1]), args.work)
947     elif s == 'raspbian':
948         target = LinuxTarget(s, None, None, args.work)
949     elif s == 'osx':
950         target = OSXUniversalTarget(args.work)
951     elif s == 'osx-intel':
952         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_intel_deployment'), args.work)
953     elif s == 'osx-old':
954         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_old_deployment'), args.work, False)
955     elif s == 'source':
956         target = SourceTarget()
957     elif s == 'flatpak':
958         target = FlatpakTarget(args.project, args.checkout)
959     elif s == 'appimage':
960         target = AppImageTarget(args.work)
961
962     if target is None:
963         raise Error("Bad target `%s'" % s)
964
965     target.debug = args.debug
966     target.ccache = args.ccache
967
968     if args.environment is not None:
969         for e in args.environment:
970             target.set(e, os.environ[e])
971
972     if args.mount is not None:
973         for m in args.mount:
974             target.mount(m)
975
976     target.setup()
977     return target
978
979
980 #
981 # Tree
982 #
983
984 class Tree(object):
985     """Description of a tree, which is a checkout of a project,
986        possibly built.  This class is never exposed to cscripts.
987        Attributes:
988            name -- name of git repository (without the .git)
989            commit_ish -- git tag or revision to use
990            target -- target object that we are using
991            version -- version from the wscript (if one is present)
992            commit -- git revision that is actually being used
993            built -- true if the tree has been built yet in this run
994            required_by -- name of the tree that requires this one
995     """
996
997     def __init__(self, name, commit_ish, target, required_by, built=False):
998         self.name = name
999         self.commit_ish = commit_ish
1000         self.target = target
1001         self.version = None
1002         self.commit = None
1003         self.built = built
1004         self.required_by = required_by
1005
1006         cwd = os.getcwd()
1007         proj = '%s/src/%s' % (target.directory, self.name)
1008
1009         if not built:
1010             flags = ''
1011             redirect = ''
1012             if globals.quiet:
1013                 flags = '-q'
1014                 redirect = '>/dev/null'
1015             if config.has('git_reference'):
1016                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
1017             else:
1018                 ref = ''
1019             command('git clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
1020             os.chdir('%s/src/%s' % (target.directory, self.name))
1021
1022             if self.commit_ish is not None:
1023                 command('git checkout %s %s %s' % (flags, self.commit_ish, redirect))
1024             self.commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
1025
1026         self.cscript = {}
1027         exec(open('%s/cscript' % proj).read(), self.cscript)
1028
1029         if not built:
1030             # cscript can include submodules = False to stop submodules being fetched
1031             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1032                 command('git submodule --quiet init')
1033                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1034                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1035                 for path, url in zip(paths, urls):
1036                     ref = ''
1037                     if config.has('git_reference'):
1038                         url = url.split(' ')[1]
1039                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1040                         if os.path.exists(ref_path):
1041                             ref = '--reference %s' % ref_path
1042                     path = path.split(' ')[1]
1043                     command('git submodule --quiet update %s %s' % (ref, path))
1044
1045         if os.path.exists('%s/wscript' % proj):
1046             v = read_wscript_variable(proj, "VERSION");
1047             if v is not None:
1048                 try:
1049                     self.version = Version(v)
1050                 except:
1051                     try:
1052                         tag = command_and_read('git -C %s describe --match v* --tags' % proj)[0][1:]
1053                         self.version = Version.from_git_tag(tag)
1054                     except:
1055                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1056                         # Should probably just install git on the Windows VM
1057                         pass
1058
1059         os.chdir(cwd)
1060
1061     def call(self, function, *args):
1062         with TreeDirectory(self):
1063             return self.cscript[function](self.target, *args)
1064
1065     def add_defaults(self, options):
1066         """Add the defaults from self into a dict options"""
1067         if 'option_defaults' in self.cscript:
1068             from_cscript = self.cscript['option_defaults']
1069             if isinstance(from_cscript, dict):
1070                 defaults_dict = from_cscript
1071             else:
1072                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1073                 defaults_dict = from_cscript()
1074             for k, v in defaults_dict.items():
1075                 if not k in options:
1076                     options[k] = v
1077
1078     def dependencies(self, options):
1079         """
1080         yield details of the dependencies of this tree.  Each dependency is returned
1081         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1082         we want to force for 'self'.
1083         """
1084         if not 'dependencies' in self.cscript:
1085             return
1086
1087         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1088             self_options = copy.copy(options)
1089             self.add_defaults(self_options)
1090             deps = self.call('dependencies', self_options)
1091         else:
1092             log_normal("Deprecated cscript dependencies() method with no options parameter")
1093             deps = self.call('dependencies')
1094
1095         # Loop over our immediate dependencies
1096         for d in deps:
1097             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1098
1099             # deps only get their options from the parent's cscript
1100             dep_options = d[2] if len(d) > 2 else {}
1101             for i in dep.dependencies(dep_options):
1102                 yield i
1103             yield (dep, dep_options, self)
1104
1105     def checkout_dependencies(self, options={}):
1106         for i in self.dependencies(options):
1107             pass
1108
1109     def build_dependencies(self, options):
1110         """
1111         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1112         'options' will be the ones from the command line.
1113         """
1114         for i in self.dependencies(options):
1115             i[0].build(i[1])
1116
1117     def build(self, options, for_package=False):
1118         if self.built:
1119             return
1120
1121         log_verbose("Building %s %s %s with %s" % (self.name, self.commit_ish, self.version, options))
1122
1123         variables = copy.copy(self.target.variables)
1124
1125         options = copy.copy(options)
1126         self.add_defaults(options)
1127
1128         if not globals.dry_run:
1129             num_args = len(inspect.getfullargspec(self.cscript['build']).args)
1130             if num_args == 3:
1131                 self.call('build', options, for_package)
1132             elif num_args == 2:
1133                 self.call('build', options)
1134             else:
1135                 self.call('build')
1136
1137         self.target.variables = variables
1138         self.built = True
1139
1140
1141 #
1142 # Command-line parser
1143 #
1144
1145 def main():
1146
1147     commands = {
1148         "build": "build project",
1149         "package": "build and package the project",
1150         "release": "release a project using its next version number (adding a tag)",
1151         "pot": "build the project's .pot files",
1152         "manual": "build the project's manual",
1153         "doxygen": "build the project's Doxygen documentation",
1154         "latest": "print out the latest version",
1155         "test": "build the project and run its unit tests",
1156         "shell": "start a shell in the project''s work directory",
1157         "checkout": "check out the project",
1158         "revision": "print the head git revision number",
1159         "dependencies" : "print details of the project's dependencies as a .dot file"
1160     }
1161
1162     one_of = ""
1163     summary = ""
1164     for k, v in commands.items():
1165         one_of += "\t%s%s\n" % (k.ljust(20), v)
1166         summary += k + " "
1167
1168     parser = argparse.ArgumentParser()
1169     parser.add_argument('-p', '--project', help='project name')
1170     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1171     parser.add_argument('-o', '--output', help='output directory', default='.')
1172     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1173     parser.add_argument('-t', '--target', help='target', action='append')
1174     parser.add_argument('--environment-version', help='version of environment to use')
1175     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1176     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1177     parser.add_argument('-w', '--work', help='override default work directory')
1178     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1179     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1180     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1181     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1182     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1183     parser.add_argument('--ccache', help='use ccache', action='store_true')
1184     parser.add_argument('--verbose', help='be verbose', action='store_true')
1185
1186     subparsers = parser.add_subparsers(help='command to run', dest='command')
1187     parser_build = subparsers.add_parser("build", help="build project")
1188     parser_package = subparsers.add_parser("package", help="build and package project")
1189     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1190     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1191     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1192     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1193     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1194     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1195     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1196     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1197     parser_latest.add_argument('--major', help='major version to return', type=int)
1198     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1199     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1200     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1201     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1202     parser_shell = subparsers.add_parser("shell", help="build the project then start a shell")
1203     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1204     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1205     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1206     parser_notarize = subparsers.add_parser("notarize", help="notarize .dmgs in a directory using *.dmg.id files")
1207     parser_notarize.add_argument('--dmgs', help='directory containing *.dmg and *.dmg.id')
1208
1209     global args
1210     args = parser.parse_args()
1211
1212     # Check for incorrect multiple parameters
1213     if args.target is not None:
1214         if len(args.target) > 1:
1215             parser.error('multiple -t options specified')
1216             sys.exit(1)
1217         else:
1218             args.target = args.target[0]
1219
1220     # Override configured stuff
1221     if args.git_prefix is not None:
1222         config.set('git_prefix', args.git_prefix)
1223
1224     if args.output.find(':') == -1:
1225         # This isn't of the form host:path so make it absolute
1226         args.output = os.path.abspath(args.output) + '/'
1227     else:
1228         if args.output[-1] != ':' and args.output[-1] != '/':
1229             args.output += '/'
1230
1231     # Now, args.output is 'host:', 'host:path/' or 'path/'
1232
1233     if args.work is not None:
1234         args.work = os.path.abspath(args.work)
1235         if not os.path.exists(args.work):
1236             os.makedirs(args.work)
1237
1238     if args.project is None and not args.command in ['shell', 'notarize']:
1239         raise Error('you must specify -p or --project')
1240
1241     globals.quiet = args.quiet
1242     globals.verbose = args.verbose
1243     globals.dry_run = args.dry_run
1244
1245     if args.command == 'build':
1246         if args.target is None:
1247             raise Error('you must specify -t or --target')
1248
1249         target = target_factory(args)
1250         try:
1251             target.build(args.project, args.checkout, get_command_line_options(args))
1252         finally:
1253             if not args.keep:
1254                 target.cleanup()
1255
1256     elif args.command == 'package':
1257         if args.target is None:
1258             raise Error('you must specify -t or --target')
1259
1260         target = None
1261         try:
1262             target = target_factory(args)
1263
1264             if target.platform == 'linux' and target.detail != "appimage":
1265                 if target.distro != 'arch':
1266                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1267                 else:
1268                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1269             else:
1270                 output_dir = args.output
1271
1272             makedirs(output_dir)
1273             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), not args.no_notarize)
1274         finally:
1275             if target is not None and not args.keep:
1276                 target.cleanup()
1277
1278     elif args.command == 'release':
1279         if args.minor is False and args.micro is False:
1280             raise Error('you must specify --minor or --micro')
1281
1282         target = SourceTarget()
1283         tree = globals.trees.get(args.project, args.checkout, target)
1284
1285         version = tree.version
1286         version.to_release()
1287         if args.minor:
1288             version.bump_minor()
1289         else:
1290             version.bump_micro()
1291
1292         with TreeDirectory(tree):
1293             command('git tag -m "v%s" v%s' % (version, version))
1294             command('git push --tags')
1295
1296         target.cleanup()
1297
1298     elif args.command == 'pot':
1299         target = SourceTarget()
1300         tree = globals.trees.get(args.project, args.checkout, target)
1301
1302         pots = tree.call('make_pot')
1303         for p in pots:
1304             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1305
1306         target.cleanup()
1307
1308     elif args.command == 'manual':
1309         target = SourceTarget()
1310         tree = globals.trees.get(args.project, args.checkout, target)
1311         tree.checkout_dependencies()
1312
1313         outs = tree.call('make_manual')
1314         for o in outs:
1315             if os.path.isfile(o):
1316                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1317             else:
1318                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1319
1320         target.cleanup()
1321
1322     elif args.command == 'doxygen':
1323         target = SourceTarget()
1324         tree = globals.trees.get(args.project, args.checkout, target)
1325
1326         dirs = tree.call('make_doxygen')
1327         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1328             dirs = [dirs]
1329
1330         for d in dirs:
1331             copytree(d, args.output)
1332
1333         target.cleanup()
1334
1335     elif args.command == 'latest':
1336         target = SourceTarget()
1337         tree = globals.trees.get(args.project, args.checkout, target)
1338
1339         with TreeDirectory(tree):
1340             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1341             latest = None
1342             line = 0
1343             while latest is None:
1344                 t = f[line]
1345                 line += 1
1346                 m = re.compile(".*\((.*)\).*").match(t)
1347                 if m:
1348                     tags = m.group(1).split(', ')
1349                     for t in tags:
1350                         s = t.split()
1351                         if len(s) > 1:
1352                             t = s[1]
1353                         if len(t) > 0 and t[0] == 'v':
1354                             v = Version(t[1:])
1355                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1356                                 latest = v
1357
1358         print(latest)
1359         target.cleanup()
1360
1361     elif args.command == 'test':
1362         if args.target is None:
1363             raise Error('you must specify -t or --target')
1364
1365         target = None
1366         try:
1367             target = target_factory(args)
1368             options = get_command_line_options(args)
1369             if args.no_implicit_build:
1370                 globals.trees.add_built(args.project, args.checkout, target)
1371             else:
1372                 target.build(args.project, args.checkout, options)
1373             target.test(args.project, args.checkout, target, args.test, options)
1374         finally:
1375             if target is not None and not args.keep:
1376                 target.cleanup()
1377
1378     elif args.command == 'shell':
1379         if args.target is None:
1380             raise Error('you must specify -t or --target')
1381
1382         target = target_factory(args)
1383         target.command('bash')
1384
1385     elif args.command == 'revision':
1386
1387         target = SourceTarget()
1388         tree = globals.trees.get(args.project, args.checkout, target)
1389         with TreeDirectory(tree):
1390             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1391         target.cleanup()
1392
1393     elif args.command == 'checkout':
1394
1395         if args.output is None:
1396             raise Error('you must specify -o or --output')
1397
1398         target = SourceTarget()
1399         tree = globals.trees.get(args.project, args.checkout, target)
1400         with TreeDirectory(tree):
1401             shutil.copytree('.', args.output)
1402         target.cleanup()
1403
1404     elif args.command == 'dependencies':
1405         if args.target is None:
1406             raise Error('you must specify -t or --target')
1407         if args.checkout is None:
1408             raise Error('you must specify -c or --checkout')
1409
1410         target = target_factory(args)
1411         tree = globals.trees.get(args.project, args.checkout, target)
1412         print("strict digraph {")
1413         for d in list(tree.dependencies({})):
1414             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1415         print("}")
1416
1417     elif args.command == 'notarize':
1418         if args.dmgs is None:
1419             raise Error('you must specify ---dmgs')
1420
1421         for dmg in Path(args.dmgs).glob('*.dmg'):
1422             id = None
1423             try:
1424                 with open(str(dmg) + '.id') as f:
1425                     id = f.readline().strip()
1426             except OSError:
1427                 raise Error('could not find ID file for %s' % dmg)
1428             notarize_dmg(dmg, id)
1429
1430 try:
1431     main()
1432 except Error as e:
1433     print('cdist: %s' % str(e), file=sys.stderr)
1434     sys.exit(1)