Fix LocalTarget to work better (mostly making it work like a normal Linux target).
[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, dependencies_only=False):
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         self.dependencies_only = dependencies_only
424
425         if directory is None:
426             try:
427                 os.makedirs(config.get('temp'))
428             except OSError as e:
429                 if e.errno != 17:
430                     raise e
431             self.directory = tempfile.mkdtemp('', 'tmp', config.get('temp'))
432             self.rmdir = True
433             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
434             self.set('CCACHE_NOHASHDIR', '')
435         else:
436             self.directory = os.path.realpath(directory)
437             self.rmdir = False
438
439
440     def setup(self):
441         pass
442
443     def _cscript_package(self, tree, options):
444         """
445         Call package() in the cscript and return what it returns, except that
446         anything not in a list will be put into one.
447         """
448         if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
449             packages = tree.call('package', tree.version, options)
450         else:
451             log_normal("Deprecated cscript package() method with no options parameter")
452             packages = tree.call('package', tree.version)
453
454         return packages if isinstance(packages, list) else [packages]
455
456     def _copy_packages(self, tree, packages, output_dir):
457         for p in packages:
458             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p))))
459
460     def package(self, project, checkout, output_dir, options, notarize):
461         tree = self.build(project, checkout, options, for_package=True)
462         tree.add_defaults(options)
463         p = self._cscript_package(tree, options)
464         self._copy_packages(tree, p, output_dir)
465
466     def build(self, project, checkout, options, for_package=False):
467         tree = globals.trees.get(project, checkout, self)
468         if self.build_dependencies:
469             tree.build_dependencies(options)
470         if not self.dependencies_only:
471             tree.build(options, for_package=for_package)
472         return tree
473
474     def test(self, project, checkout, target, test, options):
475         """test is the test case to run, or None"""
476         tree = globals.trees.get(project, checkout, target)
477
478         tree.add_defaults(options)
479         with TreeDirectory(tree):
480             if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
481                 return tree.call('test', options, test)
482             else:
483                 log_normal('Deprecated cscript test() method with no options parameter')
484                 return tree.call('test', test)
485
486     def set(self, a, b):
487         self.variables[a] = b
488
489     def unset(self, a):
490         del(self.variables[a])
491
492     def get(self, a):
493         return self.variables[a]
494
495     def append(self, k, v, s):
496         if (not k in self.variables) or len(self.variables[k]) == 0:
497             self.variables[k] = '"%s"' % v
498         else:
499             e = self.variables[k]
500             if e[0] == '"' and e[-1] == '"':
501                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
502             else:
503                 self.variables[k] = '"%s%s%s"' % (e, s, v)
504
505     def append_with_space(self, k, v):
506         return self.append(k, v, ' ')
507
508     def append_with_colon(self, k, v):
509         return self.append(k, v, ':')
510
511     def variables_string(self, escaped_quotes=False):
512         e = ''
513         for k, v in self.variables.items():
514             if escaped_quotes:
515                 v = v.replace('"', '\\"')
516             e += '%s=%s ' % (k, v)
517         return e
518
519     def cleanup(self):
520         if self.rmdir:
521             rmtree(self.directory)
522
523     def mount(self, m):
524         pass
525
526     @property
527     def ccache(self):
528         return self._ccache
529
530     @ccache.setter
531     def ccache(self, v):
532         self._ccache = v
533
534
535 class DockerTarget(Target):
536     def __init__(self, platform, directory):
537         super(DockerTarget, self).__init__(platform, directory)
538         self.mounts = []
539         self.privileged = False
540
541     def _user_tag(self):
542         if config.get('docker_no_user'):
543             return ''
544         return '-u %s' % getpass.getuser()
545
546     def _mount_option(self, d):
547         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
548
549     def setup(self):
550         opts = self._mount_option(self.directory)
551         for m in self.mounts:
552             opts += self._mount_option(m)
553         if config.has('git_reference'):
554             opts += self._mount_option(config.get('git_reference'))
555         if self.privileged:
556             opts += '--privileged=true '
557         if self.ccache:
558             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache " % (self.image, os.getuid())
559         opts += "--rm "
560
561         tag = self.image
562         if config.has('docker_hub_repository'):
563             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
564
565         def signal_handler(signum, frame):
566             raise Error('Killed')
567         signal.signal(signal.SIGTERM, signal_handler)
568
569         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
570
571     def command(self, cmd):
572         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
573         interactive_flag = '-i ' if sys.stdin.isatty() else ''
574         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))
575
576     def cleanup(self):
577         super(DockerTarget, self).cleanup()
578         command('%s kill %s' % (config.docker(), self.container))
579
580     def mount(self, m):
581         self.mounts.append(m)
582
583
584 class WindowsDockerTarget(DockerTarget):
585     """
586     This target exposes the following additional API:
587
588     bits: bitness of Windows (32 or 64)
589     name: name of our target e.g. x86_64-w64-mingw32.shared
590     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
591     tool_path: path to 32- and 64-bit tools
592     """
593     def __init__(self, bits, directory, environment_version):
594         super(WindowsDockerTarget, self).__init__('windows', directory)
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     bits: bitness of Windows (32 or 64)
661     name: name of our target e.g. x86_64-w64-mingw32.shared
662     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
663     """
664     def __init__(self, directory):
665         super().__init__('windows', directory)
666         self.bits = 64
667
668         self.environment_prefix = config.get('windows_native_environmnet_prefix')
669
670         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
671
672     def command(self, cmd):
673         command(cmd)
674
675
676 class LinuxTarget(DockerTarget):
677     """
678     Build for Linux in a docker container.
679     This target exposes the following additional API:
680
681     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
682     version: distribution version (e.g. '12.04', '8', '6.5')
683     bits: bitness of the distribution (32 or 64)
684     detail: None or 'appimage' if we are building for appimage
685     """
686
687     def __init__(self, distro, version, bits, directory=None):
688         super(LinuxTarget, self).__init__('linux', directory)
689         self.distro = distro
690         self.version = version
691         self.bits = bits
692         self.detail = None
693
694         self.set('CXXFLAGS', '-I%s/include' % self.directory)
695         self.set('CPPFLAGS', '')
696         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
697         self.set('PKG_CONFIG_PATH',
698                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
699         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
700
701         if self.version is None:
702             self.image = '%s-%s' % (self.distro, self.bits)
703         else:
704             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
705
706     def setup(self):
707         super(LinuxTarget, self).setup()
708         if self.ccache:
709             self.set('CC', '"ccache gcc"')
710             self.set('CXX', '"ccache g++"')
711
712     def test(self, project, checkout, target, test, options):
713         self.append_with_colon('PATH', '%s/bin' % self.directory)
714         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
715         super(LinuxTarget, self).test(project, checkout, target, test, options)
716
717
718 class AppImageTarget(LinuxTarget):
719     def __init__(self, work):
720         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
721         self.detail = 'appimage'
722         self.privileged = True
723
724
725 class FlatpakTarget(Target):
726     def __init__(self, project, checkout, work):
727         super(FlatpakTarget, self).__init__('flatpak')
728         self.build_dependencies = False
729         self.project = project
730         self.checkout = checkout
731         # If we use git references we end up with a checkout in one mount trying
732         # to link to the git reference repo in other, which doesn't work.
733         globals.use_git_reference = False
734         if config.has('flatpak_state_dir'):
735             self.mount(config.get('flatpak_state_dir'))
736
737     def command(self, c):
738         log_normal('host -> %s' % c)
739         command('%s %s' % (self.variables_string(), c))
740
741     def setup(self):
742         super().setup()
743         globals.trees.get(self.project, self.checkout, self).checkout_dependencies()
744
745     def flatpak(self):
746         return 'flatpak'
747
748     def flatpak_builder(self):
749         b = 'flatpak-builder'
750         if config.has('flatpak_state_dir'):
751             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
752         return b
753
754
755 def notarize_dmg(dmg):
756     p = subprocess.run(
757             config.get('osx_notarytool') + [
758             'submit',
759             '--apple-id',
760             config.get('apple_id'),
761             '--password',
762             config.get('apple_password'),
763             '--team-id',
764             config.get('apple_team_id'),
765             '--wait',
766             dmg
767         ], capture_output=True)
768
769     last_line = [x.strip() for x in p.stdout.decode('utf-8').splitlines() if x.strip()][-1]
770     if last_line != 'status: Accepted':
771         print("Could not understand notarytool response")
772         print(p)
773         print(f"Last line: {last_line}")
774         raise Error('Notarization failed')
775
776     subprocess.run(['xcrun', 'stapler', 'staple', dmg])
777
778
779 class OSXTarget(Target):
780     def __init__(self, directory=None):
781         super(OSXTarget, self).__init__('osx', directory)
782         self.sdk_prefix = config.get('osx_sdk_prefix')
783         self.environment_prefix = config.get('osx_environment_prefix')
784         self.apple_id = config.get('apple_id')
785         self.apple_password = config.get('apple_password')
786         self.osx_keychain_file = config.get('osx_keychain_file')
787         self.osx_keychain_password = config.get('osx_keychain_password')
788
789     def command(self, c):
790         command('%s %s' % (self.variables_string(False), c))
791
792     def unlock_keychain(self):
793         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
794
795     def package(self, project, checkout, output_dir, options, notarize):
796         self.unlock_keychain()
797         tree = globals.trees.get(project, checkout, self)
798         with TreeDirectory(tree):
799             p = self._cscript_package_and_notarize(tree, options, self.can_notarize and notarize)
800             self._copy_packages(tree, p, output_dir)
801
802     def _copy_packages(self, tree, packages, output_dir):
803         for p in packages:
804             dest = os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p)))
805             copyfile(p, dest)
806
807     def _cscript_package_and_notarize(self, tree, options, notarize):
808         """
809         Call package() in the cscript and notarize the .dmgs that are returned, if notarize == True
810         """
811         output = []
812         for x in self._cscript_package(tree, options):
813             # Some older cscripts give us the DMG filename and the bundle ID, even though
814             # (since using notarytool instead of altool for notarization) the bundle ID
815             # is no longer necessary.  Cope with either type of cscript.
816             dmg = x[0] if isinstance(x, tuple) else x
817             if notarize:
818                 notarize_dmg(dmg)
819             output.append(dmg)
820         return output
821
822
823 class OSXSingleTarget(OSXTarget):
824     def __init__(self, arch, sdk, deployment, directory=None, can_notarize=True):
825         super(OSXSingleTarget, self).__init__(directory)
826         self.arch = arch
827         self.sdk = sdk
828         self.deployment = deployment
829         self.can_notarize = can_notarize
830         self.sub_targets = [self]
831
832         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
833         if arch == 'x86_64':
834             host_enviro = '%s/x86_64/%s' % (config.get('osx_environment_prefix'), deployment)
835         else:
836             host_enviro = '%s/x86_64/10.10' % config.get('osx_environment_prefix')
837         target_enviro = '%s/%s/%s' % (config.get('osx_environment_prefix'), arch, deployment)
838
839         self.bin = '%s/bin' % target_enviro
840
841         # Environment variables
842         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
843         self.set('CPPFLAGS', '')
844         self.set('CXXFLAGS', '"-I%s/include -I%s/include -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
845         self.set('LDFLAGS', '"-L%s/lib -L%s/lib -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
846         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
847         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
848         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
849         self.set('MACOSX_DEPLOYMENT_TARGET', self.deployment)
850         self.set('CCACHE_BASEDIR', self.directory)
851
852     @Target.ccache.setter
853     def ccache(self, v):
854         Target.ccache.fset(self, v)
855         if v:
856             self.set('CC', '"ccache gcc"')
857             self.set('CXX', '"ccache g++"')
858
859     def package(self, project, checkout, output_dir, options, notarize):
860         tree = self.build(project, checkout, options, for_package=True)
861         tree.add_defaults(options)
862
863         super().package(project, checkout, output_dir, options, notarize)
864
865
866 class OSXUniversalTarget(OSXTarget):
867     def __init__(self, directory=None):
868         super(OSXUniversalTarget, self).__init__(directory)
869         self.sdk = config.get('osx_sdk')
870         self.sub_targets = []
871         for arch, deployment in (('x86_64', config.get('osx_intel_deployment')), ('arm64', config.get('osx_arm_deployment'))):
872             target = OSXSingleTarget(arch, self.sdk, deployment, os.path.join(self.directory, arch, deployment))
873             target.ccache = self.ccache
874             self.sub_targets.append(target)
875         self.can_notarize = True
876
877     def package(self, project, checkout, output_dir, options, notarize):
878         for target in self.sub_targets:
879             tree = globals.trees.get(project, checkout, target)
880             tree.build_dependencies(options)
881             tree.build(options, for_package=True)
882
883         super().package(project, checkout, output_dir, options, notarize)
884
885     @Target.ccache.setter
886     def ccache(self, v):
887         for target in self.sub_targets:
888             target.ccache = v
889
890
891 class SourceTarget(Target):
892     """Build a source .tar.bz2 and .zst"""
893     def __init__(self):
894         super(SourceTarget, self).__init__('source')
895
896     def command(self, c):
897         log_normal('host -> %s' % c)
898         command('%s %s' % (self.variables_string(), c))
899
900     def cleanup(self):
901         rmtree(self.directory)
902
903     def package(self, project, checkout, output_dir, options, notarize):
904         tree = globals.trees.get(project, checkout, self)
905         with TreeDirectory(tree):
906             name = read_wscript_variable(os.getcwd(), 'APPNAME')
907             command('./waf dist')
908             bz2 = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
909             copyfile(bz2, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, bz2))))
910             command('tar xjf %s' % bz2)
911             command('tar --zstd -cf %s-%s.tar.zst %s-%s' % (name, tree.version, name, tree.version))
912             zstd = os.path.abspath('%s-%s.tar.zst' % (name, tree.version))
913             copyfile(zstd, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, zstd))))
914
915
916 class LocalTarget(Target):
917     """Build on the local machine with its environment"""
918     def __init__(self, work, dependencies_only=False):
919         super(LocalTarget, self).__init__('linux', work, dependencies_only=dependencies_only)
920         # Hack around ffmpeg.git which sees that the target isn't windows/osx and then assumes
921         # distro will be there.
922         self.distro = None
923         self.detail = None
924         self.bits = 64
925         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
926         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
927         self.set('CXXFLAGS', '-I%s/include' % self.directory)
928         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
929
930     def command(self, c):
931         log_normal('host -> %s' % c)
932         command('%s %s' % (self.variables_string(), c))
933
934     def cleanup(self):
935         rmtree(self.directory)
936
937 # @param s Target string:
938 #       windows-{32,64}
939 #    or ubuntu-version-{32,64}
940 #    or debian-version-{32,64}
941 #    or centos-version-{32,64}
942 #    or fedora-version-{32,64}
943 #    or mageia-version-{32,64}
944 #    or osx
945 #    or source
946 #    or flatpak
947 #    or appimage
948 def target_factory(args):
949     s = args.target
950     target = None
951     if s.startswith('windows-'):
952         x = s.split('-')
953         if platform.system() == "Windows":
954             target = WindowsNativeTarget(args.work)
955         elif len(x) == 2:
956             target = WindowsDockerTarget(int(x[1]), args.work, args.environment_version)
957         else:
958             raise Error("Bad Windows target name `%s'")
959     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
960         p = s.split('-')
961         if len(p) != 3:
962             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
963         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
964     elif s.startswith('arch-'):
965         p = s.split('-')
966         if len(p) != 2:
967             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
968         target = LinuxTarget(p[0], None, int(p[1]), args.work)
969     elif s == 'raspbian':
970         target = LinuxTarget(s, None, None, args.work)
971     elif s == 'osx':
972         target = OSXUniversalTarget(args.work)
973     elif s == 'osx-intel':
974         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_intel_deployment'), args.work)
975     elif s == 'osx-old':
976         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_old_deployment'), args.work, False)
977     elif s == 'source':
978         target = SourceTarget()
979     elif s == 'flatpak':
980         target = FlatpakTarget(args.project, args.checkout, args.work)
981     elif s == 'appimage':
982         target = AppImageTarget(args.work)
983     elif s == 'local':
984         target = LocalTarget(args.work, args.dependencies_only)
985
986     if target is None:
987         raise Error("Bad target `%s'" % s)
988
989     target.debug = args.debug
990     target.ccache = args.ccache
991
992     if args.environment is not None:
993         for e in args.environment:
994             target.set(e, os.environ[e])
995
996     if args.mount is not None:
997         for m in args.mount:
998             target.mount(m)
999
1000     target.setup()
1001     return target
1002
1003
1004 #
1005 # Tree
1006 #
1007
1008 class Tree:
1009     """Description of a tree, which is a checkout of a project,
1010        possibly built.  This class is never exposed to cscripts.
1011        Attributes:
1012            name -- name of git repository (without the .git)
1013            commit_ish -- git tag or revision to use
1014            target -- target object that we are using
1015            version -- version from the wscript (if one is present)
1016            commit -- git revision that is actually being used
1017            built -- true if the tree has been built yet in this run
1018            required_by -- name of the tree that requires this one
1019     """
1020
1021     def __init__(self, name, commit_ish, target, required_by, built=False):
1022         self.name = name
1023         self.commit_ish = commit_ish
1024         self.target = target
1025         self.version = None
1026         self.commit = None
1027         self.built = built
1028         self.required_by = required_by
1029
1030         cwd = os.getcwd()
1031         proj = '%s/src/%s' % (target.directory, self.name)
1032
1033         if not built:
1034             flags = ''
1035             redirect = ''
1036             if globals.quiet:
1037                 flags = '-q'
1038                 redirect = '>/dev/null'
1039             if config.has('git_reference') and globals.use_git_reference:
1040                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
1041             else:
1042                 ref = ''
1043             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))
1044             os.chdir('%s/src/%s' % (target.directory, self.name))
1045
1046             if self.commit_ish is not None:
1047                 command('git checkout %s %s %s' % (flags, self.commit_ish, redirect))
1048             self.commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
1049
1050         self.cscript = {}
1051         exec(open('%s/cscript' % proj).read(), self.cscript)
1052
1053         if not built:
1054             # cscript can include submodules = False to stop submodules being fetched
1055             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1056                 command('git submodule --quiet init')
1057                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1058                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1059                 for path, url in zip(paths, urls):
1060                     ref = ''
1061                     if config.has('git_reference') and globals.use_git_reference:
1062                         url = url.split(' ')[1]
1063                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1064                         if os.path.exists(ref_path):
1065                             ref = '--reference %s' % ref_path
1066                     path = path.split(' ')[1]
1067                     command('git -c protocol.file.allow=always submodule --quiet update %s %s' % (ref, path))
1068
1069         if os.path.exists('%s/wscript' % proj):
1070             v = read_wscript_variable(proj, "VERSION");
1071             if v is not None:
1072                 try:
1073                     self.version = Version(v)
1074                 except:
1075                     try:
1076                         tag = command_and_read('git -C %s describe --match v* --tags' % proj)[0][1:]
1077                         self.version = Version.from_git_tag(tag)
1078                     except:
1079                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1080                         # Should probably just install git on the Windows VM
1081                         pass
1082
1083         os.chdir(cwd)
1084
1085     def call(self, function, *args):
1086         with TreeDirectory(self):
1087             return self.cscript[function](self.target, *args)
1088
1089     def add_defaults(self, options):
1090         """Add the defaults from self into a dict options"""
1091         if 'option_defaults' in self.cscript:
1092             from_cscript = self.cscript['option_defaults']
1093             if isinstance(from_cscript, dict):
1094                 defaults_dict = from_cscript
1095             else:
1096                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1097                 defaults_dict = from_cscript()
1098             for k, v in defaults_dict.items():
1099                 if not k in options:
1100                     options[k] = v
1101
1102     def dependencies(self, options):
1103         """
1104         yield details of the dependencies of this tree.  Each dependency is returned
1105         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1106         we want to force for 'self'.
1107         """
1108         if not 'dependencies' in self.cscript:
1109             return
1110
1111         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1112             self_options = copy.copy(options)
1113             self.add_defaults(self_options)
1114             deps = self.call('dependencies', self_options)
1115         else:
1116             log_normal("Deprecated cscript dependencies() method with no options parameter")
1117             deps = self.call('dependencies')
1118
1119         # Loop over our immediate dependencies
1120         for d in deps:
1121             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1122
1123             # deps only get their options from the parent's cscript
1124             dep_options = d[2] if len(d) > 2 else {}
1125             for i in dep.dependencies(dep_options):
1126                 yield i
1127             yield (dep, dep_options, self)
1128
1129     def checkout_dependencies(self, options={}):
1130         for i in self.dependencies(options):
1131             pass
1132
1133     def build_dependencies(self, options):
1134         """
1135         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1136         'options' will be the ones from the command line.
1137         """
1138         for i in self.dependencies(options):
1139             i[0].build(i[1])
1140
1141     def build(self, options, for_package=False):
1142         if self.built:
1143             return
1144
1145         log_verbose("Building %s %s %s with %s" % (self.name, self.commit_ish, self.version, options))
1146
1147         variables = copy.copy(self.target.variables)
1148
1149         options = copy.copy(options)
1150         self.add_defaults(options)
1151
1152         if not globals.dry_run:
1153             num_args = len(inspect.getfullargspec(self.cscript['build']).args)
1154             if num_args == 3:
1155                 self.call('build', options, for_package)
1156             elif num_args == 2:
1157                 self.call('build', options)
1158             else:
1159                 self.call('build')
1160
1161         self.target.variables = variables
1162         self.built = True
1163
1164
1165 #
1166 # Command-line parser
1167 #
1168
1169 def main():
1170     parser = argparse.ArgumentParser()
1171     parser.add_argument('-p', '--project', help='project name')
1172     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1173     parser.add_argument('-o', '--output', help='output directory', default='.')
1174     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1175     parser.add_argument('-t', '--target', help='target', action='append')
1176     parser.add_argument('--environment-version', help='version of environment to use')
1177     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1178     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1179     parser.add_argument('-w', '--work', help='override default work directory')
1180     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1181     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1182     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1183     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1184     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1185     parser.add_argument('--ccache', help='use ccache', action='store_true')
1186     parser.add_argument('--verbose', help='be verbose', action='store_true')
1187
1188     subparsers = parser.add_subparsers(help='command to run', dest='command')
1189     parser_build = subparsers.add_parser("build", help="build project")
1190     parser_build.add_argument('--dependencies-only', help='only build dependencies', action='store_true')
1191     parser_package = subparsers.add_parser("package", help="build and package project")
1192     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1193     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1194     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1195     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1196     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1197     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1198     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1199     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1200     parser_latest.add_argument('--major', help='major version to return', type=int)
1201     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1202     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1203     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1204     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1205     parser_shell = subparsers.add_parser("shell", help="start a shell in the project's work directory")
1206     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1207     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1208     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1209     parser_notarize = subparsers.add_parser("notarize", help="notarize .dmgs in a directory")
1210     parser_notarize.add_argument('--dmgs', help='directory containing *.dmg')
1211
1212     global args
1213     args = parser.parse_args()
1214
1215     # Check for incorrect multiple parameters
1216     if args.target is not None:
1217         if len(args.target) > 1:
1218             parser.error('multiple -t options specified')
1219             sys.exit(1)
1220         else:
1221             args.target = args.target[0]
1222
1223     # Override configured stuff
1224     if args.git_prefix is not None:
1225         config.set('git_prefix', args.git_prefix)
1226
1227     if args.output.find(':') == -1:
1228         # This isn't of the form host:path so make it absolute
1229         args.output = os.path.abspath(args.output) + '/'
1230     else:
1231         if args.output[-1] != ':' and args.output[-1] != '/':
1232             args.output += '/'
1233
1234     # Now, args.output is 'host:', 'host:path/' or 'path/'
1235
1236     if args.work is not None:
1237         args.work = os.path.abspath(args.work)
1238         if not os.path.exists(args.work):
1239             os.makedirs(args.work)
1240
1241     if args.project is None and not args.command in ['shell', 'notarize']:
1242         raise Error('you must specify -p or --project')
1243
1244     globals.quiet = args.quiet
1245     globals.verbose = args.verbose
1246     globals.dry_run = args.dry_run
1247
1248     if args.command == 'build':
1249         if args.target is None:
1250             raise Error('you must specify -t or --target')
1251
1252         target = target_factory(args)
1253         try:
1254             target.build(args.project, args.checkout, get_command_line_options(args))
1255         finally:
1256             if not args.keep:
1257                 target.cleanup()
1258
1259     elif args.command == 'package':
1260         if args.target is None:
1261             raise Error('you must specify -t or --target')
1262
1263         target = None
1264         try:
1265             target = target_factory(args)
1266
1267             if target.platform == 'linux' and target.detail != "appimage":
1268                 if target.distro != 'arch':
1269                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1270                 else:
1271                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1272             else:
1273                 output_dir = args.output
1274
1275             makedirs(output_dir)
1276             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), not args.no_notarize)
1277         finally:
1278             if target is not None and not args.keep:
1279                 target.cleanup()
1280
1281     elif args.command == 'release':
1282         if args.minor is False and args.micro is False:
1283             raise Error('you must specify --minor or --micro')
1284
1285         target = SourceTarget()
1286         tree = globals.trees.get(args.project, args.checkout, target)
1287
1288         version = tree.version
1289         version.to_release()
1290         if args.minor:
1291             version.bump_minor()
1292         else:
1293             version.bump_micro()
1294
1295         with TreeDirectory(tree):
1296             command('git tag -m "v%s" v%s' % (version, version))
1297             command('git push --tags')
1298
1299         target.cleanup()
1300
1301     elif args.command == 'pot':
1302         target = SourceTarget()
1303         tree = globals.trees.get(args.project, args.checkout, target)
1304
1305         pots = tree.call('make_pot')
1306         for p in pots:
1307             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1308
1309         target.cleanup()
1310
1311     elif args.command == 'manual':
1312         target = SourceTarget()
1313         tree = globals.trees.get(args.project, args.checkout, target)
1314         tree.checkout_dependencies()
1315
1316         outs = tree.call('make_manual')
1317         for o in outs:
1318             if os.path.isfile(o):
1319                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1320             else:
1321                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1322
1323         target.cleanup()
1324
1325     elif args.command == 'doxygen':
1326         target = SourceTarget()
1327         tree = globals.trees.get(args.project, args.checkout, target)
1328
1329         dirs = tree.call('make_doxygen')
1330         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1331             dirs = [dirs]
1332
1333         for d in dirs:
1334             copytree(d, args.output)
1335
1336         target.cleanup()
1337
1338     elif args.command == 'latest':
1339         target = SourceTarget()
1340         tree = globals.trees.get(args.project, args.checkout, target)
1341
1342         with TreeDirectory(tree):
1343             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1344             latest = None
1345             line = 0
1346             while latest is None:
1347                 t = f[line]
1348                 line += 1
1349                 m = re.compile(".*\((.*)\).*").match(t)
1350                 if m:
1351                     tags = m.group(1).split(', ')
1352                     for t in tags:
1353                         s = t.split()
1354                         if len(s) > 1:
1355                             t = s[1]
1356                         if len(t) > 0 and t[0] == 'v':
1357                             v = Version(t[1:])
1358                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1359                                 latest = v
1360
1361         print(latest)
1362         target.cleanup()
1363
1364     elif args.command == 'test':
1365         if args.target is None:
1366             raise Error('you must specify -t or --target')
1367
1368         target = None
1369         try:
1370             target = target_factory(args)
1371             options = get_command_line_options(args)
1372             if args.no_implicit_build:
1373                 globals.trees.add_built(args.project, args.checkout, target)
1374             else:
1375                 target.build(args.project, args.checkout, options)
1376             target.test(args.project, args.checkout, target, args.test, options)
1377         finally:
1378             if target is not None and not args.keep:
1379                 target.cleanup()
1380
1381     elif args.command == 'shell':
1382         if args.target is None:
1383             raise Error('you must specify -t or --target')
1384
1385         target = target_factory(args)
1386         target.command('bash')
1387
1388     elif args.command == 'revision':
1389
1390         target = SourceTarget()
1391         tree = globals.trees.get(args.project, args.checkout, target)
1392         with TreeDirectory(tree):
1393             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1394         target.cleanup()
1395
1396     elif args.command == 'checkout':
1397
1398         if args.output is None:
1399             raise Error('you must specify -o or --output')
1400
1401         target = SourceTarget()
1402         tree = globals.trees.get(args.project, args.checkout, target)
1403         with TreeDirectory(tree):
1404             shutil.copytree('.', args.output)
1405         target.cleanup()
1406
1407     elif args.command == 'dependencies':
1408         if args.target is None:
1409             raise Error('you must specify -t or --target')
1410         if args.checkout is None:
1411             raise Error('you must specify -c or --checkout')
1412
1413         target = target_factory(args)
1414         tree = globals.trees.get(args.project, args.checkout, target)
1415         print("strict digraph {")
1416         for d in list(tree.dependencies({})):
1417             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1418         print("}")
1419
1420     elif args.command == 'notarize':
1421         if args.dmgs is None:
1422             raise Error('you must specify ---dmgs')
1423
1424         for dmg in Path(args.dmgs).glob('*.dmg'):
1425             notarize_dmg(dmg)
1426
1427 try:
1428     main()
1429 except Error as e:
1430     print('cdist: %s' % str(e), file=sys.stderr)
1431     sys.exit(1)