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