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