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