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