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