Make work dir configurable.
[cdist.git] / cdist
1 #!/usr/bin/python
2
3 #    Copyright (C) 2012 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 import os
20 import sys
21 import shutil
22 import glob
23 import tempfile
24 import argparse
25 import datetime
26 import subprocess
27 import re
28
29 #
30 # Configuration
31 #
32
33 class Config:
34     def __init__(self):
35         self.keys = ['linux_dir_in_chroot',
36                      'linux_chroot_prefix',
37                      'windows_environment_prefix',
38                      'mingw_prefix',
39                      'git_prefix',
40                      'osx_build_host',
41                      'osx_dir_in_host',
42                      'osx_environment_prefix',
43                      'osx_sdk_prefix',
44                      'osx_sdk']
45
46         self.dict = dict()
47
48         try:
49             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
50             while 1:
51                 l = f.readline()
52                 if l == '':
53                     break
54
55                 if len(l) > 0 and l[0] == '#':
56                     continue
57
58                 s = l.strip().split()
59                 if len(s) == 2:
60                     for k in self.keys:
61                         if k == s[0]:
62                             self.dict[k] = s[1]
63         except:
64             raise
65
66     def get(self, k):
67         if k in self.dict:
68             return self.dict[k]
69
70         print >>sys.stderr,'Required setting %s not found' % k
71         sys.exit(1)
72
73 config = Config()
74
75 #
76 # Utility bits
77
78
79 def log(m):
80     if not args.quiet:
81         print '\x1b[33m* %s\x1b[0m' % m
82
83 def error(e):
84     print '\x1b[31mError: %s\x1b[0m' % e
85     sys.exit(1)
86
87 def copytree(a, b):
88     log('copy %s -> %s' % (a, b))
89     shutil.copytree(a, b)
90
91 def copyfile(a, b):
92     log('copy %s -> %s' % (a, b))
93     shutil.copyfile(a, b)
94
95 def rmdir(a):
96     log('remove %s' % a)
97     os.rmdir(a)
98
99 def rmtree(a):
100     log('remove %s' % a)
101     shutil.rmtree(a, ignore_errors=True)
102
103 def command(c, can_fail=False):
104     log(c)
105     r = os.system(c)
106     if (r >> 8) and not can_fail:
107         error('command %s failed' % c)
108
109 def command_and_read(c):
110     log(c)
111     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
112     f = os.fdopen(os.dup(p.stdout.fileno()))
113     return f
114
115
116 #
117 # Version
118 #
119
120 class Version:
121     def __init__(self, s):
122         self.pre = False
123         self.beta = None
124
125         if s.startswith("'"):
126             s = s[1:]
127         if s.endswith("'"):
128             s = s[0:-1]
129         
130         if s.endswith('pre'):
131             s = s[0:-3]
132             self.pre = True
133
134         b = s.find("beta")
135         if b != -1:
136             self.beta = int(s[b+4:])
137             s = s[0:b]
138
139         p = s.split('.')
140         self.major = int(p[0])
141         self.minor = int(p[1])
142
143     def bump(self):
144         self.minor += 1
145         self.pre = False
146         self.beta = None
147
148     def to_pre(self):
149         self.pre = True
150         self.beta = None
151
152     def bump_and_to_pre(self):
153         self.bump()
154         self.pre = True
155         self.beta = None
156
157     def to_release(self):
158         self.pre = False
159         self.beta = None
160
161     def bump_beta(self):
162         if self.pre:
163             self.pre = False
164             self.beta = 1
165         elif self.beta is not None:
166             self.beta += 1
167         elif self.beta is None:
168             self.beta = 1
169
170     def __str__(self):
171         s = '%d.%02d' % (self.major, self.minor)
172         if self.beta is not None:
173             s += 'beta%d' % self.beta
174         elif self.pre:
175             s += 'pre'
176
177         return s
178
179 #
180 # Targets
181 #
182
183 class Target(object):
184     def __init__(self, platform, parallel):
185         self.platform = platform
186         self.parallel = parallel
187         # Environment variables that we will use when we call cscripts
188         self.variables = {}
189         self.debug = False
190
191     def build_dependencies(self, project):
192         cwd = os.getcwd()
193         if 'dependencies' in project.cscript:
194             print project.cscript['dependencies'](self)
195             for d in project.cscript['dependencies'](self):
196                 log('Building dependency %s %s of %s' % (d[0], d[1], project.name))
197                 dep = Project(d[0], '.', d[1])
198                 dep.checkout(self)
199                 self.build_dependencies(dep)
200                 self.build(dep)
201         os.chdir(cwd)
202
203     def build(self, project):
204         project.cscript['build'](self)
205
206     def package(self, project):
207         project.checkout(self)
208         self.build_dependencies(project)
209         project.cscript['build'](self)
210         return project.cscript['package'](self, project.version)
211
212     def set(self, a, b):
213         self.variables[a] = b
214
215     def get(self, a):
216         return self.variables[a]
217
218     def variables_string(self, escaped_quotes=False):
219         e = ''
220         for k, v in self.variables.iteritems():
221             if escaped_quotes:
222                 v = v.replace('"', '\\"')
223             e += '%s=%s ' % (k, v)
224         return e
225
226     def cleanup(self):
227         pass
228
229
230 # Windows
231 #
232
233 class WindowsTarget(Target):
234     # @param directory directory to work in; if None, we will use a temporary directory
235     def __init__(self, bits, directory=None):
236         super(WindowsTarget, self).__init__('windows', 2)
237         self.bits = bits
238         if directory is None:
239             self.directory = tempfile.mkdtemp()
240             self.rmdir = True
241         else:
242             self.directory = directory
243             self.rmdir = False
244         
245         self.windows_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
246         if not os.path.exists(self.windows_prefix):
247             error('windows prefix %s does not exist' % target.windows_prefix)
248             
249         if self.bits == 32:
250             mingw_name = 'i686'
251         else:
252             mingw_name = 'x86_64'
253
254         mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
255         mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, mingw_name)]
256
257         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.windows_prefix)
258         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig' % self.work_dir_cscript())
259         self.set('PATH', '%s/bin:%s:%s' % (self.windows_prefix, mingw_path, os.environ['PATH']))
260         self.set('CC', '%s-w64-mingw32-gcc' % mingw_name)
261         self.set('CXX', '%s-w64-mingw32-g++' % mingw_name)
262         self.set('LD', '%s-w64-mingw32-ld' % mingw_name)
263         self.set('RANLIB', '%s-w64-mingw32-ranlib' % mingw_name)
264         self.set('WINRC', '%s-w64-mingw32-windres' % mingw_name)
265         cxx = '-I%s/include -I%s/include' % (self.windows_prefix, self.work_dir_cscript())
266         link = '-L%s/lib -L%s/lib' % (self.windows_prefix, self.work_dir_cscript())
267         for p in mingw_prefixes:
268             cxx += ' -I%s/include' % p
269             link += ' -L%s/lib' % p
270         self.set('CXXFLAGS', '"%s"' % cxx)
271         self.set('LINKFLAGS', '"%s"' % link)
272
273     def work_dir_cdist(self):
274         return '%s/%d' % (self.directory, self.bits)
275
276     def work_dir_cscript(self):
277         return '%s/%d' % (self.directory, self.bits)
278
279     def command(self, c):
280         log('host -> %s' % c)
281         command('%s %s' % (self.variables_string(), c))
282
283     def cleanup(self):
284         if self.rmdir:
285             rmtree(self.directory)
286
287 #
288 # Linux
289 #
290
291 class LinuxTarget(Target):
292     # @param directory Directory to work in; if None, we will use the configured linux_dir_in_chroot
293     def __init__(self, distro, version, bits, directory=None):
294         super(LinuxTarget, self).__init__('linux', 2)
295         self.distro = distro
296         self.version = version
297         self.bits = bits
298         self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
299         if directory is None:
300             self.dir_in_chroot = config.get('linux_dir_in_chroot')
301         else:
302             self.dir_in_chroot = directory
303
304         for g in glob.glob('%s/*' % self.work_dir_cdist()):
305             rmtree(g)
306
307         self.set('CXXFLAGS', '-I%s/include' % self.work_dir_cscript())
308         self.set('LINKFLAGS', '-L%s/lib' % self.work_dir_cscript())
309         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig' % self.work_dir_cscript())
310         self.set('PATH', '%s:/usr/local/bin' % (os.environ['PATH']))
311
312     def work_dir_cdist(self):
313         return '%s/%s%s' % (config.get('linux_chroot_prefix'), self.chroot, self.dir_in_chroot)
314
315     def work_dir_cscript(self):
316         return self.dir_in_chroot
317
318     def command(self, c):
319         # Work out the cwd for the chrooted command
320         cwd = os.getcwd()
321         prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
322         assert(cwd.startswith(prefix))
323         cwd = cwd[len(prefix):]
324
325         log('schroot [%s] -> %s' % (cwd, c))
326         command('%s schroot -c %s -d %s -p -- %s' % (self.variables_string(), self.chroot, cwd, c))
327
328     def cleanup(self):
329         for g in glob.glob('%s/*' % self.work_dir_cdist()):
330             rmtree(g)
331
332 #
333 # OS X
334 #
335
336 class OSXTarget(Target):
337     def __init__(self):
338         super(OSXTarget, self).__init__('osx', 4)
339
340         self.host_mount_dir = tempfile.mkdtemp()
341
342         # Mount the remote host on host_mount_dir
343         command('sshfs %s:%s %s' % (config.get('osx_build_host'), config.get('osx_dir_in_host'), self.host_mount_dir))
344         for g in glob.glob('%s/*' % self.host_mount_dir):
345             rmtree(g)
346
347     def command(self, c):
348         # Work out the cwd for the chrooted command
349         cwd = os.getcwd()
350         assert(cwd.startswith(self.host_mount_dir))
351         cwd = cwd[len(self.host_mount_dir):]
352
353         log('ssh [%s] -> %s' % (cwd, c))
354         command('ssh %s -- "cd %s%s; %s %s"' % (config.get('osx_build_host'), config.get('osx_dir_in_host'), cwd, self.variables_string(True), c))
355
356     def cleanup(self):
357         os.chdir('/')
358         command('fusermount -u %s' % self.host_mount_dir)
359         rmdir(self.host_mount_dir)
360
361
362
363 class OSXSingleTarget(OSXTarget):
364     def __init__(self, bits):
365         super(OSXSingleTarget, self).__init__()
366         self.bits = bits
367
368         if bits == 32:
369             arch = 'i386'
370         else:
371             arch = 'x86_64'
372
373         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (config.get('osx_sdk_prefix'), config.get('osx_sdk'), arch)
374         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
375
376         # Environment variables
377         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.work_dir_cscript(), enviro, flags))
378         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.work_dir_cscript(), enviro, flags))
379         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.work_dir_cscript(), enviro, flags))
380         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.work_dir_cscript(), enviro, flags))
381         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.work_dir_cscript(), enviro))
382         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
383         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
384
385     def work_dir_cdist(self):
386         return '%s/%d' % (self.host_mount_dir, self.bits)
387
388     def work_dir_cscript(self):
389         return '%s/%d' % (config.get('osx_dir_in_host'), self.bits)
390
391     def package(self, project):
392         error('cannot package non-universal OS X versions')
393
394
395 class OSXUniversalTarget(OSXTarget):
396     def __init__(self):
397         super(OSXUniversalTarget, self).__init__()
398         self.parts = []
399         self.parts.append(OSXSingleTarget(32))
400         self.parts.append(OSXSingleTarget(64))
401
402     def package(self, project):
403         for p in self.parts:
404             project.checkout(p)
405             p.build_dependencies(project)
406             p.build(project)
407
408         return project.cscript['package'](self.parts[-1], project.version)
409     
410
411 #
412 # Source
413 #
414
415 class SourceTarget(Target):
416     def __init__(self):
417         super(SourceTarget, self).__init__('source', 2)
418         self.directory = tempfile.mkdtemp()
419
420     def work_dir_cdist(self):
421         return self.directory
422
423     def work_dir_cscript(self):
424         return self.directory
425
426     def command(self, c):
427         log('host -> %s' % c)
428         command('%s %s' % (self.variables_string(), c))
429
430     def cleanup(self):
431         rmtree(self.directory)
432
433     def package(self, project):
434         project.checkout(self)
435         command('./waf dist')
436         if project.directory != '.':
437             return os.path.abspath('%s-%s.tar.bz2' % (project.directory, project.version))
438         return os.path.abspath('%s-%s.tar.bz2' % (project.name, project.version))
439
440
441 # @param s Target string:
442 #       windows-{32,64}
443 #    or ubuntu-version-{32,64}
444 #    or debian-version-{32,64}
445 #    or osx-{32,64}
446 #    or source      
447 # @param debug True to build with debugging symbols (where possible)
448 def target_factory(s, debug, work):
449     target = None
450     if s.startswith('windows-'):
451         target = WindowsTarget(int(s.split('-')[1]), work)
452     elif s.startswith('ubuntu-') or s.startswith('debian-'):
453         p = s.split('-')
454         target = LinuxTarget(p[0], p[1], int(p[2]), work)
455     elif s.startswith('osx-'):
456         target = OSXSingleTarget(int(s.split('-')[1]))
457     elif s == 'osx':
458         if args.command == 'build':
459             target = OSXSingleTarget(64)
460         else:
461             target = OSXUniversalTarget()
462     elif s == 'source':
463         target = SourceTarget()
464
465     if target is not None:
466         target.debug = debug
467
468     return target
469
470
471 #
472 # Project
473 #
474  
475 class Project(object):
476     def __init__(self, name, directory, specifier=None):
477         self.name = name
478         self.directory = directory
479         self.version = None
480         self.specifier = specifier
481         if self.specifier is None:
482             self.specifier = 'master'
483
484     def checkout(self, target):
485         flags = ''
486         redirect = ''
487         if args.quiet:
488             flags = '-q'
489             redirect = '>/dev/null'
490         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.work_dir_cdist(), self.name))
491         os.chdir('%s/src/%s' % (target.work_dir_cdist(), self.name))
492         command('git checkout %s %s %s' % (flags, self.specifier, redirect))
493         command('git submodule init')
494         command('git submodule update')
495         os.chdir(self.directory)
496
497         proj = '%s/src/%s/%s' % (target.work_dir_cdist(), self.name, self.directory)
498
499         self.read_cscript('%s/cscript' % proj)
500         
501         if os.path.exists('%s/wscript' % proj):
502             f = open('%s/wscript' % proj, 'r')
503             version = None
504             while 1:
505                 l = f.readline()
506                 if l == '':
507                     break
508
509                 s = l.split()
510                 if len(s) == 3 and s[0] == "VERSION":
511                     self.version = Version(s[2])
512
513             f.close()
514
515     def read_cscript(self, s):
516         self.cscript = {}
517         execfile(s, self.cscript)
518
519 def set_version_in_wscript(version):
520     f = open('wscript', 'rw')
521     o = open('wscript.tmp', 'w')
522     while 1:
523         l = f.readline()
524         if l == '':
525             break
526
527         s = l.split()
528         if len(s) == 3 and s[0] == "VERSION":
529             print "Writing %s" % version
530             print >>o,"VERSION = '%s'" % version
531         else:
532             print >>o,l,
533     f.close()
534     o.close()
535
536     os.rename('wscript.tmp', 'wscript')
537
538 def append_version_to_changelog(version):
539     try:
540         f = open('ChangeLog', 'r')
541     except:
542         log('Could not open ChangeLog')
543         return
544
545     c = f.read()
546     f.close()
547
548     f = open('ChangeLog', 'w')
549     now = datetime.datetime.now()
550     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))
551     f.write(c)
552
553 def append_version_to_debian_changelog(version):
554     if not os.path.exists('debian'):
555         log('Could not find debian directory')
556         return
557
558     command('dch -b -v %s-1 "New upstream release."' % version)
559
560 #
561 # Command-line parser
562 #
563
564 parser = argparse.ArgumentParser()
565 parser.add_argument('command')
566 parser.add_argument('-p', '--project', help='project name', required=True)
567 parser.add_argument('-d', '--directory', help='directory within project repo', default='.')
568 parser.add_argument('--beta', help='beta release', action='store_true')
569 parser.add_argument('--full', help='full release', action='store_true')
570 parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
571 parser.add_argument('-o', '--output', help='output directory', default='.')
572 parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
573 parser.add_argument('-t', '--target', help='target')
574 parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
575 parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
576 parser.add_argument('--work', help='override default work directory')
577 args = parser.parse_args()
578
579 args.output = os.path.abspath(args.output)
580
581 if args.project is None:
582     error('you must specify -p or --project')
583
584 project = Project(args.project, args.directory, args.checkout)
585
586 if args.command == 'build':
587     if args.target is None:
588         error('you must specify -t or --target')
589
590     target = target_factory(args.target, args.debug, os.path.abspath(args.work))
591     project.checkout(target)
592     target.build_dependencies(project)
593     target.build(project)
594     if not args.keep:
595         target.cleanup()
596
597 elif args.command == 'package':
598     if args.target is None:
599         error('you must specify -t or --target')
600         
601     target = target_factory(args.target, args.debug, os.path.abspath(args.work))
602
603     packages = target.package(project)
604     if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
605         packages = [packages]
606
607     if target.platform == 'linux':
608         out = '%s/%s-%d' % (args.output, target.version, target.bits)
609         try:
610             os.makedirs(out)
611         except:
612             pass
613         for p in packages:
614             copyfile(p, '%s/%s' % (out, os.path.basename(p)))
615     else:
616         for p in packages:
617             copyfile(p, '%s/%s' % (args.output, os.path.basename(p)))
618
619     target.cleanup()
620
621 elif args.command == 'release':
622     if args.full is False and args.beta is False:
623         error('you must specify --full or --beta')
624
625     target = SourceTarget()
626     project.checkout(target)
627
628     version = project.version
629     if args.full:
630         version.to_release()
631     else:
632         version.bump_beta()
633
634     set_version_in_wscript(version)
635     append_version_to_changelog(version)
636     append_version_to_debian_changelog(version)
637
638     command('git commit -a -m "Bump version"')
639     command('git tag -m "v%s" v%s' % (version, version))
640
641     if args.full:
642         version.bump_and_to_pre()
643         set_version_in_wscript(version)
644         command('git commit -a -m "Bump version"')
645
646     command('git push')
647     command('git push --tags')
648
649     target.cleanup()
650
651 elif args.command == 'pot':
652     target = SourceTarget()
653     project.checkout(target)
654
655     pots = project.cscript['make_pot'](target)
656     for p in pots:
657         copyfile(p, '%s/%s' % (args.output, os.path.basename(p)))
658
659     target.cleanup()
660
661 elif args.command == 'changelog':
662     target = SourceTarget()
663     project.checkout(target)
664
665     text = open('ChangeLog', 'r')
666     html = open('%s/changelog.html' % args.output, 'w')
667     versions = 8
668     
669     last = None
670     changes = []
671     
672     while 1:
673         l = text.readline()
674         if l == '':
675             break
676     
677         if len(l) > 0 and l[0] == "\t":
678             s = l.split()
679             if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
680                 if not "beta" in s[2]:
681                     if last is not None and len(changes) > 0:
682                         print >>html,"<h2>Changes between version %s and %s</h2>" % (s[2], last)
683                         print >>html,"<ul>"
684                         for c in changes:
685                             print >>html,"<li>%s" % c
686                         print >>html,"</ul>"
687                     last = s[2]
688                     changes = []
689                     versions -= 1
690                     if versions < 0:
691                         break
692             else:
693                 c = l.strip()
694                 if len(c) > 0:
695                     if c[0] == '*':
696                         changes.append(c[2:])
697                     else:
698                         changes[-1] += " " + c
699
700     target.cleanup()
701
702 elif args.command == 'manual':
703     target = SourceTarget()
704     project.checkout(target)
705
706     dirs = project.cscript['make_manual'](target)
707     for d in dirs:
708         copytree(d, '%s/%s' % (args.output, os.path.basename(d)))
709
710     target.cleanup()
711
712 elif args.command == 'doxygen':
713     target = SourceTarget()
714     project.checkout(target)
715
716     dirs = project.cscript['make_doxygen'](target)
717     if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
718         dirs = [dirs]
719
720     for d in dirs:
721         copytree(d, '%s/%s' % (args.output, 'doc'))
722
723     target.cleanup()
724
725 elif args.command == 'latest':
726     target = SourceTarget()
727     project.checkout(target)
728
729     f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
730     t = f.readline()
731     m = re.compile(".*\((.*)\).*").match(t)
732     latest = None
733     if m:
734         tags = m.group(1).split(', ')
735         for t in tags:
736             if len(t) > 0 and t[0] == 'v':
737                 latest = t[1:]
738
739     print latest
740     target.cleanup()
741
742 elif args.command == 'test':
743     if args.target is None:
744         error('you must specify -t or --target')
745
746     target = target_factory(args.target, args.debug, os.path.abspath(args.work))
747     project.read_cscript('cscript')
748     target.build(project)
749
750 else:
751     error('invalid command %s' % args.command)