Try more to fix git clones.
[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         opts += "--rm "
549
550         tag = self.image
551         if config.has('docker_hub_repository'):
552             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
553
554         def signal_handler(signum, frame):
555             raise Error('Killed')
556         signal.signal(signal.SIGTERM, signal_handler)
557
558         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
559
560     def command(self, cmd):
561         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
562         interactive_flag = '-i ' if sys.stdin.isatty() else ''
563         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))
564
565     def cleanup(self):
566         super(DockerTarget, self).cleanup()
567         command('%s kill %s' % (config.docker(), self.container))
568
569     def mount(self, m):
570         self.mounts.append(m)
571
572
573 class FlatpakTarget(Target):
574     def __init__(self, project, checkout):
575         super(FlatpakTarget, self).__init__('flatpak')
576         self.build_dependencies = False
577         self.project = project
578         self.checkout = checkout
579
580     def setup(self):
581         pass
582
583     def command(self, cmd):
584         command(cmd)
585
586     def checkout_dependencies(self):
587         tree = globals.trees.get(self.project, self.checkout, self)
588         return tree.checkout_dependencies()
589
590     def flatpak(self):
591         return 'flatpak'
592
593     def flatpak_builder(self):
594         b = 'flatpak-builder'
595         if config.has('flatpak_state_dir'):
596             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
597         return b
598
599
600 class WindowsDockerTarget(DockerTarget):
601     """
602     This target exposes the following additional API:
603
604     version: Windows version ('xp' or None)
605     bits: bitness of Windows (32 or 64)
606     name: name of our target e.g. x86_64-w64-mingw32.shared
607     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
608     tool_path: path to 32- and 64-bit tools
609     """
610     def __init__(self, windows_version, bits, directory, environment_version):
611         super(WindowsDockerTarget, self).__init__('windows', directory)
612         self.version = windows_version
613         self.bits = bits
614
615         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
616         if self.bits == 32:
617             self.name = 'i686-w64-mingw32.shared'
618         else:
619             self.name = 'x86_64-w64-mingw32.shared'
620         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
621
622         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
623         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
624         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
625         self.set('LD', '%s-ld' % self.name)
626         self.set('RANLIB', '%s-ranlib' % self.name)
627         self.set('WINRC', '%s-windres' % self.name)
628         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
629         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
630         self.set('CXXFLAGS', '"%s"' % cxx)
631         self.set('CPPFLAGS', '')
632         self.set('LINKFLAGS', '"%s"' % link)
633         self.set('LDFLAGS', '"%s"' % link)
634
635         self.image = 'windows'
636         if environment_version is not None:
637             self.image += '_%s' % environment_version
638
639     def setup(self):
640         super().setup()
641         if self.ccache:
642             self.set('CC', '"ccache %s-gcc"' % self.name)
643             self.set('CXX', '"ccache %s-g++"' % self.name)
644         else:
645             self.set('CC', '%s-gcc' % self.name)
646             self.set('CXX', '%s-g++' % self.name)
647
648     @property
649     def library_prefix(self):
650         log_normal('Deprecated property library_prefix: use environment_prefix')
651         return self.environment_prefix
652
653     @property
654     def windows_prefix(self):
655         log_normal('Deprecated property windows_prefix: use environment_prefix')
656         return self.environment_prefix
657
658     @property
659     def mingw_prefixes(self):
660         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
661         return [self.environment_prefix]
662
663     @property
664     def mingw_path(self):
665         log_normal('Deprecated property mingw_path: use tool_path')
666         return self.tool_path
667
668     @property
669     def mingw_name(self):
670         log_normal('Deprecated property mingw_name: use name')
671         return self.name
672
673
674 class WindowsNativeTarget(Target):
675     """
676     This target exposes the following additional API:
677
678     version: Windows version ('xp' or None)
679     bits: bitness of Windows (32 or 64)
680     name: name of our target e.g. x86_64-w64-mingw32.shared
681     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
682     """
683     def __init__(self, directory):
684         super().__init__('windows', directory)
685         self.version = None
686         self.bits = 64
687
688         self.environment_prefix = config.get('windows_native_environmnet_prefix')
689
690         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
691
692     def command(self, cmd):
693         command(cmd)
694
695
696 class LinuxTarget(DockerTarget):
697     """
698     Build for Linux in a docker container.
699     This target exposes the following additional API:
700
701     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
702     version: distribution version (e.g. '12.04', '8', '6.5')
703     bits: bitness of the distribution (32 or 64)
704     detail: None or 'appimage' if we are building for appimage
705     """
706
707     def __init__(self, distro, version, bits, directory=None):
708         super(LinuxTarget, self).__init__('linux', directory)
709         self.distro = distro
710         self.version = version
711         self.bits = bits
712         self.detail = None
713
714         self.set('CXXFLAGS', '-I%s/include' % self.directory)
715         self.set('CPPFLAGS', '')
716         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
717         self.set('PKG_CONFIG_PATH',
718                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
719         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
720
721         if self.version is None:
722             self.image = '%s-%s' % (self.distro, self.bits)
723         else:
724             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
725
726     def setup(self):
727         super(LinuxTarget, self).setup()
728         if self.ccache:
729             self.set('CC', '"ccache gcc"')
730             self.set('CXX', '"ccache g++"')
731
732     def test(self, project, checkout, target, test, options):
733         self.append_with_colon('PATH', '%s/bin' % self.directory)
734         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
735         super(LinuxTarget, self).test(project, checkout, target, test, options)
736
737
738 class AppImageTarget(LinuxTarget):
739     def __init__(self, work):
740         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
741         self.detail = 'appimage'
742         self.privileged = True
743
744
745 def notarize_dmg(dmg, bundle_id):
746     p = subprocess.run(
747         ['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'],
748         capture_output=True
749         )
750
751     def string_after(process, key):
752         lines = p.stdout.decode('utf-8').splitlines()
753         for i in range(0, len(lines)):
754             if lines[i].find(key) != -1:
755                 return lines[i+1].strip().replace('<string>', '').replace('</string>', '')
756
757     request_uuid = string_after(p, "RequestUUID")
758     if request_uuid is None:
759         print("Looking for upload ID")
760         message = string_after(p, "message")
761         print("Looking in %s" % message)
762         if message:
763             m = re.match('.*The upload ID is ([0-9a-f\-]*)', message)
764             if m:
765                 request_uuid = m.groups()[0]
766     if request_uuid is None:
767         print("Response: %s" % p)
768         raise Error('No RequestUUID found in response from Apple')
769
770     for i in range(0, 30):
771         print('%s: checking up on %s' % (datetime.datetime.now(), request_uuid))
772         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)
773         status = string_after(p, 'Status')
774         print('%s: got status %s' % (datetime.datetime.now(), status))
775         if status == 'invalid':
776             raise Error("Notarization failed")
777         elif status == 'success':
778             subprocess.run(['xcrun', 'stapler', 'staple', dmg])
779             return
780         elif status != "in progress":
781             print("Could not understand xcrun response")
782             print(p)
783         time.sleep(30)
784
785     raise Error("Notarization timed out")
786
787
788 class OSXTarget(Target):
789     def __init__(self, directory=None):
790         super(OSXTarget, self).__init__('osx', directory)
791         self.sdk_prefix = config.get('osx_sdk_prefix')
792         self.environment_prefix = config.get('osx_environment_prefix')
793         self.apple_id = config.get('apple_id')
794         self.apple_password = config.get('apple_password')
795         self.osx_keychain_file = config.get('osx_keychain_file')
796         self.osx_keychain_password = config.get('osx_keychain_password')
797
798     def command(self, c):
799         command('%s %s' % (self.variables_string(False), c))
800
801     def unlock_keychain(self):
802         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
803
804     def _copy_packages(self, tree, packages, output_dir):
805         for p in packages:
806             dest = os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p)))
807             copyfile(p, dest)
808             if os.path.exists(p + ".id"):
809                 copyfile(p + ".id", dest + ".id")
810
811     def _cscript_package_and_notarize(self, tree, options, notarize):
812         """
813         Call package() in the cscript and notarize the .dmgs that are returned, if notarize == True
814         """
815         p = self._cscript_package(tree, options)
816         for x in p:
817             if not isinstance(x, tuple):
818                 raise Error('macOS packages must be returned from cscript as tuples of (dmg-filename, bundle-id)')
819             if notarize:
820                 notarize_dmg(x[0], x[1])
821             else:
822                 with open(x[0] + '.id', 'w') as f:
823                     print(x[1], file=f)
824         return [x[0] for x in p]
825
826
827 class OSXSingleTarget(OSXTarget):
828     def __init__(self, arch, sdk, deployment, directory=None, can_notarize=True):
829         super(OSXSingleTarget, self).__init__(directory)
830         self.arch = arch
831         self.sdk = sdk
832         self.deployment = deployment
833         self.can_notarize = can_notarize
834         self.sub_targets = [self]
835
836         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
837         if arch == 'x86_64':
838             host_enviro = '%s/x86_64/%s' % (config.get('osx_environment_prefix'), deployment)
839         else:
840             host_enviro = '%s/x86_64/10.10' % config.get('osx_environment_prefix')
841         target_enviro = '%s/%s/%s' % (config.get('osx_environment_prefix'), arch, deployment)
842
843         self.bin = '%s/bin' % target_enviro
844
845         # Environment variables
846         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
847         self.set('CPPFLAGS', '')
848         self.set('CXXFLAGS', '"-I%s/include -I%s/include -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
849         self.set('LDFLAGS', '"-L%s/lib -L%s/lib -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
850         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
851         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
852         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
853         self.set('MACOSX_DEPLOYMENT_TARGET', self.deployment)
854         self.set('CCACHE_BASEDIR', self.directory)
855
856     @Target.ccache.setter
857     def ccache(self, v):
858         Target.ccache.fset(self, v)
859         if v:
860             self.set('CC', '"ccache gcc"')
861             self.set('CXX', '"ccache g++"')
862
863     def package(self, project, checkout, output_dir, options, notarize):
864         tree = self.build(project, checkout, options, for_package=True)
865         tree.add_defaults(options)
866         self.unlock_keychain()
867         p = self._cscript_package_and_notarize(tree, options, self.can_notarize and notarize)
868         self._copy_packages(tree, p, output_dir)
869
870
871 class OSXUniversalTarget(OSXTarget):
872     def __init__(self, directory=None):
873         super(OSXUniversalTarget, self).__init__(directory)
874         self.sdk = config.get('osx_sdk')
875         self.sub_targets = []
876         for arch, deployment in (('x86_64', config.get('osx_intel_deployment')), ('arm64', config.get('osx_arm_deployment'))):
877             target = OSXSingleTarget(arch, self.sdk, deployment, os.path.join(self.directory, arch, deployment))
878             target.ccache = self.ccache
879             self.sub_targets.append(target)
880
881     def package(self, project, checkout, output_dir, options, notarize):
882         for target in self.sub_targets:
883             tree = globals.trees.get(project, checkout, target)
884             tree.build_dependencies(options)
885             tree.build(options, for_package=True)
886
887         self.unlock_keychain()
888         tree = globals.trees.get(project, checkout, self)
889         with TreeDirectory(tree):
890             p = self._cscript_package_and_notarize(tree, options, notarize)
891             self._copy_packages(tree, p, output_dir)
892
893 class SourceTarget(Target):
894     """Build a source .tar.bz2"""
895     def __init__(self):
896         super(SourceTarget, self).__init__('source')
897
898     def command(self, c):
899         log_normal('host -> %s' % c)
900         command('%s %s' % (self.variables_string(), c))
901
902     def cleanup(self):
903         rmtree(self.directory)
904
905     def package(self, project, checkout, output_dir, options, notarize):
906         tree = globals.trees.get(project, checkout, self)
907         with TreeDirectory(tree):
908             name = read_wscript_variable(os.getcwd(), 'APPNAME')
909             command('./waf dist')
910             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
911             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p))))
912
913 # @param s Target string:
914 #       windows-{32,64}
915 #    or ubuntu-version-{32,64}
916 #    or debian-version-{32,64}
917 #    or centos-version-{32,64}
918 #    or fedora-version-{32,64}
919 #    or mageia-version-{32,64}
920 #    or osx
921 #    or source
922 #    or flatpak
923 #    or appimage
924 def target_factory(args):
925     s = args.target
926     target = None
927     if s.startswith('windows-'):
928         x = s.split('-')
929         if platform.system() == "Windows":
930             target = WindowsNativeTarget(args.work)
931         else:
932             if len(x) == 2:
933                 target = WindowsDockerTarget(None, int(x[1]), args.work, args.environment_version)
934             elif len(x) == 3:
935                 target = WindowsDockerTarget(x[1], int(x[2]), args.work, args.environment_version)
936             else:
937                 raise Error("Bad Windows target name `%s'")
938     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
939         p = s.split('-')
940         if len(p) != 3:
941             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
942         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
943     elif s.startswith('arch-'):
944         p = s.split('-')
945         if len(p) != 2:
946             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
947         target = LinuxTarget(p[0], None, int(p[1]), args.work)
948     elif s == 'raspbian':
949         target = LinuxTarget(s, None, None, args.work)
950     elif s == 'osx':
951         target = OSXUniversalTarget(args.work)
952     elif s == 'osx-intel':
953         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_intel_deployment'), args.work)
954     elif s == 'osx-old':
955         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_old_deployment'), args.work, False)
956     elif s == 'source':
957         target = SourceTarget()
958     elif s == 'flatpak':
959         target = FlatpakTarget(args.project, args.checkout)
960     elif s == 'appimage':
961         target = AppImageTarget(args.work)
962
963     if target is None:
964         raise Error("Bad target `%s'" % s)
965
966     target.debug = args.debug
967     target.ccache = args.ccache
968
969     if args.environment is not None:
970         for e in args.environment:
971             target.set(e, os.environ[e])
972
973     if args.mount is not None:
974         for m in args.mount:
975             target.mount(m)
976
977     target.setup()
978     return target
979
980
981 #
982 # Tree
983 #
984
985 class Tree(object):
986     """Description of a tree, which is a checkout of a project,
987        possibly built.  This class is never exposed to cscripts.
988        Attributes:
989            name -- name of git repository (without the .git)
990            commit_ish -- git tag or revision to use
991            target -- target object that we are using
992            version -- version from the wscript (if one is present)
993            commit -- git revision that is actually being used
994            built -- true if the tree has been built yet in this run
995            required_by -- name of the tree that requires this one
996     """
997
998     def __init__(self, name, commit_ish, target, required_by, built=False):
999         self.name = name
1000         self.commit_ish = commit_ish
1001         self.target = target
1002         self.version = None
1003         self.commit = None
1004         self.built = built
1005         self.required_by = required_by
1006
1007         cwd = os.getcwd()
1008         proj = '%s/src/%s' % (target.directory, self.name)
1009
1010         if not built:
1011             flags = ''
1012             redirect = ''
1013             if globals.quiet:
1014                 flags = '-q'
1015                 redirect = '>/dev/null'
1016             if config.has('git_reference'):
1017                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
1018             else:
1019                 ref = ''
1020             command('git -c protocol.file.allow=always clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
1021             os.chdir('%s/src/%s' % (target.directory, self.name))
1022
1023             if self.commit_ish is not None:
1024                 command('git checkout %s %s %s' % (flags, self.commit_ish, redirect))
1025             self.commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
1026
1027         self.cscript = {}
1028         exec(open('%s/cscript' % proj).read(), self.cscript)
1029
1030         if not built:
1031             # cscript can include submodules = False to stop submodules being fetched
1032             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1033                 command('git submodule --quiet init')
1034                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1035                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1036                 for path, url in zip(paths, urls):
1037                     ref = ''
1038                     if config.has('git_reference'):
1039                         url = url.split(' ')[1]
1040                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1041                         if os.path.exists(ref_path):
1042                             ref = '--reference %s' % ref_path
1043                     path = path.split(' ')[1]
1044                     command('git -c protocol.file.allow=always submodule --quiet update %s %s' % (ref, path))
1045
1046         if os.path.exists('%s/wscript' % proj):
1047             v = read_wscript_variable(proj, "VERSION");
1048             if v is not None:
1049                 try:
1050                     self.version = Version(v)
1051                 except:
1052                     try:
1053                         tag = command_and_read('git -C %s describe --match v* --tags' % proj)[0][1:]
1054                         self.version = Version.from_git_tag(tag)
1055                     except:
1056                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1057                         # Should probably just install git on the Windows VM
1058                         pass
1059
1060         os.chdir(cwd)
1061
1062     def call(self, function, *args):
1063         with TreeDirectory(self):
1064             return self.cscript[function](self.target, *args)
1065
1066     def add_defaults(self, options):
1067         """Add the defaults from self into a dict options"""
1068         if 'option_defaults' in self.cscript:
1069             from_cscript = self.cscript['option_defaults']
1070             if isinstance(from_cscript, dict):
1071                 defaults_dict = from_cscript
1072             else:
1073                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1074                 defaults_dict = from_cscript()
1075             for k, v in defaults_dict.items():
1076                 if not k in options:
1077                     options[k] = v
1078
1079     def dependencies(self, options):
1080         """
1081         yield details of the dependencies of this tree.  Each dependency is returned
1082         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1083         we want to force for 'self'.
1084         """
1085         if not 'dependencies' in self.cscript:
1086             return
1087
1088         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1089             self_options = copy.copy(options)
1090             self.add_defaults(self_options)
1091             deps = self.call('dependencies', self_options)
1092         else:
1093             log_normal("Deprecated cscript dependencies() method with no options parameter")
1094             deps = self.call('dependencies')
1095
1096         # Loop over our immediate dependencies
1097         for d in deps:
1098             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1099
1100             # deps only get their options from the parent's cscript
1101             dep_options = d[2] if len(d) > 2 else {}
1102             for i in dep.dependencies(dep_options):
1103                 yield i
1104             yield (dep, dep_options, self)
1105
1106     def checkout_dependencies(self, options={}):
1107         for i in self.dependencies(options):
1108             pass
1109
1110     def build_dependencies(self, options):
1111         """
1112         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1113         'options' will be the ones from the command line.
1114         """
1115         for i in self.dependencies(options):
1116             i[0].build(i[1])
1117
1118     def build(self, options, for_package=False):
1119         if self.built:
1120             return
1121
1122         log_verbose("Building %s %s %s with %s" % (self.name, self.commit_ish, self.version, options))
1123
1124         variables = copy.copy(self.target.variables)
1125
1126         options = copy.copy(options)
1127         self.add_defaults(options)
1128
1129         if not globals.dry_run:
1130             num_args = len(inspect.getfullargspec(self.cscript['build']).args)
1131             if num_args == 3:
1132                 self.call('build', options, for_package)
1133             elif num_args == 2:
1134                 self.call('build', options)
1135             else:
1136                 self.call('build')
1137
1138         self.target.variables = variables
1139         self.built = True
1140
1141
1142 #
1143 # Command-line parser
1144 #
1145
1146 def main():
1147
1148     commands = {
1149         "build": "build project",
1150         "package": "build and package the project",
1151         "release": "release a project using its next version number (adding a tag)",
1152         "pot": "build the project's .pot files",
1153         "manual": "build the project's manual",
1154         "doxygen": "build the project's Doxygen documentation",
1155         "latest": "print out the latest version",
1156         "test": "build the project and run its unit tests",
1157         "shell": "start a shell in the project''s work directory",
1158         "checkout": "check out the project",
1159         "revision": "print the head git revision number",
1160         "dependencies" : "print details of the project's dependencies as a .dot file"
1161     }
1162
1163     one_of = ""
1164     summary = ""
1165     for k, v in commands.items():
1166         one_of += "\t%s%s\n" % (k.ljust(20), v)
1167         summary += k + " "
1168
1169     parser = argparse.ArgumentParser()
1170     parser.add_argument('-p', '--project', help='project name')
1171     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1172     parser.add_argument('-o', '--output', help='output directory', default='.')
1173     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1174     parser.add_argument('-t', '--target', help='target', action='append')
1175     parser.add_argument('--environment-version', help='version of environment to use')
1176     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1177     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1178     parser.add_argument('-w', '--work', help='override default work directory')
1179     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1180     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1181     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1182     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1183     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1184     parser.add_argument('--ccache', help='use ccache', action='store_true')
1185     parser.add_argument('--verbose', help='be verbose', action='store_true')
1186
1187     subparsers = parser.add_subparsers(help='command to run', dest='command')
1188     parser_build = subparsers.add_parser("build", help="build project")
1189     parser_package = subparsers.add_parser("package", help="build and package project")
1190     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1191     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1192     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1193     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1194     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1195     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1196     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1197     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1198     parser_latest.add_argument('--major', help='major version to return', type=int)
1199     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1200     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1201     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1202     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1203     parser_shell = subparsers.add_parser("shell", help="build the project then start a shell")
1204     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1205     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1206     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1207     parser_notarize = subparsers.add_parser("notarize", help="notarize .dmgs in a directory using *.dmg.id files")
1208     parser_notarize.add_argument('--dmgs', help='directory containing *.dmg and *.dmg.id')
1209
1210     global args
1211     args = parser.parse_args()
1212
1213     # Check for incorrect multiple parameters
1214     if args.target is not None:
1215         if len(args.target) > 1:
1216             parser.error('multiple -t options specified')
1217             sys.exit(1)
1218         else:
1219             args.target = args.target[0]
1220
1221     # Override configured stuff
1222     if args.git_prefix is not None:
1223         config.set('git_prefix', args.git_prefix)
1224
1225     if args.output.find(':') == -1:
1226         # This isn't of the form host:path so make it absolute
1227         args.output = os.path.abspath(args.output) + '/'
1228     else:
1229         if args.output[-1] != ':' and args.output[-1] != '/':
1230             args.output += '/'
1231
1232     # Now, args.output is 'host:', 'host:path/' or 'path/'
1233
1234     if args.work is not None:
1235         args.work = os.path.abspath(args.work)
1236         if not os.path.exists(args.work):
1237             os.makedirs(args.work)
1238
1239     if args.project is None and not args.command in ['shell', 'notarize']:
1240         raise Error('you must specify -p or --project')
1241
1242     globals.quiet = args.quiet
1243     globals.verbose = args.verbose
1244     globals.dry_run = args.dry_run
1245
1246     if args.command == 'build':
1247         if args.target is None:
1248             raise Error('you must specify -t or --target')
1249
1250         target = target_factory(args)
1251         try:
1252             target.build(args.project, args.checkout, get_command_line_options(args))
1253         finally:
1254             if not args.keep:
1255                 target.cleanup()
1256
1257     elif args.command == 'package':
1258         if args.target is None:
1259             raise Error('you must specify -t or --target')
1260
1261         target = None
1262         try:
1263             target = target_factory(args)
1264
1265             if target.platform == 'linux' and target.detail != "appimage":
1266                 if target.distro != 'arch':
1267                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1268                 else:
1269                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1270             else:
1271                 output_dir = args.output
1272
1273             makedirs(output_dir)
1274             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), not args.no_notarize)
1275         finally:
1276             if target is not None and not args.keep:
1277                 target.cleanup()
1278
1279     elif args.command == 'release':
1280         if args.minor is False and args.micro is False:
1281             raise Error('you must specify --minor or --micro')
1282
1283         target = SourceTarget()
1284         tree = globals.trees.get(args.project, args.checkout, target)
1285
1286         version = tree.version
1287         version.to_release()
1288         if args.minor:
1289             version.bump_minor()
1290         else:
1291             version.bump_micro()
1292
1293         with TreeDirectory(tree):
1294             command('git tag -m "v%s" v%s' % (version, version))
1295             command('git push --tags')
1296
1297         target.cleanup()
1298
1299     elif args.command == 'pot':
1300         target = SourceTarget()
1301         tree = globals.trees.get(args.project, args.checkout, target)
1302
1303         pots = tree.call('make_pot')
1304         for p in pots:
1305             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1306
1307         target.cleanup()
1308
1309     elif args.command == 'manual':
1310         target = SourceTarget()
1311         tree = globals.trees.get(args.project, args.checkout, target)
1312         tree.checkout_dependencies()
1313
1314         outs = tree.call('make_manual')
1315         for o in outs:
1316             if os.path.isfile(o):
1317                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1318             else:
1319                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1320
1321         target.cleanup()
1322
1323     elif args.command == 'doxygen':
1324         target = SourceTarget()
1325         tree = globals.trees.get(args.project, args.checkout, target)
1326
1327         dirs = tree.call('make_doxygen')
1328         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1329             dirs = [dirs]
1330
1331         for d in dirs:
1332             copytree(d, args.output)
1333
1334         target.cleanup()
1335
1336     elif args.command == 'latest':
1337         target = SourceTarget()
1338         tree = globals.trees.get(args.project, args.checkout, target)
1339
1340         with TreeDirectory(tree):
1341             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1342             latest = None
1343             line = 0
1344             while latest is None:
1345                 t = f[line]
1346                 line += 1
1347                 m = re.compile(".*\((.*)\).*").match(t)
1348                 if m:
1349                     tags = m.group(1).split(', ')
1350                     for t in tags:
1351                         s = t.split()
1352                         if len(s) > 1:
1353                             t = s[1]
1354                         if len(t) > 0 and t[0] == 'v':
1355                             v = Version(t[1:])
1356                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1357                                 latest = v
1358
1359         print(latest)
1360         target.cleanup()
1361
1362     elif args.command == 'test':
1363         if args.target is None:
1364             raise Error('you must specify -t or --target')
1365
1366         target = None
1367         try:
1368             target = target_factory(args)
1369             options = get_command_line_options(args)
1370             if args.no_implicit_build:
1371                 globals.trees.add_built(args.project, args.checkout, target)
1372             else:
1373                 target.build(args.project, args.checkout, options)
1374             target.test(args.project, args.checkout, target, args.test, options)
1375         finally:
1376             if target is not None and not args.keep:
1377                 target.cleanup()
1378
1379     elif args.command == 'shell':
1380         if args.target is None:
1381             raise Error('you must specify -t or --target')
1382
1383         target = target_factory(args)
1384         target.command('bash')
1385
1386     elif args.command == 'revision':
1387
1388         target = SourceTarget()
1389         tree = globals.trees.get(args.project, args.checkout, target)
1390         with TreeDirectory(tree):
1391             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1392         target.cleanup()
1393
1394     elif args.command == 'checkout':
1395
1396         if args.output is None:
1397             raise Error('you must specify -o or --output')
1398
1399         target = SourceTarget()
1400         tree = globals.trees.get(args.project, args.checkout, target)
1401         with TreeDirectory(tree):
1402             shutil.copytree('.', args.output)
1403         target.cleanup()
1404
1405     elif args.command == 'dependencies':
1406         if args.target is None:
1407             raise Error('you must specify -t or --target')
1408         if args.checkout is None:
1409             raise Error('you must specify -c or --checkout')
1410
1411         target = target_factory(args)
1412         tree = globals.trees.get(args.project, args.checkout, target)
1413         print("strict digraph {")
1414         for d in list(tree.dependencies({})):
1415             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1416         print("}")
1417
1418     elif args.command == 'notarize':
1419         if args.dmgs is None:
1420             raise Error('you must specify ---dmgs')
1421
1422         for dmg in Path(args.dmgs).glob('*.dmg'):
1423             id = None
1424             try:
1425                 with open(str(dmg) + '.id') as f:
1426                     id = f.readline().strip()
1427             except OSError:
1428                 raise Error('could not find ID file for %s' % dmg)
1429             notarize_dmg(dmg, id)
1430
1431 try:
1432     main()
1433 except Error as e:
1434     print('cdist: %s' % str(e), file=sys.stderr)
1435     sys.exit(1)