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