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