make-release.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. #!/usr/bin/env python
  2. import os
  3. import re
  4. import subprocess
  5. import sys
  6. DESCRIPTION='''Makes a full release.
  7. This script will update the version number of the package and perform all steps
  8. necessary to make a full release.
  9. '''
  10. ROOT = os.path.join(
  11. os.path.dirname(__file__),
  12. os.pardir)
  13. LIB_DIR = os.path.join(ROOT, 'lib')
  14. PACKAGE_NAME = next(
  15. name
  16. for name in os.listdir(LIB_DIR)
  17. if name[0] != '_')
  18. PACKAGE_DIR = os.path.join(LIB_DIR, PACKAGE_NAME)
  19. def main(version):
  20. assert_current_branch_is_clean()
  21. update_info(version)
  22. check_readme()
  23. check_release_notes(version)
  24. commit_changes(version)
  25. try:
  26. tag_release(version)
  27. except:
  28. commit_changes.undo()
  29. raise
  30. push_to_origin()
  31. upload_to_pypi()
  32. def assert_current_branch_is_clean():
  33. """Asserts that the current branch contains no local changes.
  34. :raises RuntimeError: if the repository contains local changes
  35. """
  36. try:
  37. git('diff-index', '--quiet', 'HEAD', '--')
  38. except RuntimeError as e:
  39. print(e.args[0] % e.args[1:])
  40. raise RuntimeError('Your repository contains local changes')
  41. def update_info(version):
  42. """Updates the version information in ``._info.``
  43. :param tuple version: The version to set.
  44. """
  45. gsub(
  46. os.path.join(PACKAGE_DIR, '_info.py'),
  47. re.compile(r'__version__\s*=\s*(\([0-9]+(\s*,\s*[0-9]+)*\))'),
  48. 1,
  49. repr(version))
  50. def check_readme():
  51. """Verifies that the ``README`` is *reStructuredText* compliant.
  52. """
  53. python('setup.py', 'check', '--restructuredtext', '--strict')
  54. def check_release_notes(version):
  55. """Displays the release notes and allows the user to cancel the release
  56. process.
  57. :param tuple version: The version that is being released.
  58. """
  59. CHANGES = os.path.join(ROOT, 'CHANGES.rst')
  60. header = 'v%s' % '.'.join(str(v) for v in version)
  61. # Read the release notes
  62. found = False
  63. release_notes = []
  64. with open(CHANGES) as f:
  65. for line in (l.strip() for l in f):
  66. if found:
  67. if not line:
  68. # Break on the first empty line after release notes
  69. break
  70. elif set(line) == {'-'}:
  71. # Ignore underline lines
  72. continue
  73. release_notes.append(line)
  74. elif line.startswith(header):
  75. # The release notes begin after the header
  76. found = True
  77. while True:
  78. # Display the release notes
  79. sys.stdout.write('Release notes for %s:\n' % header)
  80. sys.stdout.write(
  81. '\n'.join(
  82. ' %s' % release_note
  83. for release_note in release_notes) + '\n')
  84. sys.stdout.write('Is this correct [yes/no]? ')
  85. sys.stdout.flush()
  86. response = sys.stdin.readline().strip()
  87. if response in ('yes', 'y'):
  88. break
  89. elif response in ('no', 'n'):
  90. raise RuntimeError('Release notes are not up to date')
  91. def commit_changes(version):
  92. """Commits all local changes.
  93. :param tuple version: The version that is being released.
  94. """
  95. git('commit',
  96. '-a',
  97. '-m', 'Release %s' % '.'.join(str(v) for v in version))
  98. def _commit_changes_undo():
  99. git('reset',
  100. '--hard',
  101. 'HEAD^')
  102. commit_changes.undo = _commit_changes_undo
  103. def tag_release(version):
  104. """Tags the current commit as a release.
  105. :param version: The version that is being released.
  106. :type version: tuple of version parts
  107. """
  108. git('tag',
  109. '-a',
  110. '-m', 'Release %s' % '.'.join(str(v) for v in version),
  111. 'v' + '.'.join(str(v) for v in version))
  112. def push_to_origin():
  113. """Pushes master to origin.
  114. """
  115. print('Pushing to origin...')
  116. git('push', 'origin', 'HEAD:master')
  117. git('push', '--tags')
  118. def upload_to_pypi():
  119. """Uploads this project to PyPi.
  120. """
  121. print('Uploading to PyPi...')
  122. python(
  123. os.path.join(ROOT, 'setup.py'),
  124. 'sdist',
  125. 'bdist_egg',
  126. 'bdist_wheel',
  127. 'upload')
  128. def git(*args):
  129. """Executes ``git`` with the command line arguments given.
  130. :param args: The arguments to ``git``.
  131. :return: stdout of ``git``
  132. :raises RuntimeError: if ``git`` returns non-zero
  133. """
  134. return command('git', *args)
  135. def python(*args):
  136. """Executes *Python* with the command line arguments given.
  137. The *Python* used is the one executing the current script.
  138. :param args: The arguments to *Python*.
  139. :return: stdout of *Python*
  140. :raises RuntimeError: if *Python* returns non-zero
  141. """
  142. return command(sys.executable, *args)
  143. def gsub(path, regex, group, replacement):
  144. """Runs a regular expression on the contents of a file and replaces a
  145. group.
  146. :param str path: The path to the file.
  147. :param regex: The regular expression to use.
  148. :param int group: The group of the regular expression to replace.
  149. :param str replacement: The replacement string.
  150. """
  151. with open(path) as f:
  152. data = f.read()
  153. def sub(match):
  154. full = match.group(0)
  155. o = match.start(0)
  156. return full[:match.start(group) - o] \
  157. + replacement \
  158. + full[match.end(group) - o:]
  159. with open(path, 'w') as f:
  160. f.write(regex.sub(sub, data))
  161. def command(*args):
  162. """Executes a command.
  163. :param args: The command and arguments.
  164. :return: stdout of the command
  165. :raises RuntimeError: if the command returns non-zero
  166. """
  167. g = subprocess.Popen(
  168. args,
  169. stdout=subprocess.PIPE,
  170. stderr=subprocess.PIPE)
  171. stdout, stderr = g.communicate()
  172. if g.returncode != 0:
  173. raise RuntimeError(
  174. 'Failed to execute <%s> (%d): %s',
  175. ' '.join(args),
  176. g.returncode, stderr)
  177. else:
  178. return stdout.decode('utf-8')
  179. if __name__ == '__main__':
  180. import argparse
  181. parser = argparse.ArgumentParser(description=DESCRIPTION)
  182. parser.add_argument(
  183. 'version',
  184. type=lambda s: tuple(int(v) for v in s.split('.')))
  185. try:
  186. main(**vars(parser.parse_args()))
  187. except Exception as e:
  188. try:
  189. sys.stderr.write(e.args[0] % e.args[1:] + '\n')
  190. except:
  191. sys.stderr.write('%s\n' % str(e))
  192. sys.exit(1)