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