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