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