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