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