Create temp directory if it doesn't exist.
[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 _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)