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