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