Add pypi_release.py.
I assume no responsibility for damages!
This commit is contained in:
parent
4717ccf84e
commit
4c497096fc
1 changed files with 420 additions and 0 deletions
420
pypi_release.py
Normal file
420
pypi_release.py
Normal file
|
@ -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:]))
|
Loading…
Reference in a new issue