]> git.donarmstrong.com Git - neurodebian.git/blob - tools/blends-inject
default exit code for error
[neurodebian.git] / tools / blends-inject
1 #!/usr/bin/python
2 #emacs: -*- mode: python-mode; py-indent-offset: 4; tab-width: 4; indent-tabs-mode: nil -*-
3 #ex: set sts=4 ts=4 sw=4 noet:
4 """Script to help maintaining package definitions within Debian blends
5 task files.
6
7 Often it becomes necessary to duplicate the same information about a
8 package within debian packaging and multiple blends task files,
9 possibly of different blends.  This script allows to automate:
10
11 - construction of entries
12 - injection into task files
13 - modification of existing entries if things changed
14 - removal of previously injected entries
15
16 Possible TODOs:
17 ---------------
18
19 * For every package the same task file might be re-read/written (if
20   entry changed/added) from disk.
21   That allows to replace easily original entry for 'source' package
22   (listed as Ignore:) with actual first listed binary package.
23   This should be taken into consideration if current per-package
24   handling gets changed
25
26 """
27
28 """
29 Configuration
30 -------------
31
32 Paths to the blends top directories, containing tasks directories are
33 specified in ~/.blends-inject.cfg file, e.g.::
34
35  [debian-med]
36  path = /home/yoh/deb/debian-med/
37
38  [debian-science]
39  path = /home/yoh/deb/debian-science/
40
41
42 Definition of the fields for task files by default are looked up
43 within debian/blends, or files provided in the command line.
44
45 Format of debian/blends
46 -----------------------
47
48 Example::
49
50  ; If originally filed using project source name, and it is different
51  ; from the primary (first) binary package name, keep 'Source' to be
52  ; able to adopt previously included tasks entry
53 Source: brian
54
55  ; Define the format on how entries should be handled.
56  ; Possible values:
57  ;   extended -- whenever package is not in Debian and additional
58  ;               fields should be obtained from debian/*:
59  ;               * License
60  ;               * WNPP
61  ;               * Pkg-Description
62  ;               * Responsible
63  ;               * Homepage
64  ;               * Vcs-*
65  ;   plain [default] -- only fields listed here should be mentioned.
66  ;               Common use -- whenever package is already known to UDD.
67  ;
68  ;  By default, all fields specified previously propagate into following
69  ;  packages as well.  If that is not desired, add suffix '-clean' to
70  ;  the Format
71 Format: extended
72
73 Tasks: debian-science/neuroscience-modeling
74
75  ; Could have Depends/Recommends/Suggests and Ignore
76  ; All those define Pkg-Name field which is not included
77  ; in the final "rendering" but is available as Pkg-Name item
78 Depends: python-brian
79 Pkg-URL: http://neuro.debian.net/pkgs/%(Pkg-Name)s.html
80 Language: Python, C
81 Published-Authors: Goodman D.F. and Brette R.
82 Published-Title: Brian: a simulator for spiking neural networks in Python
83 Published-In: Front. Neuroinform
84 Published-Year: 2008
85 Published-DOI: 10.3389/neuro.11.005.2008
86
87  ; May be some previous entry should be removed, thus say so
88 Removed: python-brian-doc
89
90  ;Tasks: debian-med/imaging-dev
91  ;Why: Allows interactive development/scripting
92
93  ; ; It should be possible to switch between formats,
94  ; ; e.g. if some component is not yet in Debian
95  ;Format: extended
96  ;
97  ; ; Now some bogus one but with customizations
98  ;Tasks: debian-med/documentation
99  ;Recommends: python-brian-doc
100  ;Language:
101  ;Remark: some remark
102  ;
103
104 """
105
106
107 import re, os, sys
108 from os.path import join, exists, expanduser, dirname, basename
109
110 from ConfigParser import ConfigParser
111 from optparse import OptionParser, Option
112
113 from copy import deepcopy
114 #from debian_bundle import deb822
115 from debian import deb822
116 #import deb822
117 from debian.changelog import Changelog
118
119 # all files we are dealing with should be UTF8, thus
120 # lets override
121 import codecs
122
123 def open(f, *args):
124     return codecs.open(f, *args, encoding='utf-8')
125
126 __author__ = 'Yaroslav Halchenko'
127 __prog__ = os.path.basename(sys.argv[0])
128 __version__ = '0.0.2'
129 __copyright__ = 'Copyright (c) 2010 Yaroslav Halchenko'
130 __license__ = 'GPL'
131
132 # What fields initiate new package description
133 PKG_FIELDS = ('depends', 'recommends', 'suggests', 'ignore', 'removed')
134
135 # We might need to resort to assure some what a canonical order
136 FIELDS_ORDER = ('depends', 'recommends', 'suggests', 'ignore',
137                 'homepage', 'language', 'wnpp', 'responsible', 'license',
138                 'vcs-', 'pkg-url', 'pkg-description',
139                 'published-', 'x-', 'registration', 'remark')
140
141 verbosity = None
142
143 def error(msg, exit_code=1):
144     sys.stderr.write(msg + '\n')
145     sys.exit(exit_code)
146
147 def verbose(level, msg):
148     if level <= verbosity:
149         print " "*level, msg
150
151
152 def parse_debian_blends(f='debian/blends'):
153     """Parses debian/blends file
154
155     Returns unprocessed list of customized Deb822 entries
156     """
157     # Linearize all the paragraphs since we are not using them
158     items = []
159     for p in deb822.Deb822.iter_paragraphs(open(f)):
160         items += p.items()
161
162     verbose(6, "Got items %s" % items)
163     # Traverse and collect things
164     format_ = 'plain'
165     format_clean = False # do not propagate fields into a new pkg if True
166     pkg, source = None, None
167     pkgs = []
168     tasks = []
169
170     def new_pkg(prev_pkg, bname, sname, tasks):
171         """Helper function to create a new package
172         """
173         if format_clean or prev_pkg is None:
174             pkg = deb822.Deb822()
175         else:
176             pkg = deepcopy(prev_pkg)
177             for k_ in PKG_FIELDS:   # prune older depends
178                 pkg.pop(k_, None)
179         pkg['Pkg-Name'] = pkg[k] = bname
180         pkg['Pkg-Source'] = sname
181         pkgs.append(pkg)
182         pkg.tasks = dict( (t.strip(), deb822.Deb822Dict()) for t in tasks )
183         pkg.format = format_
184         return pkg
185
186     for k, v in items:
187
188         kl = k.lower()
189         if kl == 'source':
190             source = v.strip()
191         elif kl == 'format':
192             format_ = v.strip()
193             format_clean = format_.endswith('-clean')
194             if format_clean:
195                 format_ = format_[:-6]
196         elif kl == 'tasks':
197             tasks = v.split(',')
198             newtasks = True                 # either we need to provide tune-ups
199                                             # for current package
200         elif kl in PKG_FIELDS: # new package
201             if source is None:
202                 source = v
203             pkg = new_pkg(pkg, v, source, tasks)
204             newtasks = False
205         else:
206             if newtasks:
207                 if pkg is None:
208                     # So we had just source?
209                     if source is None:
210                         error("No package or source is known where to add %s" % (k,), 1)
211                         # TODO: just deduce source from DebianMaterials
212                     pkg = new_pkg(pkg, source, source, tasks)
213                     # Since only source is available, it should be Ignore:-ed
214                     pkg['Ignore'] = source
215                     newtasks = False
216                 # Add customization
217                 for t in tasks:
218                     if not t in pkg.tasks:
219                         pkg.tasks[t] = deb822.Deb822Dict()
220                     pkg.tasks[t][k] = v
221             else:
222                 # just store the key in the pkg itself
223                 pkg[k] = v
224     return pkgs
225
226
227 def expand_pkgs(pkgs, topdir='.'):
228     """In-place modification of pkgs taking if necessary additional
229     information from Debian materials, and pruning empty fields
230     """
231     verbose(4, "Expanding content for %d packages" % len(pkgs))
232     debianm = None
233     # Expand packages which format is extended
234     for pkg in pkgs:
235         if pkg.format == 'extended':
236             # expanding, for that we need debian/control
237             if debianm is None:
238                 debianm = DebianMaterials(topdir)
239             for k, m in (('License', lambda: debianm.get_license(pkg['Pkg-Name'])),
240                          ('WNPP', debianm.get_wnpp),
241                          ('Pkg-Description',
242                           lambda: debianm.get_description(pkg['Pkg-Name'])),
243                          ('Responsible', debianm.get_responsible),
244                          ('Homepage', lambda: debianm.source.get('Homepage', None))):
245                 if pkg.get(k, None):
246                     continue
247                 v = m()
248                 if v:
249                     pkg[k] = v
250             # VCS fields
251             pkg.update(debianm.get_vcsfields())
252
253 def key_prefix_compare(x, y, order, strict=False, case=False):
254     """Little helper to help with sorting
255
256     Sorts according to the order of string prefixes as given by
257     `order`.  If `strict`, then if no matching prefix found, would
258     raise KeyError; otherwise provides least priority to those keys
259     which were not found in `order`
260     """
261     if not case:
262         order = [v.lower() for v in order]
263
264     def prefix_index(t, order, strict=True, case=False):
265         x = t[0]
266         if not case:
267             x = x.lower()
268         for i, v in enumerate(order):
269             if x.startswith(v):
270                 return i
271
272         if strict:
273             raise IndexError(
274                 "Could not find location for %s as specified by %s" %
275                 (x, order))
276         return 10000                    #  some large number ;)
277
278     cmp_res =  cmp(prefix_index(x, order, strict, case),
279                    prefix_index(y, order, strict, case))
280     if not cmp_res:                     # still unknown
281         return cmp(x, y)
282     return cmp_res
283
284
285 def group_packages_into_tasks(pkgs):
286     """Given a list of packages (with .tasks) group them per each
287     task and perform necessary customizations stored in .tasks
288     """
289     # Time to take care about packages and tasks
290     # Unroll pkgs into a collection of pkgs per known task
291     tasks = {}
292     for pkg in pkgs:
293         # Lets just create deepcopies with tune-ups for each task
294         for itask, (task, fields) in enumerate(pkg.tasks.iteritems()):
295             pkg_ = deepcopy(pkg)
296             pkg_.update(fields)
297
298             # Perform string completions and removals
299             for k,v in pkg_.iteritems():
300                 pkg_[k] = v % pkg_
301                 if v is None or not len(v.strip()):
302                     pkg_.pop(k)
303
304             # Sort the fields according to FIELDS_ORDER. Unfortunately
305             # silly Deb822* cannot create from list of tuples, so will do
306             # manually
307             pkg__ = deb822.Deb822()
308             for k,v in sorted(pkg_.items(),
309                               cmp=lambda x, y:
310                               key_prefix_compare(x, y, order=FIELDS_ORDER)):
311                 pkg__[k] = v
312
313             # Move Pkg-source/name into attributes
314             pkg__.source = pkg__.pop('Pkg-Source')
315             pkg__.name = pkg__.pop('Pkg-name')
316
317             tasks[task] = tasks.get(task, []) + [pkg__]
318     verbose(4, "Grouped %d packages into %d tasks: %s" %
319             (len(pkgs), len(tasks), ', '.join(tasks.keys())))
320     return tasks
321
322 def inject_tasks(tasks, config):
323     # Now go through task files and replace/add entries
324     for task, pkgs in tasks.iteritems():
325         verbose(2, "Task %s with %d packages" % (task, len(pkgs)))
326         blend, puretask = task.split('/')
327         taskfile = join(config.get(blend, 'path'), 'tasks', puretask)
328
329         # Load the file
330         stats = dict(Added=[], Modified=[])
331         for pkg in pkgs:
332             msgs = {'Name': pkg.name.strip(), 'Action': None}
333             # Find either it is known to the task file already
334
335             # Load entirely so we could simply manipulate
336             entries = open(taskfile).readlines()
337             known = False
338             # We need to search by name and by source
339             # We need to search for every possible type of dependecy
340             regexp = re.compile('^ *(%s) *: *(%s) *$' %
341                                 ('|'.join(PKG_FIELDS),
342                                 '|'.join((pkg.name, pkg.source))),
343                                 re.I)
344             for istart, e in enumerate(entries):
345                 if regexp.search(e):
346                     verbose(4, "Found %s in position %i: %s" %
347                             (pkg.name, istart, e.rstrip()))
348                     known = True
349                     break
350
351             descr = ' ; Added by %s %s. [Please note here if modified manually]\n' % \
352                     (__prog__,  __version__)
353             # Replace existing entry
354             if known:
355                 # TODO: Check if previous copy does not have our preceding comment
356                 # Find the previous end
357                 icount = 1
358                 try:
359                     while entries[istart+icount].strip() != '':
360                         icount += 1
361                 except IndexError, e:
362                     pass                # if we go beyond
363
364                 # Lets not change file without necessity, if entry is identical --
365                 # do nothing
366                 entry = pkg.dump()
367                 old_entry = entries[istart:istart+icount]
368
369                 if u''.join(old_entry) == entry:
370                    pass
371                 else: # Rewrite the entry
372                    if __prog__ in entries[istart-1]:
373                        istart -= 1
374                        icount += 2
375                    if not 'Removed' in pkg.keys():
376                        entries = entries[:istart] + [descr + entry] + entries[istart+icount:]
377                        msgs['Action'] = 'Changed'
378                    else:
379                        while entries[istart-1].strip() == '':
380                            istart -=1
381                            icount +=2
382                        entries = entries[:istart] + entries[istart+icount:]
383                        msgs['Action'] = 'Removed'
384                    output = ''.join(entries)         # 'compute' first
385                    open(taskfile, 'w').write(output) # then only overwrite
386             elif not 'removed' in pkg:  # or Append one
387                 msgs['Action'] = 'Added'
388                 # could be as simple as
389                 output = '\n%s%s' % (descr, pkg.dump(),)
390                 open(taskfile, 'a').write(output)
391
392             if msgs['Action']:
393                 verbose(3, "%(Action)s %(Name)s" % msgs)
394
395
396 class DebianMaterials(object):
397     """Extract selected information from an existing debian/
398     """
399     _WNPP_RE = re.compile('^ *\* *Initial release.*closes:? #(?P<bug>[0-9]*).*', re.I)
400
401     def __init__(self, topdir):
402         #self.topdir = topdir
403         self._debiandir = join(topdir, 'debian')
404         self._source = None
405         self._binaries = None
406
407     @property
408     def source(self):
409         if self._source is None:
410             self._assign_packages()
411         return self._source
412
413     @property
414     def binaries(self):
415         if self._binaries is None:
416             self._assign_packages()
417         return self._binaries
418
419     def fpath(self, name):
420         return join(self._debiandir, name)
421
422     def _assign_packages(self):
423         try:
424             control = deb822.Deb822.iter_paragraphs(
425                 open(self.fpath('control')))
426         except Exception, e:
427             raise RuntimeError(
428                   "Cannot parse %s file necessary for the %s package entry. Error: %s"
429                   % (control_file, pkg['Pkg-Name'], str(e)))
430         self._binaries = {}
431         self._source = None
432         for v in control:
433             if v.get('Source', None):
434                 self._source = v
435             else:
436                 self._binaries[v['Package']] = v
437
438     def get_license(self, package=None, first_only=True):
439         """Return a license(s). Parsed out from debian/copyright if it is
440         in machine readable format
441         """
442         licenses = []
443         # may be package should carry custom copyright file
444         copyright_file_ = self.fpath('%s.copyright' % package)
445         if package and exists(copyright_file_):
446             copyright_file = copyright_file_
447         else:
448             copyright_file = self.fpath('copyright')
449
450         try:
451             for p in deb822.Deb822.iter_paragraphs(open(copyright_file)):
452                 if not 'Files' in p or p['Files'].strip().startswith('debian/'):
453                     continue
454                 l = p['License']
455                 # Take only the short version of first line
456                 l = re.sub('\n.*', '', l).strip()
457                 if not len(l):
458                     l = 'custom'
459                 if not l in licenses:
460                     licenses.append(l)
461                     if first_only:
462                         break
463         except Exception, e:
464             # print e
465             return None
466         return ', '.join(licenses)
467
468     def get_wnpp(self):
469         """Search for a template changelog entry closing "Initial bug
470         """
471         for l in open(self.fpath('changelog')):
472             rr = self._WNPP_RE.match(l)
473             if rr:
474                 return rr.groupdict()['bug']
475         return None
476
477     def get_responsible(self):
478         """Returns responsible, atm -- maintainer
479         """
480         return self.source['Maintainer']
481
482     def get_vcsfields(self):
483         vcs = deb822.Deb822()
484         for f,v in self._source.iteritems():
485             if f.lower().startswith('vcs-'):
486                 vcs[f] = v
487         return vcs
488
489     def get_description(self, pkg_name):
490         """Some logic to extract description.
491
492            If binary package matching pkg_name is found -- gets it description.
493            If no binary package with such name, and name matches source name,
494            obtain description of the first binary package.
495         """
496         if pkg_name in self.binaries:
497             pass
498         elif pkg_name.lower() == self.source['Source'].lower():
499             pkg_name = self.binaries.keys()[0]
500         else:
501             error("Name %s does not match any binary, nor source package in %s"
502                   % (pkg_name, self))
503         return self.binaries[pkg_name]['Description']
504
505 def main():
506
507     p = OptionParser(
508                 usage="%s [OPTIONS] [blends_files]\n\n" % __prog__ + __doc__,
509                 version="%prog " + __version__)
510
511     p.add_option(
512         Option("-d", "--topdir", action="store",
513                dest="topdir", default=None,
514                help="Top directory of a Debian package. It is used to locate "
515                "'debian/blends' if none is specified, and where to look for "
516                "extended information."))
517
518     p.add_option(
519         Option("-c", "--config-file", action="store",
520                dest="config_file", default=join(expanduser('~'), '.%s.cfg' % __prog__),
521                help="Noise level."))
522
523     p.add_option(
524         Option("-v", "--verbosity", action="store", type="int",
525                dest="verbosity", default=1, help="Noise level."))
526
527     (options, infiles) = p.parse_args()
528     global verbosity; verbosity = options.verbosity
529
530     if not len(infiles):
531         infiles = [join(options.topdir or './', 'debian/blends')]     #  default one
532
533     # Load configuration
534     config = ConfigParser()
535     config.read(options.config_file)
536
537     for blends_file in infiles:
538         verbose(1, "Processing %s" % blends_file)
539         if not exists(blends_file):
540             error("Cannot find a file %s.  Either provide a file or specify top "
541                   "debian directory with -d." % blends_file, 1)
542         pkgs = parse_debian_blends(blends_file)
543         if options.topdir is None:
544             if dirname(blends_file).endswith('/debian'):
545                 topdir = dirname(dirname(blends_file))
546             else:
547                 topdir = '.'            # and hope for the best ;)
548         else:
549             topdir = options.topdir
550         expand_pkgs(pkgs, topdir=topdir)
551         tasks = group_packages_into_tasks(pkgs)
552         inject_tasks(tasks, config)
553
554
555 if __name__ == '__main__':
556     main()
557