Version bumping in python

Hello Python Experts,

I have a set of packages that I am working on and I develop. Everytime I change one I would like to do something like:

bump_and_publish -n project_name

to get my project version in the setup.py to increase and publish to pypi. Also, in order to make sure that all my projects are up to date in pypi I would like to do something like:

bump_and_publish -f file_with_project_names.txt

to increase the version and publish anything that has not been published but has been changed. Is there a tool to do this? I want to ask before thinking of writing it myself.

Cheers.

There’s bump2version of course, just to handle the version strings.

To trigger a build and publish to PyPi, you could have a CI / CD pipeline that is dispatched on a version-tagged commit, from a particular branch. Or even from any commit.

2 Likes

Dear James,

Thank you for your answer. I just read the documentation and it seems kind of what I was thinking but I see that we need the --current-version argument which is not optional. This would work much better if we did not have to pass that, I mean, the tool could just go inside the setup.py and find out that value. I am not sure why that argument should be needed, it would just add more work to the user and the idea is precisely to have something automated to relief the user from these tasks.

Cheers.

bump2version [options] part [file]

It only requires a decision as to whether to bump the major, minor or patch version number.

1 Like

Dear James,

Thanks, but are you sure? I am seeing:

(rk) acampove@ubuntu:~/Packages/RK/rk_extractor$ bump2version minor setup.py
Evaluating 'parse' option: '(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)' does not parse current version '0.9'
usage: bumpversion [-h] [--config-file FILE] [--verbose] [--list] [--allow-dirty] [--parse REGEX] [--serialize FORMAT] [--search SEARCH]
                   [--replace REPLACE] [--current-version VERSION] [--no-configured-files] [--dry-run] --new-version VERSION
                   [--commit | --no-commit] [--tag | --no-tag] [--sign-tags | --no-sign-tags] [--tag-name TAG_NAME]
                   [--tag-message TAG_MESSAGE] [--message COMMIT_MSG] [--commit-args COMMIT_ARGS]
                   part [file ...]
bumpversion: error: the following arguments are required: --new-version

also, when using the config file I see in the first link you showed:

Or maybe I am using incorrectly the CLI tool?

BTW, this is how my setup function is called

setup(
        name              = 'rk_extractor',
        version           = '0.9.9',
        description       = 'Used to extract RK from simultaneous fits',
        scripts           = get_scripts('scripts/jobs') + get_scripts('scripts/offline'),
        long_description  = '', 
        packages          = get_packages(),
        package_dir       = {'' : 'src'},
        package_data      = {'extractor_data' : get_data_packages('extractor_data')}, 
        install_requires  = open('requirements.txt').read().splitlines()
        )   

also, by playing with the regex I see:

(rk) acampove@ubuntu:~/Packages/RK/rk_extractor$ bump2version --parse '(\d+)\.(\d+)' minor setup.py 
usage: bumpversion [-h] [--config-file FILE] [--verbose] [--list] [--allow-dirty] [--parse REGEX] [--serialize FORMAT] [--search SEARCH]
                   [--replace REPLACE] [--current-version VERSION] [--no-configured-files] [--dry-run] --new-version VERSION
                   [--commit | --no-commit] [--tag | --no-tag] [--sign-tags | --no-sign-tags] [--tag-name TAG_NAME]
                   [--tag-message TAG_MESSAGE] [--message COMMIT_MSG] [--commit-args COMMIT_ARGS]
                   part [file ...]
bumpversion: error: the following arguments are required: --new-version

Cheers.

At this point it’s just faster to try it yourself. But on the command line, anything in [..] is optional.

It may well also say that if you choose to use a config file, the config file requires a version. But it does say the config file is optional.

Another thing is that this project does not seem to be maintained anymore. Despite there is nothing saying that explicitly I do not see much activity in the issues:

so of course, the question would be if it’s easier to just write something myself hahahaha.

Well spotted! The latest seems to be:

I like the support for pyproject.toml but it appears appending dev and rc is now mandatory.

It’d definitely possibly to roll your own. Arguably, none of these are any easier than taking a moment before a release to update a single version string in pyproject.toml. And I think if you had a bump_version.py in your root everyone would get it. But easier or harder, it will be more work.

So here’re a few other methods for managing versions:

1 Like

That’s the fork of a fork? So when the maintainer gets tired of working on it, someone will have to fork that again and we will be using the fork of a fork of a fork :smiley:

1 Like

In any case, I think I managed to write something that more or less does what I needed, in case anyone else needs it. The logger will need to be changed to logging:

#!/usr/bin/env python3

import os
import re
import argparse
import subprocess

from log_store import log_store

log=log_store.add_logger('rx_setup:bver')
#-----------------------------
class data:
    package = None
    file_pt = None
    publish = None
    regex   = '\s*version\s*=\s*(?:\'|")(\d)\.(\d)\.(\d)(?:\'|")\s*,\s*'

    l_pkg   = list()
