cmd/pypi_release.py

434 lines
12 KiB
Python

'''
pypi_release
============
This script helps me release voussoirkit on pypi.
--major,
--minor,
--patch:
Pass only one of these. The version number of your package will increase by
1 in either the major, minor, or patch.
--do-tag:
If this argument is passed, a git tag will be added to the release commit.
'''
import argparse
import re
import shutil
import subprocess
import sys
import textwrap
import time
from voussoirkit import interactive
from voussoirkit import passwordy
from voussoirkit import vlogging
from voussoirkit import winwhich
log = vlogging.getLogger(__name__, 'pypi_release')
GIT = winwhich.which('git')
PY = winwhich.which('py')
TWINE = winwhich.which('twine')
BUMP_PATTERN = r'Bump to version (\d+\.\d+\.\d+)\.'
class PypiReleaseError(Exception):
pass
class BadSetup(PypiReleaseError):
pass
class DirtyState(PypiReleaseError):
pass
class NotSemver(PypiReleaseError):
pass
class VersionOutOfOrder(PypiReleaseError):
pass
# HELPER FUNCTIONS
################################################################################
def bump_version(version, versionbump):
(major, minor, patch) = [int(x) for x in version.split('.')]
if versionbump == 'major':
major += 1
minor = 0
patch = 0
elif versionbump == 'minor':
minor += 1
patch = 0
elif versionbump == 'patch':
patch += 1
else:
raise ValueError(f'versionbump should be major, minor, or patch, not {versionbump}.')
version = f'{major}.{minor}.{patch}'
return version
def check_call(command):
log.debug(format_command(command))
return subprocess.check_call(command)
def check_output(command):
log.debug(format_command(command))
return subprocess.check_output(command, stderr=subprocess.STDOUT)
def pick_versionbump(major, minor, patch):
mmp = (major, minor, patch)
if not all(b in [True, False, None] for b in mmp):
raise TypeError('major, minor, patch should all be True, False, or None.')
if mmp in [(False, False, False), (None, None, None)]:
versionbump = 'patch'
elif mmp.count(True) > 1:
raise TypeError('Must only pick one of major, minor, patch.')
elif major:
versionbump = 'major'
elif minor:
versionbump = 'minor'
elif patch:
versionbump = 'patch'
else:
raise TypeError()
return versionbump
def format_command(command):
cmd = [('"%s"' % x) if (' ' in x or x == '') else x for x in command]
cmd = ' '.join(cmd)
cmd = cmd.strip()
cmd = cmd.replace(GIT, 'git')
cmd = cmd.replace(PY, 'py')
cmd = cmd.replace(TWINE, 'twine')
print(f'> {cmd}')
def semver_split(semver):
original = semver
try:
semver = semver.strip('v')
mmp = semver.split('.')
mmp = tuple(int(x) for x in mmp)
(major, minor, patch) = mmp
return mmp
except Exception:
raise NotSemver(original)
def slowprint(s=None):
slow = 0.05
if s is None:
print()
time.sleep(slow)
return
for line in s.split('\n'):
print(line)
time.sleep(slow)
# SETUP.PY
################################################################################
def extract_info_from_setup():
handle = open('setup.py', 'r', encoding='utf-8')
with handle:
setup_py = handle.read()
name = re.findall(r'''\bname=["']([A-Za-z0-9_-]+)["']''', setup_py)
if len(name) != 1:
raise BadSetup(f'Expected to find 1 name but found {len(name)}.')
name = name[0]
version = re.findall(r'''\bversion=["'](\d+\.\d+\.\d+)["']''', setup_py)
if len(version) != 1:
raise BadSetup(f'Expected to find 1 version but found {len(version)}.')
version = version[0]
return (setup_py, name, version)
def update_info_in_setup(setup_py, name, version):
re_from = fr'''\bname=(["'])({name})(["'])'''
re_to = fr'''name=\1\2\3'''
setup_py = re.sub(re_from, re_to, setup_py)
re_from = fr'''\bversion=(["'])(\d+\.\d+\.\d+)(["'])'''
re_to = fr'''version=\g<1>{version}\g<3>'''
setup_py = re.sub(re_from, re_to, setup_py)
return setup_py
def write_setup(setup_py):
handle = open('setup.py', 'w', encoding='utf-8')
with handle:
handle.write(setup_py)
# GIT
################################################################################
def git_assert_current_greater_than_latest(latest_release_version, new_version):
if latest_release_version >= semver_split(new_version):
msg = f'New version should be {new_version} but {latest_release_version} already exists.'
raise VersionOutOfOrder(msg)
def git_assert_no_stashes():
command = [GIT, 'stash', 'list']
output = check_output(command)
lines = output.strip().splitlines()
if len(lines) != 0:
raise DirtyState('Please ensure there are no stashes.')
def git_assert_pushable():
command = [GIT, 'fetch', '--all']
check_call(command)
command = [GIT, 'merge-base', '@', '@{u}']
merge_base = check_output(command).strip()
command = [GIT, 'rev-parse', '@']
my_head = check_output(command).strip()
command = [GIT, 'rev-parse', '@{u}']
remote_head = check_output(command).strip()
if my_head == remote_head:
pass
elif my_head == merge_base:
git_commits_since(merge_base.decode(), show=True, inclusive=True)
raise DirtyState('Cant push, need to pull first.')
elif remote_head == merge_base:
pass
else:
git_commits_since(merge_base.decode(), show=True, inclusive=True)
raise DirtyState('Cant push, diverged from remote.')
def git_commit_bump(version):
command = [GIT, 'add', 'setup.py']
check_call(command)
command = [GIT, 'commit', '-m', f'Bump to version {version}.']
check_call(command)
def git_commits_since(commit, show, inclusive=False):
if commit:
if inclusive:
commit += '~1'
command = [GIT, '--no-pager', 'log', '--oneline', '--graph', '--branches', '--remotes', f'{commit}..']
else:
command = [GIT, '--no-pager', 'log', '--oneline', '--graph']
if show:
check_call(command)
else:
output = check_output(command)
lines = output.strip().splitlines()
return lines
def git_current_remote_branch():
command = [GIT, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']
output = check_output(command)
output = output.strip().decode()
(remote, branch) = output.split('/')
return (remote, branch)
def git_determine_latest_release():
(latest_tagged_commit, latest_tag_version) = git_latest_tagged_commit()
(latest_bump_commit, latest_bump_version) = git_latest_bump_commit()
if latest_tagged_commit is None:
latest_release_commit = latest_bump_commit
latest_release_version = latest_bump_version
elif latest_bump_commit is None:
latest_release_commit = latest_tagged_commit
latest_release_version = latest_tag_version
elif latest_tag_version > latest_bump_version:
latest_release_commit = latest_tagged_commit
latest_release_version = latest_tag_version
else:
latest_release_commit = latest_bump_commit
latest_release_version = latest_bump_version
return (latest_release_commit, latest_release_version)
def git_latest_bump_commit():
command = [GIT, 'log', '--oneline', '--no-abbrev-commit', '--grep', 'Bump to version *.', '-1']
output = check_output(command)
output = output.strip()
if not output:
return (None, None)
commit = output.splitlines()[0].decode()
version = re.search(BUMP_PATTERN, commit).group(1)
version = semver_split(version)
commit = commit.split()[0]
return (commit, version)
def git_latest_tagged_commit():
command = [GIT, 'log', '--oneline', '--no-abbrev-commit', '--tags=v*.*.*', '-1']
output = check_output(command)
output = output.strip()
if not output:
return (None, None)
commit = output.splitlines()[0].decode().split()[0]
for tag in git_tags_on_commit(commit):
try:
version = semver_split(tag)
break
except NotSemver:
pass
else:
return (None, None)
return (commit, version)
def git_push(remote, branch):
command = [GIT, 'push', remote, branch]
check_call(command)
def git_push_tag(remote, tag):
command = [GIT, 'push', remote, tag]
check_call(command)
def git_show_commit(commit):
command = [GIT, 'show', '--oneline', '-s', commit]
check_call(command)
def git_stash_push():
token = passwordy.urandom_hex(32)
command = [GIT, 'stash', 'push', '--include-untracked', '--message', token]
output = check_output(command)
command = [GIT, 'stash', 'list', '--grep', token]
output = check_output(command).strip()
did_stash = bool(output)
return did_stash
def git_stash_restore():
command = [GIT, 'stash', 'pop']
check_call(command)
def git_tag_version(tag):
command = [GIT, 'tag', '-a', tag, '-m', '']
check_call(command)
def git_tags_on_commit(commit):
command = [GIT, 'tag', '--points-at', commit]
output = check_output(command)
output = output.strip()
if not output:
return []
output = output.decode().splitlines()
return output
# PYPI
################################################################################
def pypi_upload(name):
egg_dir = f'{name}.egg-info'
command = [PY, 'setup.py', 'sdist']
check_call(command)
command = [TWINE, 'upload', '-r', 'pypi', 'dist\\*']
check_call(command)
shutil.rmtree('dist')
shutil.rmtree(egg_dir)
def pypi_release(do_tag=False, versionbump='patch'):
git_assert_no_stashes()
git_assert_pushable()
(setup_py, name, old_version) = extract_info_from_setup()
new_version = bump_version(old_version, versionbump)
new_tag = f'v{new_version}'
(latest_release_commit, latest_release_version) = git_determine_latest_release()
git_assert_current_greater_than_latest(latest_release_version, new_version)
commits_since_last_release = git_commits_since(latest_release_commit, show=False)
if len(commits_since_last_release) == 0:
print('No new commits to release')
return
(remote, branch) = git_current_remote_branch()
def linebreak():
cli_width = shutil.get_terminal_size()[0]
line = '#' * (cli_width - 1)
line += '\r'
line += f'# {name} {new_version} '
line = f'\n{line}\n'
slowprint(line)
linebreak()
slowprint(f'Upgrading {name} from {old_version} --> {new_version}.')
slowprint()
if latest_release_commit:
slowprint('Latest release:')
git_show_commit(latest_release_commit)
slowprint()
slowprint('This release:')
git_commits_since(latest_release_commit, show=True)
slowprint()
setup_py = update_info_in_setup(setup_py, name, new_version)
slowprint('Rewrite setup.py:')
slowprint(textwrap.indent(setup_py, '>>> '))
slowprint()
slowprint(f'Release will be pushed to {remote} {branch}.')
linebreak()
if not interactive.getpermission(f'READY TO RELEASE {name} {new_version}.'):
return
write_setup(setup_py)
git_commit_bump(new_version)
if do_tag:
git_tag_version(new_tag)
git_push(remote, branch)
if do_tag:
git_push_tag(remote, new_tag)
did_stash = git_stash_push()
pypi_upload(name)
if did_stash:
git_stash_restore()
git_commits_since(latest_release_commit, show=True)
def pypi_release_argparse(args):
versionbump = pick_versionbump(args.major, args.minor, args.patch)
try:
return pypi_release(do_tag=args.do_tag, versionbump=versionbump)
except subprocess.CalledProcessError as exc:
print(exc.output)
return 1
@vlogging.main_decorator
def main(argv):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--major', action='store_true')
parser.add_argument('--minor', action='store_true')
parser.add_argument('--patch', action='store_true')
parser.add_argument('--do_tag', '--do-tag', action='store_true')
parser.set_defaults(func=pypi_release_argparse)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == '__main__':
raise SystemExit(main(sys.argv[1:]))