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