]> git.donarmstrong.com Git - neurodebian.git/blob - tools/blends-inject
RF: blends-inject -- use Suggests (instead of Ignore) for "source-only" entries
[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
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.3'
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', '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', 'language', '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         print " "*level, msg
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         if kl == 'source':
199             source = v.strip()
200         elif kl == 'format':
201             format_ = v.strip()
202             format_clean = format_.endswith('-clean')
203             if format_clean:
204                 format_ = format_[:-6]
205         elif kl == 'tasks':
206             tasks = v.split(',')
207             newtasks = True                 # either we need to provide tune-ups
208                                             # for current package
209         elif kl in PKG_FIELDS: # new package
210             if source is None:
211                 source = v
212             pkg = new_pkg(pkg, v, source, tasks)
213             newtasks = False
214         else:
215             if newtasks:
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                 # Add customization
226                 for t in tasks:
227                     if not t in pkg.tasks:
228                         pkg.tasks[t] = deb822.Deb822Dict()
229                     pkg.tasks[t][k] = v
230             else:
231                 # just store the key in the pkg itself
232                 pkg[k] = v
233     return pkgs
234
235
236 def expand_pkgs(pkgs, topdir='.'):
237     """In-place modification of pkgs taking if necessary additional
238     information from Debian materials, and pruning empty fields
239     """
240     verbose(4, "Expanding content for %d packages" % len(pkgs))
241     debianm = None
242     # Expand packages which format is extended
243     for pkg in pkgs:
244         if pkg.format == 'extended':
245             # expanding, for that we need debian/control
246             if debianm is None:
247                 debianm = DebianMaterials(topdir)
248             for k, m in (('License', lambda: debianm.get_license(pkg['Pkg-Name'])),
249                          ('WNPP', debianm.get_wnpp),
250                          ('Pkg-Description',
251                           lambda: debianm.get_description(pkg['Pkg-Name'])),
252                          ('Responsible', debianm.get_responsible),
253                          ('Homepage', lambda: debianm.source.get('Homepage', None))):
254                 if pkg.get(k, None):
255                     continue
256                 v = m()
257                 if v:
258                     pkg[k] = v
259             # VCS fields
260             pkg.update(debianm.get_vcsfields())
261
262
263 def prefix_index(x, entries, strict=True, case=False, default=10000):
264     """Returns an index for the x in entries
265     """
266     if not case:
267         x = x.lower()
268     for i, v in enumerate(entries):
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, entries))
276     return default
277
278
279 def key_prefix_compare(x, y, order, strict=True, case=False):
280     """Little helper to help with sorting
281
282     Sorts according to the order of string prefixes as given by
283     `order`.  If `strict`, then if no matching prefix found, would
284     raise KeyError; otherwise provides least priority to those keys
285     which were not found in `order`
286     """
287     if not case:
288         order = [v.lower() for v in order]
289
290     cmp_res =  cmp(prefix_index(x[0], order, strict, case),
291                    prefix_index(y[0], order, strict, case))
292     if not cmp_res:                     # still unknown
293         return cmp(x, y)
294     return cmp_res
295
296
297 def group_packages_into_tasks(pkgs):
298     """Given a list of packages (with .tasks) group them per each
299     task and perform necessary customizations stored in .tasks
300     """
301     # Time to take care about packages and tasks
302     # Unroll pkgs into a collection of pkgs per known task
303     tasks = {}
304     for pkg in pkgs:
305         # Lets just create deepcopies with tune-ups for each task
306         for itask, (task, fields) in enumerate(pkg.tasks.iteritems()):
307             pkg_ = deepcopy(pkg)
308             pkg_.update(fields)
309
310             # Perform string completions and removals
311             for k,v in pkg_.iteritems():
312                 pkg_[k] = v % pkg_
313                 if v is None or not len(v.strip()):
314                     pkg_.pop(k)
315
316             # Sort the fields according to FIELDS_ORDER. Unfortunately
317             # silly Deb822* cannot create from list of tuples, so will do
318             # manually
319             pkg__ = deb822.Deb822()
320             for k,v in sorted(pkg_.items(),
321                               cmp=lambda x, y:
322                               key_prefix_compare(x, y, order=FIELDS_ORDER)):
323                 pkg__[k] = v
324
325             # Move Pkg-source/name into attributes
326             pkg__.source = pkg__.pop('Pkg-Source')
327             pkg__.name = pkg__.pop('Pkg-Name')
328             # Store the action taken on the package for later on actions
329             for f in PKG_FIELDS:
330                 if f in pkg__:
331                     pkg__.action = f
332                     break
333
334             tasks[task] = tasks.get(task, []) + [pkg__]
335     verbose(4, "Grouped %d packages into %d tasks: %s" %
336             (len(pkgs), len(tasks), ', '.join(tasks.keys())))
337     return tasks
338
339 def inject_tasks(tasks, config):
340     # Now go through task files and replace/add entries
341     for task, pkgs in tasks.iteritems():
342         verbose(2, "Task %s with %d packages" % (task, len(pkgs)))
343         blend, puretask = task.split('/')
344         taskfile = join(config.get(blend, 'path'), 'tasks', puretask)
345
346         # Load the file
347         stats = dict(Added=[], Modified=[])
348         for pkg in pkgs:
349             msgs = {'Name': pkg.name.strip(), 'Action': None}
350
351             # Create a copy of the pkg with only valid tasks
352             # fields:
353             # TODO: make it configurable?
354             pkg = deepcopy(pkg)
355             for k in pkg:
356                 if prefix_index(k, BLENDS_FIELDS_PREFIXES,
357                                 strict=False, default=None) is None:
358                     pkg.pop(k) # remove it from becoming present in
359                                # the taskfile
360
361             # Find either it is known to the task file already
362
363             # Load entirely so we could simply manipulate
364             entries = open(taskfile).readlines()
365             known = False
366             # We need to search by name and by source
367             # We need to search for every possible type of dependecy
368             regexp = re.compile('^ *(%s) *: *(%s) *$' %
369                                 ('|'.join(PKG_FIELDS),
370                                 '|'.join((pkg.name, pkg.source))),
371                                 re.I)
372             for istart, e in enumerate(entries):
373                 if regexp.search(e):
374                     verbose(4, "Found %s in position %i: %s" %
375                             (pkg.name, istart, e.rstrip()))
376                     known = True
377                     break
378
379             descr = ' ; Added by %s %s. [Please note here if modified manually]\n' % \
380                     (__prog__,  __version__)
381
382             entry = pkg.dump()
383             # Replace existing entry?
384             if known:
385                 # TODO: Check if previous copy does not have our preceding comment
386                 # Find the previous end
387                 icount = 1
388                 try:
389                     while entries[istart+icount].strip() != '':
390                         icount += 1
391                 except IndexError, e:
392                     pass                # if we go beyond
393
394                 # Lets not change file without necessity, if entry is identical --
395                 # do nothing
396                 old_entry = entries[istart:istart+icount]
397
398                 if u''.join(old_entry) == entry:
399                     # no changes -- just go to the next one
400                     continue
401                 else: # Rewrite the entry
402                    if __prog__ in entries[istart-1]:
403                        istart -= 1
404                        icount += 2
405                    if 'remove' != pkg.action:
406                        entry = descr + entry
407                        msgs['Action'] = 'Changed'
408                    else:
409                        while entries[istart-1].strip() == '':
410                            istart -=1
411                            icount +=2
412                        entry = ''
413                        msgs['Action'] = 'Removed'
414                    entries_prior = entries[:istart]
415                    entries_post = entries[istart+icount:]
416             elif not 'remove' == pkg.action:  # or Append one
417                 if pkg.name == 'python-brian-doc':
418                     import pydb
419                     pydb.debugger()
420                 msgs['Action'] = 'Added'
421                 entries_prior = entries
422                 entry = descr + entry
423                 entries_post = []
424                 # could be as simple as
425                 # Lets do 'in full' for consistent handling of empty lines
426                 # around
427                 #output = '\n%s%s' % (descr, pkg.dump(),)
428                 #open(taskfile, 'a').write(output)
429
430             if msgs['Action']:
431                 # Prepare for dumping
432                 # Prune spaces before
433                 while len(entries_prior) and entries_prior[-1].strip() == '':
434                     entries_prior = entries_prior[:-1]
435                 if len(entries_prior) and not entries_prior[-1].endswith('\n'):
436                     entries_prior[-1] += '\n' # assure present trailing newline
437                 # Prune spaces after
438                 while len(entries_post) and entries_post[0].strip() == '':
439                     entries_post = entries_post[1:]
440                 if len(entries_post) and len(entry):
441                     # only then trailing empty line
442                     entry += '\n'
443                 output = ''.join(entries_prior + [ '\n' + entry ] + entries_post)
444                 open(taskfile, 'w').write(output) # then only overwrite
445
446                 verbose(3, "%(Action)s %(Name)s" % msgs)
447
448
449 class DebianMaterials(object):
450     """Extract selected information from an existing debian/
451     """
452     _WNPP_RE = re.compile('^ *\* *Initial release.*closes:? #(?P<bug>[0-9]*).*', re.I)
453
454     def __init__(self, topdir):
455         #self.topdir = topdir
456         self._debiandir = join(topdir, 'debian')
457         self._source = None
458         self._binaries = None
459
460     @property
461     def source(self):
462         if self._source is None:
463             self._assign_packages()
464         return self._source
465
466     @property
467     def binaries(self):
468         if self._binaries is None:
469             self._assign_packages()
470         return self._binaries
471
472     def fpath(self, name):
473         return join(self._debiandir, name)
474
475     def _assign_packages(self):
476         try:
477             control = deb822.Deb822.iter_paragraphs(
478                 open(self.fpath('control')))
479         except Exception, e:
480             raise RuntimeError(
481                   "Cannot parse %s file necessary for the %s package entry. Error: %s"
482                   % (control_file, pkg['Pkg-Name'], str(e)))
483         self._binaries = {}
484         self._source = None
485         for v in control:
486             if v.get('Source', None):
487                 self._source = v
488             else:
489                 self._binaries[v['Package']] = v
490
491     def get_license(self, package=None, first_only=True):
492         """Return a license(s). Parsed out from debian/copyright if it is
493         in machine readable format
494         """
495         licenses = []
496         # may be package should carry custom copyright file
497         copyright_file_ = self.fpath('%s.copyright' % package)
498         if package and exists(copyright_file_):
499             copyright_file = copyright_file_
500         else:
501             copyright_file = self.fpath('copyright')
502
503         try:
504             for p in deb822.Deb822.iter_paragraphs(open(copyright_file)):
505                 if not 'Files' in p or p['Files'].strip().startswith('debian/'):
506                     continue
507                 l = p['License']
508                 # Take only the short version of first line
509                 l = re.sub('\n.*', '', l).strip()
510                 if not len(l):
511                     l = 'custom'
512                 if not l in licenses:
513                     licenses.append(l)
514                     if first_only:
515                         break
516         except Exception, e:
517             # print e
518             return None
519         return ', '.join(licenses)
520
521     def get_wnpp(self):
522         """Search for a template changelog entry closing "Initial bug
523         """
524         for l in open(self.fpath('changelog')):
525             rr = self._WNPP_RE.match(l)
526             if rr:
527                 return rr.groupdict()['bug']
528         return None
529
530     def get_responsible(self):
531         """Returns responsible, atm -- maintainer
532         """
533         return self.source['Maintainer']
534
535     def get_vcsfields(self):
536         vcs = deb822.Deb822()
537         for f,v in self._source.iteritems():
538             if f.lower().startswith('vcs-'):
539                 vcs[f] = v
540         return vcs
541
542     def get_description(self, pkg_name):
543         """Some logic to extract description.
544
545            If binary package matching pkg_name is found -- gets it description.
546            If no binary package with such name, and name matches source name,
547            obtain description of the first binary package.
548         """
549         if pkg_name in self.binaries:
550             pass
551         elif pkg_name.lower() == self.source['Source'].lower():
552             pkg_name = self.binaries.keys()[0]
553         else:
554             error("Name %s does not match any binary, nor source package in %s"
555                   % (pkg_name, self))
556         return self.binaries[pkg_name]['Description']
557
558 def main():
559
560     p = OptionParser(
561                 usage="%s [OPTIONS] [blends_files]\n\n" % __prog__ + __doc__,
562                 version="%prog " + __version__)
563
564     p.add_option(
565         Option("-d", "--topdir", action="store",
566                dest="topdir", default=None,
567                help="Top directory of a Debian package. It is used to locate "
568                "'debian/blends' if none is specified, and where to look for "
569                "extended information."))
570
571     p.add_option(
572         Option("-c", "--config-file", action="store",
573                dest="config_file", default=join(expanduser('~'), '.%s.cfg' % __prog__),
574                help="Noise level."))
575
576     p.add_option(
577         Option("-v", "--verbosity", action="store", type="int",
578                dest="verbosity", default=1, help="Noise level."))
579
580     (options, infiles) = p.parse_args()
581     global verbosity; verbosity = options.verbosity
582
583     if not len(infiles):
584         infiles = [join(options.topdir or './', 'debian/blends')]     #  default one
585
586     # Load configuration
587     config = ConfigParser()
588     config.read(options.config_file)
589
590     for blends_file in infiles:
591         verbose(1, "Processing %s" % blends_file)
592         if not exists(blends_file):
593             error("Cannot find a file %s.  Either provide a file or specify top "
594                   "debian directory with -d." % blends_file, 1)
595         pkgs = parse_debian_blends(blends_file)
596         if options.topdir is None:
597             if dirname(blends_file).endswith('/debian'):
598                 topdir = dirname(dirname(blends_file))
599             else:
600                 topdir = '.'            # and hope for the best ;)
601         else:
602             topdir = options.topdir
603         expand_pkgs(pkgs, topdir=topdir)
604         tasks = group_packages_into_tasks(pkgs)
605         inject_tasks(tasks, config)
606
607
608 if __name__ == '__main__':
609     main()
610