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