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