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