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