diff --git a/pypi_release.py b/pypi_release.py new file mode 100644 index 0000000..4de69c0 --- /dev/null +++ b/pypi_release.py @@ -0,0 +1,420 @@ +import argparse +import re +import shutil +import subprocess +import sys +import textwrap +import time + +from voussoirkit import passwordy +from voussoirkit import winwhich + +GIT = winwhich.which('git') +PY = winwhich.which('py') +TWINE = winwhich.which('twine') + +BUMP_PATTERN = r'Bump to version (\d+\.\d+\.\d+)\.' + +DEBUG = False + +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, show_command=True): + if DEBUG or show_command: + print_command(command) + return subprocess.check_call(command) + +def check_output(command, show_command=True): + if DEBUG or show_command: + print_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 print_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): + raise VersionOutOfOrder(f'New version should be {new_version} but {latest_release_version} already exists.') + +def git_assert_no_stashes(): + command = [GIT, 'stash', 'list'] + output = check_output(command, show_command=False) + 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_output(command, show_command=False) + + command = [GIT, 'merge-base', '@', '@{u}'] + merge_base = check_output(command, show_command=False).strip() + + command = [GIT, 'rev-parse', '@'] + my_head = check_output(command, show_command=False).strip() + + command = [GIT, 'rev-parse', '@{u}'] + remote_head = check_output(command, show_command=False).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_output(command) + + command = [GIT, 'commit', '-m', f'Bump to version {version}.'] + check_output(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, show_command=False) + else: + output = check_output(command, show_command=False) + 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, show_command=False) + 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, show_command=False) + 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, show_command=False) + 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_output(command) + +def git_push_tag(remote, tag): + command = [GIT, 'push', remote, tag] + check_output(command) + +def git_show_commit(commit): + command = [GIT, 'show', '--oneline', '-s', commit] + check_call(command, show_command=False) + +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'] + output = check_output(command) + +def git_tag_version(tag): + command = [GIT, 'tag', '-a', tag, '-m', ''] + check_output(command) + +def git_tags_on_commit(commit): + command = [GIT, 'tag', '--points-at', commit] + output = check_output(command, show_command=False) + 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_output(command) + + command = [TWINE, 'upload', '-r', 'pypi', 'dist\\*'] + check_output(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() + + input(f'PRESS ENTER TO RELEASE {name} {new_version}.') + + 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): + global DEBUG + if args.debug: + DEBUG = True + 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 + +def main(argv): + parser = argparse.ArgumentParser(description=__doc__) + + parser.add_argument('--major', dest='major', action='store_true') + parser.add_argument('--minor', dest='minor', action='store_true') + parser.add_argument('--patch', dest='patch', action='store_true') + parser.add_argument('--do_tag', dest='do_tag', action='store_true') + parser.add_argument('--debug', dest='debug', 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:]))