]> git.donarmstrong.com Git - neurodebian.git/blob - tools/blends-inject
Merge branch 'master' of alioth:/git/pkg-exppsy/neurodebian
[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 Suggests:) 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 Remove: 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, tempfile
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.4'
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', 'remove')
134
135 # We might need to resort to assure some what a canonical order
136 # Prefixes for "standard" blends/tasks fields.  Others do not get embedded
137 # into tasks files
138 BLENDS_FIELDS_PREFIXES = ('depends', 'recommends', 'suggests', 'ignore',
139                 'why', 'homepage', 'language', 'wnpp', 'responsible', 'license',
140                 'vcs-', 'pkg-url', 'pkg-description',
141                 'published-', 'x-', 'registration', 'remark')
142 # Additional fields which might come useful (e.g. for filing wnpp bugs)
143 # but are not "standard" thus should be in the trailer
144 CUSTOM_FIELDS_PREFIXES = ('author', 'pkg-name', 'pkg-source',
145                           'version', 'remove')
146 # Other fields should cause Error for consistency
147
148 FIELDS_ORDER = BLENDS_FIELDS_PREFIXES + CUSTOM_FIELDS_PREFIXES
149
150 verbosity = None
151
152 def error(msg, exit_code=1):
153     sys.stderr.write(msg + '\n')
154     sys.exit(exit_code)
155
156 def verbose(level, msg):
157     if level <= verbosity:
158         sys.stderr.write(" "*level + msg + '\n')
159
160
161 def parse_debian_blends(f='debian/blends'):
162     """Parses debian/blends file
163
164     Returns unprocessed list of customized Deb822 entries
165     """
166     # Linearize all the paragraphs since we are not using them
167     items = []
168     for p in deb822.Deb822.iter_paragraphs(open(f)):
169         items += p.items()
170
171     verbose(6, "Got items %s" % items)
172     # Traverse and collect things
173     format_ = 'plain'
174     format_clean = False # do not propagate fields into a new pkg if True
175     pkg, source = None, None
176     pkgs = []
177     tasks = []
178
179     def new_pkg(prev_pkg, bname, sname, tasks):
180         """Helper function to create a new package
181         """
182         if format_clean or prev_pkg is None:
183             pkg = deb822.Deb822()
184         else:
185             pkg = deepcopy(prev_pkg)
186             for k_ in PKG_FIELDS:   # prune older depends
187                 pkg.pop(k_, None)
188         pkg['Pkg-Name'] = pkg[k] = bname
189         pkg['Pkg-Source'] = sname
190         pkgs.append(pkg)
191         pkg.tasks = dict( (t.strip(), deb822.Deb822Dict()) for t in tasks )
192         pkg.format = format_
193         return pkg
194
195     for k, v in items:
196
197         kl = k.lower()
198
199         if kl == 'source':
200             source = v.strip()
201         elif kl == 'format':
202             format_ = v.strip()
203             format_clean = format_.endswith('-clean')
204             if format_clean:
205                 format_ = format_[:-6]
206         elif kl == 'tasks':
207             tasks = v.split(',')
208             newtasks = pkg is not None      # either we need to provide tune-ups
209                                             # for current package
210         elif kl in PKG_FIELDS: # new package
211             if source is None:
212                 source = v
213             pkg = new_pkg(pkg, v, source, tasks)
214             newtasks = False
215         else:
216                         if pkg is None:
217                                 # So we had just source?
218                                 if source is None:
219                                         error("No package or source is known where to add %s" % (k,), 1)
220                                         # TODO: just deduce source from DebianMaterials
221                                 pkg = new_pkg(pkg, source, source, tasks)
222                                 # Since only source is available, it should be only Suggest:-ed
223                                 pkg['Suggests'] = source
224                                 newtasks = False
225
226             if newtasks:
227                 # Add customization
228                 for t in tasks:
229                     if not t in pkg.tasks:
230                         pkg.tasks[t] = deb822.Deb822Dict()
231                     pkg.tasks[t][k] = v
232             else:
233                 # just store the key in the pkg itself
234                 pkg[k] = v
235
236     return pkgs
237
238
239 def expand_pkgs(pkgs, topdir='.'):
240     """In-place modification of pkgs taking if necessary additional
241     information from Debian materials, and pruning empty fields
242     """
243     verbose(4, "Expanding content for %d packages" % len(pkgs))
244     debianm = None
245
246     # Expand packages which format is extended
247     for pkg in pkgs:
248         if pkg.format == 'extended':
249             # expanding, for that we need debian/control
250             if debianm is None:
251                 debianm = DebianMaterials(topdir)
252             for k, m in (('License', lambda: debianm.get_license(pkg['Pkg-Name'])),
253                          ('WNPP', debianm.get_wnpp),
254                          ('Pkg-Description',
255                           lambda: debianm.get_description(pkg['Pkg-Name'])),
256                          ('Responsible', debianm.get_responsible),
257                          ('Homepage', lambda: debianm.source.get('Homepage', None))):
258                 if pkg.get(k, None):
259                     continue
260                 v = m()
261                 if v:
262                     pkg[k] = v
263             # VCS fields
264             pkg.update(debianm.get_vcsfields())
265
266
267 def prefix_index(x, entries, strict=True, case=False, default=10000):
268     """Returns an index for the x in entries
269     """
270     if not case:
271         x = x.lower()
272     for i, v in enumerate(entries):
273         if x.startswith(v):
274             return i
275
276     if strict:
277         raise IndexError(
278             "Could not find location for %s as specified by %s" %
279             (x, entries))
280     return default
281
282
283 def key_prefix_compare(x, y, order, strict=True, case=False):
284     """Little helper to help with sorting
285
286     Sorts according to the order of string prefixes as given by
287     `order`.  If `strict`, then if no matching prefix found, would
288     raise KeyError; otherwise provides least priority to those keys
289     which were not found in `order`
290     """
291     if not case:
292         order = [v.lower() for v in order]
293
294     cmp_res =  cmp(prefix_index(x[0], order, strict, case),
295                    prefix_index(y[0], order, strict, case))
296     if not cmp_res:                     # still unknown
297         return cmp(x, y)
298     return cmp_res
299
300
301 def group_packages_into_tasks(pkgs):
302     """Given a list of packages (with .tasks) group them per each
303     task and perform necessary customizations stored in .tasks
304     """
305     # Time to take care about packages and tasks
306     # Unroll pkgs into a collection of pkgs per known task
307     tasks = {}
308     for pkg in pkgs:
309         # Lets just create deepcopies with tune-ups for each task
310         for itask, (task, fields) in enumerate(pkg.tasks.iteritems()):
311             pkg_ = deepcopy(pkg)
312             pkg_.update(fields)
313
314             # Perform string completions and removals
315             for k,v in pkg_.iteritems():
316                 pkg_[k] = v % pkg_
317                 if v is None or not len(v.strip()):
318                     pkg_.pop(k)
319
320             # Sort the fields according to FIELDS_ORDER. Unfortunately
321             # silly Deb822* cannot create from list of tuples, so will do
322             # manually
323             pkg__ = deb822.Deb822()
324             for k,v in sorted(pkg_.items(),
325                               cmp=lambda x, y:
326                               key_prefix_compare(x, y, order=FIELDS_ORDER)):
327                 pkg__[k] = v
328
329             # Move Pkg-source/name into attributes
330             pkg__.source = pkg__.pop('Pkg-Source')
331             pkg__.name = pkg__.pop('Pkg-Name')
332             # Store the action taken on the package for later on actions
333             for f in PKG_FIELDS:
334                 if f in pkg__:
335                     pkg__.action = f
336                     break
337
338             tasks[task] = tasks.get(task, []) + [pkg__]
339     verbose(4, "Grouped %d packages into %d tasks: %s" %
340             (len(pkgs), len(tasks), ', '.join(tasks.keys())))
341     return tasks
342
343 def inject_tasks(tasks, config):
344     # Now go through task files and replace/add entries
345     for task, pkgs in tasks.iteritems():
346         verbose(2, "Task %s with %d packages" % (task, len(pkgs)))
347         blend, puretask = task.split('/')
348         taskfile = join(config.get(blend, 'path'), 'tasks', puretask)
349
350         # Load the file
351         stats = dict(Added=[], Modified=[])
352         for pkg in pkgs:
353             msgs = {'Name': pkg.name.strip(), 'Action': None}
354
355             # Create a copy of the pkg with only valid tasks
356             # fields:
357             # TODO: make it configurable?
358             pkg = deepcopy(pkg)
359             for k in pkg:
360                 if prefix_index(k, BLENDS_FIELDS_PREFIXES,
361                                 strict=False, default=None) is None:
362                     pkg.pop(k) # remove it from becoming present in
363                                # the taskfile
364
365             # Find either it is known to the task file already
366
367             # Load entirely so we could simply manipulate
368             entries = open(taskfile).readlines()
369             known = False
370             # We need to search by name and by source
371             # We need to search for every possible type of dependecy
372             regexp = re.compile('^ *(%s) *: *(%s) *$' %
373                                 ('|'.join(PKG_FIELDS),
374                                 '|'.join((pkg.name, pkg.source))),
375                                 re.I)
376             for istart, e in enumerate(entries):
377                 if regexp.search(e):
378                     verbose(4, "Found %s in position %i: %s" %
379                             (pkg.name, istart, e.rstrip()))
380                     known = True
381                     break
382
383             descr = ' ; Added by %s %s. [Please note here if modified manually]\n' % \
384                     (__prog__,  __version__)
385
386             entry = pkg.dump()
387             # Replace existing entry?
388             if known:
389                 # TODO: Check if previous copy does not have our preceding comment
390                 # Find the previous end
391                 icount = 1
392                 try:
393                     while entries[istart+icount].strip() != '':
394                         icount += 1
395                 except IndexError, e:
396                     pass                # if we go beyond
397
398                 # Lets not change file without necessity, if entry is identical --
399                 # do nothing
400                 old_entry = entries[istart:istart+icount]
401
402                 if u''.join(old_entry) == entry:
403                     # no changes -- just go to the next one
404                     continue
405                 else: # Rewrite the entry
406                    if __prog__ in entries[istart-1]:
407                        istart -= 1
408                        icount += 2
409                    if 'remove' != pkg.action:
410                        entry = descr + entry
411                        msgs['Action'] = 'Changed'
412                    else:
413                        while entries[istart-1].strip() == '':
414                            istart -=1
415                            icount +=2
416                        entry = ''
417                        msgs['Action'] = 'Removed'
418                    entries_prior = entries[:istart]
419                    entries_post = entries[istart+icount:]
420             elif not 'remove' == pkg.action:  # or Append one
421                 msgs['Action'] = 'Added'
422                 entries_prior = entries
423                 entry = descr + entry
424                 entries_post = []
425                 # could be as simple as
426                 # Lets do 'in full' for consistent handling of empty lines
427                 # around
428                 #output = '\n%s%s' % (descr, pkg.dump(),)
429                 #open(taskfile, 'a').write(output)
430
431             if msgs['Action']:
432                 # Prepare for dumping
433                 # Prune spaces before
434                 while len(entries_prior) and entries_prior[-1].strip() == '':
435                     entries_prior = entries_prior[:-1]
436                 if len(entries_prior) and not entries_prior[-1].endswith('\n'):
437                     entries_prior[-1] += '\n' # assure present trailing newline
438                 # Prune spaces after
439                 while len(entries_post) and entries_post[0].strip() == '':
440                     entries_post = entries_post[1:]
441                 if len(entries_post) and len(entry):
442                     # only then trailing empty line
443                     entry += '\n'
444                 output = ''.join(entries_prior + [ '\n' + entry ] + entries_post)
445                 open(taskfile, 'w').write(output) # then only overwrite
446
447                 verbose(3, "%(Action)s %(Name)s" % msgs)
448
449
450 class DebianMaterials(object):
451     """Extract selected information from an existing debian/
452     """
453     _WNPP_RE = re.compile('^ *\* *Initial release.*closes:? #(?P<bug>[0-9]*).*', re.I)
454
455     def __init__(self, topdir):
456         #self.topdir = topdir
457         self._debiandir = join(topdir, 'debian')
458         self._source = None
459         self._binaries = None
460
461     @property
462     def source(self):
463         if self._source is None:
464             self._assign_packages()
465         return self._source
466
467     @property
468     def binaries(self):
469         if self._binaries is None:
470             self._assign_packages()
471         return self._binaries
472
473     def fpath(self, name):
474         return join(self._debiandir, name)
475
476     def _assign_packages(self):
477         try:
478             control = deb822.Deb822.iter_paragraphs(
479                 open(self.fpath('control')))
480         except Exception, e:
481             raise RuntimeError(
482                   "Cannot parse %s file necessary for the %s package entry. Error: %s"
483                   % (control_file, pkg['Pkg-Name'], str(e)))
484         self._binaries = {}
485         self._source = None
486         for v in control:
487             if v.get('Source', None):
488                 self._source = v
489             else:
490                 self._binaries[v['Package']] = v
491
492     def get_license(self, package=None, first_only=True):
493         """Return a license(s). Parsed out from debian/copyright if it is
494         in machine readable format
495         """
496         licenses = []
497         # may be package should carry custom copyright file
498         copyright_file_ = self.fpath('%s.copyright' % package)
499         if package and exists(copyright_file_):
500             copyright_file = copyright_file_
501         else:
502             copyright_file = self.fpath('copyright')
503
504         try:
505             for p in deb822.Deb822.iter_paragraphs(open(copyright_file)):
506                 if not 'Files' in p or p['Files'].strip().startswith('debian/'):
507                     continue
508                 l = p['License']
509                 # Take only the short version of first line
510                 l = re.sub('\n.*', '', l).strip()
511                 if not len(l):
512                     l = 'custom'
513                 if not l in licenses:
514                     licenses.append(l)
515                     if first_only:
516                         break
517         except Exception, e:
518             # print e
519             return None
520         return ', '.join(licenses)
521
522     def get_wnpp(self):
523         """Search for a template changelog entry closing "Initial bug
524         """
525         for l in open(self.fpath('changelog')):
526             rr = self._WNPP_RE.match(l)
527             if rr:
528                 return rr.groupdict()['bug']
529         return None
530
531     def get_responsible(self):
532         """Returns responsible, atm -- maintainer
533         """
534         return self.source['Maintainer']
535
536     def get_vcsfields(self):
537         vcs = deb822.Deb822()
538         for f,v in self._source.iteritems():
539             if f.lower().startswith('vcs-'):
540                 vcs[f] = v
541         return vcs
542
543     def get_description(self, pkg_name):
544         """Some logic to extract description.
545
546            If binary package matching pkg_name is found -- gets it description.
547            If no binary package with such name, and name matches source name,
548            obtain description of the first binary package.
549         """
550         if pkg_name in self.binaries:
551             pass
552         elif pkg_name.lower() == self.source['Source'].lower():
553             pkg_name = self.binaries.keys()[0]
554         else:
555             error("Name %s does not match any binary, nor source package in %s"
556                   % (pkg_name, self))
557         return self.binaries[pkg_name]['Description']
558
559 def print_wnpp(pkgs, config, wnpp_type="ITP"):
560     """Little helper to spit out formatted entry for WNPP bugreport
561
562     TODO: It would puke atm if any field is missing
563     """
564
565     pkg = pkgs[0]                       # everything is based on the 1st one
566     opts = dict(pkg.items())
567     opts['WNPP-Type'] = wnpp_type.upper()
568     opts['Pkg-Description-Short'] = re.sub('\n.*', '', pkg['Pkg-Description'])
569
570     subject = "%(WNPP-Type)s: %(Pkg-Name)s -- %(Pkg-Description-Short)s" % opts
571     body = """*** Please type your report below this line ***
572
573 * Package name    : %(Pkg-Name)s
574   Version         : %(Version)s
575   Upstream Author : %(Author)s
576 * URL             : %(Homepage)s
577 * License         : %(License)s
578   Programming Lang: %(Language)s
579   Description     : %(Pkg-Description)s
580
581 """ % opts
582
583     # Unfortunately could not figure out how to set the owner, so I will just print it out
584     if False:
585         tmpfile = tempfile.NamedTemporaryFile()
586         tmpfile.write(body)
587         tmpfile.flush()
588         cmd = "reportbug -b --paranoid --subject='%s' --severity=wishlist --body-file='%s' -o /tmp/o.txt wnpp" \
589               % (subject, tmpfile.name)
590         verbose(2, "Running %s" %cmd)
591         os.system(cmd)
592     else:
593         print "Subject: %s\n\n%s" % (subject, body)
594
595
596 def main():
597
598     p = OptionParser(
599                 usage="%s [OPTIONS] [blends_files]\n\n" % __prog__ + __doc__,
600                 version="%prog " + __version__)
601
602     p.add_option(
603         Option("-d", "--topdir", action="store",
604                dest="topdir", default=None,
605                help="Top directory of a Debian package. It is used to locate "
606                "'debian/blends' if none is specified, and where to look for "
607                "extended information."))
608
609     p.add_option(
610         Option("-c", "--config-file", action="store",
611                dest="config_file", default=join(expanduser('~'), '.%s.cfg' % __prog__),
612                help="Noise level."))
613
614     p.add_option(
615         Option("-v", "--verbosity", action="store", type="int",
616                dest="verbosity", default=1, help="Noise level."))
617
618     # We might like to create a separate 'group' of options for commands
619     p.add_option(
620         Option("-w", action="store_true",
621                dest="wnpp", default=False,
622                help="Operate in WNPP mode: dumps cut-paste-able entry for WNPP bugreport"))
623
624     p.add_option(
625         Option("--wnpp", action="store",
626                dest="wnpp_mode", default=None,
627                help="Operate in WNPP mode: dumps cut-paste-able entry for WNPP bugreport"))
628
629
630     (options, infiles) = p.parse_args()
631     global verbosity; verbosity = options.verbosity
632
633         if options.wnpp and options.wnpp_mode is None:
634              options.wnpp_mode = 'ITP'
635
636     if not len(infiles):
637         infiles = [join(options.topdir or './', 'debian/blends')]     #  default one
638
639     # Load configuration
640     config = ConfigParser()
641     config.read(options.config_file)
642
643     for blends_file in infiles:
644         verbose(1, "Processing %s" % blends_file)
645         if not exists(blends_file):
646             error("Cannot find a file %s.  Either provide a file or specify top "
647                   "debian directory with -d." % blends_file, 1)
648         pkgs = parse_debian_blends(blends_file)
649         if options.topdir is None:
650             if dirname(blends_file).endswith('/debian'):
651                 topdir = dirname(dirname(blends_file))
652             else:
653                 topdir = '.'            # and hope for the best ;)
654         else:
655             topdir = options.topdir
656
657                 expand_pkgs(pkgs, topdir=topdir)
658         if options.wnpp_mode is not None:
659                     print_wnpp(pkgs, config, options.wnpp_mode)
660         else:
661             # by default -- operate on blends/tasks files
662             tasks = group_packages_into_tasks(pkgs)
663             inject_tasks(tasks, config)
664
665
666 if __name__ == '__main__':
667     main()
668