DebianFiles.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. # vim:set fileencoding=utf-8 et ts=4 sts=4 sw=4:
  2. #
  3. # apt-listchanges - Show changelog entries between the installed versions
  4. # of a set of packages and the versions contained in
  5. # corresponding .deb files
  6. #
  7. # Copyright (C) 2000-2006 Matt Zimmerman <mdz@debian.org>
  8. # Copyright (C) 2006 Pierre Habouzit <madcoder@debian.org>
  9. # Copyright (C) 2016 Robert Luberda <robert@debian.org>
  10. #
  11. # This program is free software; you can redistribute it and/or modify
  12. # it under the terms of the GNU General Public License as published by
  13. # the Free Software Foundation; either version 2 of the License, or
  14. # (at your option) any later version.
  15. #
  16. # This program is distributed in the hope that it will be useful,
  17. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. # GNU General Public License for more details.
  20. #
  21. # You should have received a copy of the GNU General Public
  22. # License along with this program; if not, write to the Free
  23. # Software Foundation, Inc., 59 Temple Place, Suite 330, Boston,
  24. # MA 02111-1307 USA
  25. #
  26. import re
  27. import sys, os
  28. import tempfile
  29. import gzip
  30. import errno
  31. import glob
  32. import shutil
  33. import shlex
  34. import signal
  35. import subprocess
  36. import apt_pkg
  37. import ALCLog
  38. from ALChacks import _
  39. from functools import reduce
  40. # TODO:
  41. # indexed lookups by package at least, maybe by arbitrary field
  42. def _numeric_urgency(u):
  43. urgency_map = { 'critical' : 1,
  44. 'emergency' : 1,
  45. 'high' : 2,
  46. 'medium' : 3,
  47. 'low' : 4 }
  48. return urgency_map.get(u.lower(), 0)
  49. class ControlStanza:
  50. source_version_re = re.compile('^\S+ \((?P<version>.*)\).*')
  51. fields_to_read = [ 'Package', 'Source', 'Version', 'Architecture', 'Status' ]
  52. def __init__(self, s):
  53. field = None
  54. for line in s.split('\n'):
  55. if not line:
  56. break
  57. if line[0] in (' ', '\t'):
  58. if field:
  59. setattr(self, field, getattr(self, field) + '\n' + line)
  60. else:
  61. field, value = line.split(':', 1)
  62. if field in self.fields_to_read:
  63. setattr(self, field, value.lstrip())
  64. else:
  65. field = None
  66. def source(self):
  67. return getattr(self, 'Source', self.Package).split(' ')[0]
  68. def installed(self):
  69. return hasattr(self, 'Status') and self.Status.split(' ')[2] == 'installed'
  70. def version(self):
  71. """
  72. This function returns the version of the package. One would like it to
  73. be the "binary" version, though we have the tough case of source
  74. package whose binary packages versioning scheme is different from the
  75. source one (see OOo, linux-source, ...).
  76. This code does the following, if the Source field is set with a
  77. specified version, then we use the binary version if and only if the
  78. source version is a prefix. We must do that because of binNMUs.
  79. """
  80. v = self.Version
  81. if hasattr(self, 'Source'):
  82. match = self.source_version_re.match(self.Source)
  83. if match:
  84. sv = match.group('version')
  85. if not v.startswith(sv):
  86. return sv
  87. return v
  88. class ControlParser:
  89. def __init__(self):
  90. self.stanzas = []
  91. self.index = {}
  92. def makeindex(self, field):
  93. self.index[field] = {}
  94. for stanza in self.stanzas:
  95. self.index[field][getattr(stanza, field)] = stanza
  96. def readfile(self, file):
  97. try:
  98. self.stanzas += [ControlStanza(x) for x in open(file, 'r', encoding='utf-8', errors='replace').read().split('\n\n') if x]
  99. except Exception as ex:
  100. raise RuntimeError(_("Error processing '%(what)s': %(errmsg)s") %
  101. {'what': file, 'errmsg': str(ex)}) from ex
  102. def readdeb(self, deb):
  103. try:
  104. command = ['dpkg-deb', '-f', deb] + ControlStanza.fields_to_read
  105. output = subprocess.check_output(command)
  106. self.stanzas.append(ControlStanza(output.decode('utf-8', 'replace')))
  107. except Exception as ex:
  108. raise RuntimeError(_("Error processing '%(what)s': %(errmsg)s") %
  109. {'what': file, 'errmsg': str(ex)}) from ex
  110. def find(self, field, value):
  111. if field in self.index:
  112. if value in self.index[field]:
  113. return self.index[field][value]
  114. else:
  115. return None
  116. else:
  117. for stanza in self.stanzas:
  118. if hasattr(stanza, field) and getattr(stanza, field) == value:
  119. return stanza
  120. return None
  121. class ChangelogParser:
  122. _changelog_header = re.compile('^\S+ \((?P<version>.*)\) .*;.*urgency=(?P<urgency>\w+).*')
  123. _changelog_header_ancient = re.compile('^(\S+ \(?\d.*\)|Old Changelog:|Changes|ChangeLog begins|Mon|Tue|Wed|Thu|Fri|Sat|Sun).*')
  124. _changelog_header_emacs = re.compile('(;;\s*)?Local\s+variables.*', re.IGNORECASE)
  125. def __init__(self):
  126. self._entry = ''
  127. self._entries = []
  128. def parse(self, fd, since_version):
  129. '''Parse changelog or news from the given file descriptor.
  130. If since_version is specified, only return entries later
  131. than the specified version.'''
  132. urgency = _numeric_urgency('low')
  133. is_debian_changelog = False
  134. ancient = False
  135. for line in fd.readlines():
  136. line = line.decode('utf-8', 'replace')
  137. if line.startswith(' ') or line == '\n':
  138. self._add_line(line)
  139. elif line.startswith('#'):
  140. continue
  141. else:
  142. match = self._changelog_header.match(line) if not ancient else None
  143. if match:
  144. if since_version and apt_pkg.version_compare(match.group('version'),
  145. since_version) <= 0:
  146. break
  147. is_debian_changelog = True
  148. urgency = min(_numeric_urgency(match.group('urgency')),
  149. urgency)
  150. self._save_entry(line)
  151. elif self._changelog_header_ancient.match(line):
  152. if not is_debian_changelog: # probably upstream changelog in GNU format
  153. break
  154. ancient = True
  155. self._save_entry(line)
  156. elif self._changelog_header_emacs.match(line):
  157. break
  158. elif is_debian_changelog:
  159. self._add_line(line)
  160. else:
  161. break
  162. if not is_debian_changelog:
  163. return None, urgency
  164. self._save_entry(None)
  165. return self._entries, urgency
  166. def _add_line(self, line):
  167. self._entry += line
  168. def _save_entry(self, new_entry_header):
  169. self._entry = self._entry.strip()
  170. if self._entry != '':
  171. self._entries += [ self._entry + '\n' ]
  172. self._entry = new_entry_header
  173. class Package:
  174. def __init__(self, path):
  175. self.path = path
  176. parser = ControlParser()
  177. parser.readdeb(self.path)
  178. pkgdata = parser.stanzas[0]
  179. self.binary = pkgdata.Package
  180. self.source = pkgdata.source()
  181. self.Version = pkgdata.version()
  182. self.arch = pkgdata.Architecture
  183. def extract_changes(self, which, since_version=None, reverse=None):
  184. '''Extract changelog entries, news or both from the package.
  185. If since_version is specified, only return entries later than the specified version.
  186. returns a sequence of Changes objects.'''
  187. news_filenames = self._changelog_variations('NEWS.Debian')
  188. changelog_filenames = self._changelog_variations('changelog.Debian')
  189. changelog_filenames_binnmu = self._changelog_variations('changelog.Debian.' + self.arch)
  190. changelog_filenames_native = self._changelog_variations('changelog')
  191. filenames = []
  192. if which == 'both' or which == 'news':
  193. filenames.extend(news_filenames)
  194. if which == 'both' or which == 'changelogs':
  195. filenames.extend(changelog_filenames)
  196. filenames.extend(changelog_filenames_binnmu)
  197. filenames.extend(changelog_filenames_native)
  198. tempdir = self._extract_contents(filenames)
  199. find_first = lambda acc, fname: acc or self._read_changelog(os.path.join(tempdir, fname), since_version, reverse)
  200. news = reduce(find_first, news_filenames, None)
  201. changelog = reduce(find_first, changelog_filenames + changelog_filenames_native, None)
  202. binnmu = reduce(find_first, changelog_filenames_binnmu, None)
  203. shutil.rmtree(tempdir, 1)
  204. return (news, changelog, binnmu)
  205. def _extract_contents(self, filenames):
  206. tempdir = tempfile.mkdtemp(prefix='apt-listchanges')
  207. extract_command = 'dpkg-deb --fsys-tarfile %s | tar xf - --wildcards -C %s %s 2>/dev/null' % (
  208. shlex.quote(self.path), shlex.quote(tempdir),
  209. ' '.join([shlex.quote(x) for x in filenames])
  210. )
  211. # tar exits unsuccessfully if _any_ of the files we wanted
  212. # were not available, so we can't do much with its status
  213. status = os.system(extract_command)
  214. if os.WIFSIGNALED(status) and os.WTERMSIG(status) == signal.SIGINT:
  215. shutil.rmtree(tempdir, 1)
  216. raise KeyboardInterrupt
  217. return tempdir
  218. def _open_changelog_file(self, filename):
  219. filenames = glob.glob(filename)
  220. for filename in filenames:
  221. try:
  222. if os.path.isdir(filename):
  223. ALCLog.error(_("Ignoring `%s' (seems to be a directory!)") % filename)
  224. elif filename.endswith('.gz'):
  225. return gzip.GzipFile(filename)
  226. else:
  227. return open(filename)
  228. break
  229. except IOError as e:
  230. if e.errno == errno.ENOENT:
  231. pass
  232. else:
  233. raise
  234. return None
  235. def _read_changelog(self, filename, since_version, reverse=False):
  236. fd = self._open_changelog_file(filename)
  237. if not fd:
  238. return None
  239. entries, urgency = ChangelogParser().parse(fd, since_version)
  240. if not urgency:
  241. return None # not a Debian changelog file
  242. if not entries:
  243. # Valid Debian changelog, but all entries filtered out due
  244. # to --since-version. Return empty string to prevent the caller
  245. # from searching for another Debian changelog file.
  246. return Changes(self.source, "", urgency)
  247. if reverse:
  248. entries.reverse()
  249. changes = "\n".join(entries) + "\n"
  250. return Changes(self.source, changes, urgency)
  251. def _changelog_variations(self, filename):
  252. formats = ['./usr/share/doc/*/%s.gz',
  253. './usr/share/doc/*/%s']
  254. return [x % filename for x in formats]
  255. class Changes:
  256. def __init__(self, package, changes, urgency):
  257. self.package = package
  258. self.changes = changes
  259. self.urgency = urgency
  260. def merge_binnmu(self, other, reverse = False):
  261. assert self.package == other.package
  262. self.urgency = min(self.urgency, other.urgency)
  263. # Assumption: binnmu has greater version than regular changelog
  264. if reverse:
  265. self.changes = self.changes + other.changes
  266. else:
  267. self.changes = other.changes + self.changes
  268. __all__ = [ 'ControlParser', 'Package' ]