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