#-----------------------------
def get_args():
    parser = argparse.ArgumentParser(description='Used to bump versions of projects')
    parser.add_argument('-n', '--package' , type=str, help='Package name')
    parser.add_argument('-f', '--file'    , type=str, help='File with packages to bump')
    parser.add_argument('-p', '--publish' , type=int, help='Will publish to pypi after bumping', default=1, choices=[0, 1])
    args = parser.parse_args()

    data.package = args.package
    data.file_pt = args.file
    data.publish = args.publish
#-----------------------------
def get_list_from_file():
    if data.file_pt is None:
        return []

    if not os.path.isfile(data.file_pt):
        log.error(f'File not found: {data.file_pt}')
        raise FileNotFoundError

    with open(data.file_pt) as ifile:
        l_package = ifile.read().splitlines()

    return l_package
#-----------------------------
def initialize():
    if data.package is None and data.file_pt is None:
        log.error(f'Neither package name, not config was passed')
        raise

    if data.package is not None:
        data.l_pkg.append(data.package)

    data.l_pkg += get_list_from_file()

    if len(data.l_pkg) == 0:
        log.error('No packages found')
        raise
#-----------------------------
def run_command(cmd, options=None, shell=False):
    if not isinstance(options, list):
        log.error(f'Invalid options argument: {options}')
        raise ValueError

    log.debug('-' * 30)
    log.debug(f'{cmd:<10}{str(options):<50}')
    log.debug('-' * 30)

    stat = subprocess.run([cmd] + options, capture_output=True, shell=shell)

    return stat
#-----------------------------
def get_pkg_location(pkg):
    stat = run_command('pip', ['show', pkg])
    if stat.returncode != 0:
        log.warning(f'Cannot find {pkg}')
        return

    l_line = stat.stdout.splitlines()
    l_line = [ line.decode('utf-8') for line in l_line]
    [loc]  = [ line                 for line in l_line if 'Location' in line]
    loc    = loc.split(':')[1].replace(' ', '')
    loc    = os.path.dirname(loc)

    return loc
#-----------------------------
def increase_version(x, y, z):
    x = int(x)
    y = int(y)
    z = int(z)

    if   z < 9:
        z += 1
    elif y < 9:
        y += 1
        z  = 0
    else:
        x += 1
        y  = 0
        z  = 0

    return x, y, z
#-----------------------------
def update_ver_line(ver_line):
    mtch = re.match(data.regex, ver_line)
    if not mtch:
        log.error(f'Not a valid version matching {data.regex}: {ver_line}')
        raise

    x, y, z = mtch.groups()
    over    = f'{x}.{y}.{z}'
    x, y, z = increase_version(x, y, z)
    nver    = f'{x}.{y}.{z}'

    log.info(f'{over:<10}{"->":<5}{nver:<10}')
    ver_line= ver_line.replace(over, nver)

    return ver_line
#-----------------------------
def is_version_line(line):
    mtch = re.match(data.regex, line)

    return mtch is not None
#-----------------------------
def update_setup(ploc):
    setup_path = f'{ploc}/setup.py'
    if not os.path.isfile(setup_path):
        log.error(f'Cannot find setup file in: {setup_path}')
        raise FileNotFoundError

    with open(setup_path) as ifile:
        l_line = ifile.read().splitlines()

    try:
        [ver_line] = [line for line in l_line if is_version_line(line)]
    except:
        log.error(f'Cannot find one and only one version line with {data.regex} in {setup_path}')
        raise

    index         = l_line.index(ver_line)
    ver_line      = update_ver_line(ver_line)
    l_line[index] = ver_line

    with open(setup_path, 'w') as ofile:
        for line in l_line:
            ofile.write(f'{line}\n')
#-----------------------------
def publish(ploc):
    if data.publish == 0:
        log.warning('Not publishing')
        return

    this_dir = os.getcwd()

    log.info(f'--> {ploc}')
    os.chdir(ploc)
    log.info('Publishing to pypi')
    run_command('rm'    , options=['-rf', 'dist', 'build', '*.egg-info'])
    run_command('python', options=['setup.py', 'sdist', 'bdist_wheel']  )
    run_command('twine' , options=['upload', 'dist/*']                  )
    log.info(f'{this_dir} <---')
    os.chdir(this_dir)
#-----------------------------
def bump(pkg):
    log.info(f'{"":<4}{pkg:<20}')
    ploc = get_pkg_location(pkg)
    if ploc is None:
        return

    update_setup(ploc)
    publish(ploc)
#-----------------------------
def main():
    get_args()
    initialize()

    log.info('Bumping:')
    for pkg in data.l_pkg:
        bump(pkg)
#-----------------------------
if __name__ == '__main__':
    main()

it bumps and publishes, assuming the user has a pypi token.

It’s not ideal I agree, but I’m sure there were good reasons.

There’s a lot that could be said in favour of manually bumping one single version string, e.g. in pyproject.toml.