33
|
1 # -*- coding: utf-8 -*-
|
|
2
|
|
3 # version.py
|
|
4 # Part of ‘python-daemon’, an implementation of PEP 3143.
|
|
5 #
|
|
6 # Copyright © 2008–2015 Ben Finney <ben+python@benfinney.id.au>
|
|
7 #
|
|
8 # This is free software: you may copy, modify, and/or distribute this work
|
|
9 # under the terms of the GNU General Public License as published by the
|
|
10 # Free Software Foundation; version 3 of that license or any later version.
|
|
11 # No warranty expressed or implied. See the file ‘LICENSE.GPL-3’ for details.
|
|
12
|
|
13 """ Version information unified for human- and machine-readable formats.
|
|
14
|
|
15 The project ‘ChangeLog’ file is a reStructuredText document, with
|
|
16 each section describing a version of the project. The document is
|
|
17 intended to be readable as-is by end users.
|
|
18
|
|
19 This module handles transformation from the ‘ChangeLog’ to a
|
|
20 mapping of version information, serialised as JSON. It also
|
|
21 provides functionality for Distutils to use this information.
|
|
22
|
|
23 Requires:
|
|
24
|
|
25 * Docutils <http://docutils.sourceforge.net/>
|
|
26 * JSON <https://docs.python.org/3/reference/json.html>
|
|
27
|
|
28 """
|
|
29
|
|
30 from __future__ import (absolute_import, unicode_literals)
|
|
31
|
|
32 import sys
|
|
33 import os
|
|
34 import io
|
|
35 import errno
|
|
36 import json
|
|
37 import datetime
|
|
38 import textwrap
|
|
39 import re
|
|
40 import functools
|
|
41 import collections
|
|
42 import distutils
|
|
43 import distutils.errors
|
|
44 import distutils.cmd
|
|
45 try:
|
|
46 # Python 2 has both ‘str’ (bytes) and ‘unicode’ (text).
|
|
47 basestring = basestring
|
|
48 unicode = unicode
|
|
49 except NameError:
|
|
50 # Python 3 names the Unicode data type ‘str’.
|
|
51 basestring = str
|
|
52 unicode = str
|
|
53
|
|
54 import setuptools
|
|
55 import setuptools.command.egg_info
|
|
56
|
|
57
|
|
58 def ensure_class_bases_begin_with(namespace, class_name, base_class):
|
|
59 """ Ensure the named class's bases start with the base class.
|
|
60
|
|
61 :param namespace: The namespace containing the class name.
|
|
62 :param class_name: The name of the class to alter.
|
|
63 :param base_class: The type to be the first base class for the
|
|
64 newly created type.
|
|
65 :return: ``None``.
|
|
66
|
|
67 This function is a hack to circumvent a circular dependency:
|
|
68 using classes from a module which is not installed at the time
|
|
69 this module is imported.
|
|
70
|
|
71 Call this function after ensuring `base_class` is available,
|
|
72 before using the class named by `class_name`.
|
|
73
|
|
74 """
|
|
75 existing_class = namespace[class_name]
|
|
76 assert isinstance(existing_class, type)
|
|
77
|
|
78 bases = list(existing_class.__bases__)
|
|
79 if base_class is bases[0]:
|
|
80 # Already bound to a type with the right bases.
|
|
81 return
|
|
82 bases.insert(0, base_class)
|
|
83
|
|
84 new_class_namespace = existing_class.__dict__.copy()
|
|
85 # Type creation will assign the correct ‘__dict__’ attribute.
|
|
86 del new_class_namespace['__dict__']
|
|
87
|
|
88 metaclass = existing_class.__metaclass__
|
|
89 new_class = metaclass(class_name, tuple(bases), new_class_namespace)
|
|
90
|
|
91 namespace[class_name] = new_class
|
|
92
|
|
93
|
|
94 class VersionInfoWriter(object):
|
|
95 """ Docutils writer to produce a version info JSON data stream. """
|
|
96
|
|
97 # This class needs its base class to be a class from `docutils`.
|
|
98 # But that would create a circular dependency: Setuptools cannot
|
|
99 # ensure `docutils` is available before importing this module.
|
|
100 #
|
|
101 # Use `ensure_class_bases_begin_with` after importing `docutils`, to
|
|
102 # re-bind the `VersionInfoWriter` name to a new type that inherits
|
|
103 # from `docutils.writers.Writer`.
|
|
104
|
|
105 __metaclass__ = type
|
|
106
|
|
107 supported = ['version_info']
|
|
108 """ Formats this writer supports. """
|
|
109
|
|
110 def __init__(self):
|
|
111 super(VersionInfoWriter, self).__init__()
|
|
112 self.translator_class = VersionInfoTranslator
|
|
113
|
|
114 def translate(self):
|
|
115 visitor = self.translator_class(self.document)
|
|
116 self.document.walkabout(visitor)
|
|
117 self.output = visitor.astext()
|
|
118
|
|
119
|
|
120 rfc822_person_regex = re.compile(
|
|
121 "^(?P<name>[^<]+) <(?P<email>[^>]+)>$")
|
|
122
|
|
123 class ChangeLogEntry:
|
|
124 """ An individual entry from the ‘ChangeLog’ document. """
|
|
125
|
|
126 __metaclass__ = type
|
|
127
|
|
128 field_names = [
|
|
129 'release_date',
|
|
130 'version',
|
|
131 'maintainer',
|
|
132 'body',
|
|
133 ]
|
|
134
|
|
135 date_format = "%Y-%m-%d"
|
|
136 default_version = "UNKNOWN"
|
|
137 default_release_date = "UNKNOWN"
|
|
138
|
|
139 def __init__(
|
|
140 self,
|
|
141 release_date=default_release_date, version=default_version,
|
|
142 maintainer=None, body=None):
|
|
143 self.validate_release_date(release_date)
|
|
144 self.release_date = release_date
|
|
145
|
|
146 self.version = version
|
|
147
|
|
148 self.validate_maintainer(maintainer)
|
|
149 self.maintainer = maintainer
|
|
150 self.body = body
|
|
151
|
|
152 @classmethod
|
|
153 def validate_release_date(cls, value):
|
|
154 """ Validate the `release_date` value.
|
|
155
|
|
156 :param value: The prospective `release_date` value.
|
|
157 :return: ``None`` if the value is valid.
|
|
158 :raises ValueError: If the value is invalid.
|
|
159
|
|
160 """
|
|
161 if value in ["UNKNOWN", "FUTURE"]:
|
|
162 # A valid non-date value.
|
|
163 return None
|
|
164
|
|
165 # Raises `ValueError` if parse fails.
|
|
166 datetime.datetime.strptime(value, ChangeLogEntry.date_format)
|
|
167
|
|
168 @classmethod
|
|
169 def validate_maintainer(cls, value):
|
|
170 """ Validate the `maintainer` value.
|
|
171
|
|
172 :param value: The prospective `maintainer` value.
|
|
173 :return: ``None`` if the value is valid.
|
|
174 :raises ValueError: If the value is invalid.
|
|
175
|
|
176 """
|
|
177 valid = False
|
|
178
|
|
179 if value is None:
|
|
180 valid = True
|
|
181 elif rfc822_person_regex.search(value):
|
|
182 valid = True
|
|
183
|
|
184 if not valid:
|
|
185 raise ValueError("Not a valid person specification {value!r}")
|
|
186 else:
|
|
187 return None
|
|
188
|
|
189 @classmethod
|
|
190 def make_ordered_dict(cls, fields):
|
|
191 """ Make an ordered dict of the fields. """
|
|
192 result = collections.OrderedDict(
|
|
193 (name, fields[name])
|
|
194 for name in cls.field_names)
|
|
195 return result
|
|
196
|
|
197 def as_version_info_entry(self):
|
|
198 """ Format the changelog entry as a version info entry. """
|
|
199 fields = vars(self)
|
|
200 entry = self.make_ordered_dict(fields)
|
|
201
|
|
202 return entry
|
|
203
|
|
204
|
|
205 class InvalidFormatError(ValueError):
|
|
206 """ Raised when the document is not a valid ‘ChangeLog’ document. """
|
|
207
|
|
208
|
|
209 class VersionInfoTranslator(object):
|
|
210 """ Translator from document nodes to a version info stream. """
|
|
211
|
|
212 # This class needs its base class to be a class from `docutils`.
|
|
213 # But that would create a circular dependency: Setuptools cannot
|
|
214 # ensure `docutils` is available before importing this module.
|
|
215 #
|
|
216 # Use `ensure_class_bases_begin_with` after importing `docutils`,
|
|
217 # to re-bind the `VersionInfoTranslator` name to a new type that
|
|
218 # inherits from `docutils.nodes.SparseNodeVisitor`.
|
|
219
|
|
220 __metaclass__ = type
|
|
221
|
|
222 wrap_width = 78
|
|
223 bullet_text = "* "
|
|
224
|
|
225 attr_convert_funcs_by_attr_name = {
|
|
226 'released': ('release_date', unicode),
|
|
227 'version': ('version', unicode),
|
|
228 'maintainer': ('maintainer', unicode),
|
|
229 }
|
|
230
|
|
231 def __init__(self, document):
|
|
232 super(VersionInfoTranslator, self).__init__(document)
|
|
233 self.settings = document.settings
|
|
234 self.current_section_level = 0
|
|
235 self.current_field_name = None
|
|
236 self.content = []
|
|
237 self.indent_width = 0
|
|
238 self.initial_indent = ""
|
|
239 self.subsequent_indent = ""
|
|
240 self.current_entry = None
|
|
241
|
|
242 # Docutils is not available when this class is defined.
|
|
243 # Get the `docutils` module dynamically.
|
|
244 self._docutils = sys.modules['docutils']
|
|
245
|
|
246 def astext(self):
|
|
247 """ Return the translated document as text. """
|
|
248 text = json.dumps(self.content, indent=4)
|
|
249 return text
|
|
250
|
|
251 def append_to_current_entry(self, text):
|
|
252 if self.current_entry is not None:
|
|
253 if self.current_entry.body is not None:
|
|
254 self.current_entry.body += text
|
|
255
|
|
256 def visit_Text(self, node):
|
|
257 raw_text = node.astext()
|
|
258 text = textwrap.fill(
|
|
259 raw_text,
|
|
260 width=self.wrap_width,
|
|
261 initial_indent=self.initial_indent,
|
|
262 subsequent_indent=self.subsequent_indent)
|
|
263 self.append_to_current_entry(text)
|
|
264
|
|
265 def depart_Text(self, node):
|
|
266 pass
|
|
267
|
|
268 def visit_comment(self, node):
|
|
269 raise self._docutils.nodes.SkipNode
|
|
270
|
|
271 def visit_field_body(self, node):
|
|
272 field_list_node = node.parent.parent
|
|
273 if not isinstance(field_list_node, self._docutils.nodes.field_list):
|
|
274 raise InvalidFormatError(
|
|
275 "Unexpected field within {node!r}".format(
|
|
276 node=field_list_node))
|
|
277 (attr_name, convert_func) = self.attr_convert_funcs_by_attr_name[
|
|
278 self.current_field_name]
|
|
279 attr_value = convert_func(node.astext())
|
|
280 setattr(self.current_entry, attr_name, attr_value)
|
|
281
|
|
282 def depart_field_body(self, node):
|
|
283 pass
|
|
284
|
|
285 def visit_field_list(self, node):
|
|
286 pass
|
|
287
|
|
288 def depart_field_list(self, node):
|
|
289 self.current_field_name = None
|
|
290 self.current_entry.body = ""
|
|
291
|
|
292 def visit_field_name(self, node):
|
|
293 field_name = node.astext()
|
|
294 if self.current_section_level == 1:
|
|
295 # At a top-level section.
|
|
296 if field_name.lower() not in ["released", "maintainer"]:
|
|
297 raise InvalidFormatError(
|
|
298 "Unexpected field name {name!r}".format(name=field_name))
|
|
299 self.current_field_name = field_name.lower()
|
|
300
|
|
301 def depart_field_name(self, node):
|
|
302 pass
|
|
303
|
|
304 def visit_bullet_list(self, node):
|
|
305 self.current_context = []
|
|
306
|
|
307 def depart_bullet_list(self, node):
|
|
308 self.current_entry.changes = self.current_context
|
|
309 self.current_context = None
|
|
310
|
|
311 def adjust_indent_width(self, delta):
|
|
312 self.indent_width += delta
|
|
313 self.subsequent_indent = " " * self.indent_width
|
|
314 self.initial_indent = self.subsequent_indent
|
|
315
|
|
316 def visit_list_item(self, node):
|
|
317 indent_delta = +len(self.bullet_text)
|
|
318 self.adjust_indent_width(indent_delta)
|
|
319 self.initial_indent = self.subsequent_indent[:-indent_delta]
|
|
320 self.append_to_current_entry(self.initial_indent + self.bullet_text)
|
|
321
|
|
322 def depart_list_item(self, node):
|
|
323 indent_delta = +len(self.bullet_text)
|
|
324 self.adjust_indent_width(-indent_delta)
|
|
325 self.append_to_current_entry("\n")
|
|
326
|
|
327 def visit_section(self, node):
|
|
328 self.current_section_level += 1
|
|
329 if self.current_section_level == 1:
|
|
330 # At a top-level section.
|
|
331 self.current_entry = ChangeLogEntry()
|
|
332 else:
|
|
333 raise InvalidFormatError(
|
|
334 "Subsections not implemented for this writer")
|
|
335
|
|
336 def depart_section(self, node):
|
|
337 self.current_section_level -= 1
|
|
338 self.content.append(
|
|
339 self.current_entry.as_version_info_entry())
|
|
340 self.current_entry = None
|
|
341
|
|
342 _expected_title_word_length = len("Version FOO".split(" "))
|
|
343
|
|
344 def depart_title(self, node):
|
|
345 title_text = node.astext()
|
|
346 # At a top-level section.
|
|
347 words = title_text.split(" ")
|
|
348 version = None
|
|
349 if len(words) != self._expected_title_word_length:
|
|
350 raise InvalidFormatError(
|
|
351 "Unexpected title text {text!r}".format(text=title_text))
|
|
352 if words[0].lower() not in ["version"]:
|
|
353 raise InvalidFormatError(
|
|
354 "Unexpected title text {text!r}".format(text=title_text))
|
|
355 version = words[-1]
|
|
356 self.current_entry.version = version
|
|
357
|
|
358
|
|
359 def changelog_to_version_info_collection(infile):
|
|
360 """ Render the ‘ChangeLog’ document to a version info collection.
|
|
361
|
|
362 :param infile: A file-like object containing the changelog.
|
|
363 :return: The serialised JSON data of the version info collection.
|
|
364
|
|
365 """
|
|
366
|
|
367 # Docutils is not available when Setuptools needs this module, so
|
|
368 # delay the imports to this function instead.
|
|
369 import docutils.core
|
|
370 import docutils.nodes
|
|
371 import docutils.writers
|
|
372
|
|
373 ensure_class_bases_begin_with(
|
|
374 globals(), str('VersionInfoWriter'), docutils.writers.Writer)
|
|
375 ensure_class_bases_begin_with(
|
|
376 globals(), str('VersionInfoTranslator'),
|
|
377 docutils.nodes.SparseNodeVisitor)
|
|
378
|
|
379 writer = VersionInfoWriter()
|
|
380 settings_overrides = {
|
|
381 'doctitle_xform': False,
|
|
382 }
|
|
383 version_info_json = docutils.core.publish_string(
|
|
384 infile.read(), writer=writer,
|
|
385 settings_overrides=settings_overrides)
|
|
386
|
|
387 return version_info_json
|
|
388
|
|
389
|
|
390 try:
|
|
391 lru_cache = functools.lru_cache
|
|
392 except AttributeError:
|
|
393 # Python < 3.2 does not have the `functools.lru_cache` function.
|
|
394 # Not essential, so replace it with a no-op.
|
|
395 lru_cache = lambda maxsize=None, typed=False: lambda func: func
|
|
396
|
|
397
|
|
398 @lru_cache(maxsize=128)
|
|
399 def generate_version_info_from_changelog(infile_path):
|
|
400 """ Get the version info for the latest version in the changelog.
|
|
401
|
|
402 :param infile_path: Filesystem path to the input changelog file.
|
|
403 :return: The generated version info mapping; or ``None`` if the
|
|
404 file cannot be read.
|
|
405
|
|
406 The document is explicitly opened as UTF-8 encoded text.
|
|
407
|
|
408 """
|
|
409 version_info = collections.OrderedDict()
|
|
410
|
|
411 versions_all_json = None
|
|
412 try:
|
|
413 with io.open(infile_path, 'rt', encoding="utf-8") as infile:
|
|
414 versions_all_json = changelog_to_version_info_collection(infile)
|
|
415 except EnvironmentError:
|
|
416 # If we can't read the input file, leave the collection empty.
|
|
417 pass
|
|
418
|
|
419 if versions_all_json is not None:
|
|
420 versions_all = json.loads(versions_all_json.decode('utf-8'))
|
|
421 version_info = get_latest_version(versions_all)
|
|
422
|
|
423 return version_info
|
|
424
|
|
425
|
|
426 def get_latest_version(versions):
|
|
427 """ Get the latest version from a collection of changelog entries.
|
|
428
|
|
429 :param versions: A collection of mappings for changelog entries.
|
|
430 :return: An ordered mapping of fields for the latest version,
|
|
431 if `versions` is non-empty; otherwise, an empty mapping.
|
|
432
|
|
433 """
|
|
434 version_info = collections.OrderedDict()
|
|
435
|
|
436 versions_by_release_date = {
|
|
437 item['release_date']: item
|
|
438 for item in versions}
|
|
439 if versions_by_release_date:
|
|
440 latest_release_date = max(versions_by_release_date.keys())
|
|
441 version_info = ChangeLogEntry.make_ordered_dict(
|
|
442 versions_by_release_date[latest_release_date])
|
|
443
|
|
444 return version_info
|
|
445
|
|
446
|
|
447 def serialise_version_info_from_mapping(version_info):
|
|
448 """ Generate the version info serialised data.
|
|
449
|
|
450 :param version_info: Mapping of version info items.
|
|
451 :return: The version info serialised to JSON.
|
|
452
|
|
453 """
|
|
454 content = json.dumps(version_info, indent=4)
|
|
455
|
|
456 return content
|
|
457
|
|
458
|
|
459 changelog_filename = "ChangeLog"
|
|
460
|
|
461 def get_changelog_path(distribution, filename=changelog_filename):
|
|
462 """ Get the changelog file path for the distribution.
|
|
463
|
|
464 :param distribution: The distutils.dist.Distribution instance.
|
|
465 :param filename: The base filename of the changelog document.
|
|
466 :return: Filesystem path of the changelog document, or ``None``
|
|
467 if not discoverable.
|
|
468
|
|
469 """
|
|
470 setup_dirname = os.path.dirname(distribution.script_name)
|
|
471 filepath = os.path.join(setup_dirname, filename)
|
|
472
|
|
473 return filepath
|
|
474
|
|
475
|
|
476 def has_changelog(command):
|
|
477 """ Return ``True`` iff the distribution's changelog file exists. """
|
|
478 result = False
|
|
479
|
|
480 changelog_path = get_changelog_path(command.distribution)
|
|
481 if changelog_path is not None:
|
|
482 if os.path.exists(changelog_path):
|
|
483 result = True
|
|
484
|
|
485 return result
|
|
486
|
|
487
|
|
488 class EggInfoCommand(setuptools.command.egg_info.egg_info, object):
|
|
489 """ Custom ‘egg_info’ command for this distribution. """
|
|
490
|
|
491 sub_commands = ([
|
|
492 ('write_version_info', has_changelog),
|
|
493 ] + setuptools.command.egg_info.egg_info.sub_commands)
|
|
494
|
|
495 def run(self):
|
|
496 """ Execute this command. """
|
|
497 super(EggInfoCommand, self).run()
|
|
498
|
|
499 for command_name in self.get_sub_commands():
|
|
500 self.run_command(command_name)
|
|
501
|
|
502
|
|
503 version_info_filename = "version_info.json"
|
|
504
|
|
505 class WriteVersionInfoCommand(EggInfoCommand, object):
|
|
506 """ Setuptools command to serialise version info metadata. """
|
|
507
|
|
508 user_options = ([
|
|
509 ("changelog-path=", None,
|
|
510 "Filesystem path to the changelog document."),
|
|
511 ("outfile-path=", None,
|
|
512 "Filesystem path to the version info file."),
|
|
513 ] + EggInfoCommand.user_options)
|
|
514
|
|
515 def initialize_options(self):
|
|
516 """ Initialise command options to defaults. """
|
|
517 super(WriteVersionInfoCommand, self).initialize_options()
|
|
518 self.changelog_path = None
|
|
519 self.outfile_path = None
|
|
520
|
|
521 def finalize_options(self):
|
|
522 """ Finalise command options before execution. """
|
|
523 self.set_undefined_options(
|
|
524 'build',
|
|
525 ('force', 'force'))
|
|
526
|
|
527 super(WriteVersionInfoCommand, self).finalize_options()
|
|
528
|
|
529 if self.changelog_path is None:
|
|
530 self.changelog_path = get_changelog_path(self.distribution)
|
|
531
|
|
532 if self.outfile_path is None:
|
|
533 egg_dir = self.egg_info
|
|
534 self.outfile_path = os.path.join(egg_dir, version_info_filename)
|
|
535
|
|
536 def run(self):
|
|
537 """ Execute this command. """
|
|
538 version_info = generate_version_info_from_changelog(self.changelog_path)
|
|
539 content = serialise_version_info_from_mapping(version_info)
|
|
540 self.write_file("version info", self.outfile_path, content)
|
|
541
|
|
542
|
|
543 # Local variables:
|
|
544 # coding: utf-8
|
|
545 # mode: python
|
|
546 # End:
|
|
547 # vim: fileencoding=utf-8 filetype=python :
|