From 3471560eaa841ccc1411fee61145b66904e041df Mon Sep 17 00:00:00 2001 From: Manuel Barkhau Date: Sun, 2 Sep 2018 21:48:12 +0200 Subject: [PATCH] WIP: mostly scratch code still --- .gitignore | 66 +++++ CHANGELOG.rst | 7 + LICENSE | 2 +- Makefile | 160 ++++++++++++ README.rst | 546 +++++++++++++++++++++++++++++++++++++++ license.header | 10 + scratch.py | 109 ++++++++ setup.cfg | 35 +++ setup.py | 80 ++++++ src/pycalver/__init__.py | 11 + src/pycalver/__main__.py | 146 +++++++++++ src/pycalver/config.py | 122 +++++++++ src/pycalver/lex_id.py | 137 ++++++++++ src/pycalver/parse.py | 94 +++++++ src/pycalver/rewrite.py | 21 ++ src/pycalver/vcs.py | 111 ++++++++ src/pycalver/version.py | 71 +++++ test/__init__.py | 0 test/test_config.py | 0 test/test_version.py | 30 +++ 20 files changed, 1757 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CHANGELOG.rst create mode 100644 Makefile create mode 100644 README.rst create mode 100644 license.header create mode 100644 scratch.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/pycalver/__init__.py create mode 100644 src/pycalver/__main__.py create mode 100644 src/pycalver/config.py create mode 100644 src/pycalver/lex_id.py create mode 100644 src/pycalver/parse.py create mode 100644 src/pycalver/rewrite.py create mode 100644 src/pycalver/vcs.py create mode 100644 src/pycalver/version.py create mode 100644 test/__init__.py create mode 100644 test/test_config.py create mode 100644 test/test_version.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f83e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv/ +venv2/ +venv3/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +.mypy_cache/ +*.egg-info/ +.installed.cfg +*.egg +.eggs/ +.pytest_cache/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml +.coverage_percent.txt + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ +.idea +.*.make_marker +README.html +envs.txt +test_build_logs/*.log diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..c852261 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,7 @@ +Changelog for pycalver +====================== + +v201809.0001-alpha +------------------ + +- Initial release diff --git a/LICENSE b/LICENSE index 182a256..ee7dd80 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 mbarkhau +Copyright (c) 2018 Manuel Barkhau (@mbarkhau) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..60f4af9 --- /dev/null +++ b/Makefile @@ -0,0 +1,160 @@ +.PHONY: setup_conda_envs install \ + test lint \ + clean rm_site_packages \ + build readme upload + + +build/.setup_conda_envs.make_marker: + conda create --name pycalver_37 python=3.7 --yes + conda create --name pycalver_36 python=3.6 --yes + conda create --name pycalver_27 python=2.7 --yes + @mkdir -p build/ + @touch build/.setup_conda_envs.make_marker + + +build/envs.txt: build/.setup_conda_envs.make_marker + @mkdir -p build/ + conda env list | grep pycalver | rev | cut -d " " -f1 | rev > build/envs.txt.tmp + mv build/envs.txt.tmp build/envs.txt + + +PYENV37 ?= $(shell bash -c "grep 37 build/envs.txt || true") +PYENV36 ?= $(shell bash -c "grep 36 build/envs.txt || true") +PYENV27 ?= $(shell bash -c "grep 27 build/envs.txt || true") +PYTHON37 ?= $(PYENV37)/bin/python +PYTHON36 ?= $(PYENV36)/bin/python +PYTHON27 ?= $(PYENV27)/bin/python + +BDIST_WHEEL_LIB3TO6 = $(shell bash -c "ls -1t dist/pycalver*py2*.whl | head -n 1") +SDIST_LIB3TO6 = $(shell bash -c "ls -1t dist/pycalver*.tar.gz | head -n 1") +DIST_WHEEL_TEST = $(shell bash -c "ls -1t test_project/dist/*py2*.whl | head -n 1") +BUILD_LOG_DIR = "test_build_logs/" +BUILD_LOG_FILE := $(shell date +"$(BUILD_LOG_DIR)%Y%m%dt%H%M%S%N.log") + + +build/.install.make_marker: setup.py build/envs.txt + $(PYTHON36) -m pip install --upgrade --quiet \ + pip setuptools wheel twine \ + flake8 mypy typing-extensions \ + rst2html5 \ + pytest pytest-cov \ + ipython pudb \ + pathlib2 click; + + $(PYTHON37) -m pip install --upgrade --quiet pip setuptools wheel; + $(PYTHON36) -m pip install --upgrade --quiet pip setuptools wheel; + $(PYTHON27) -m pip install --upgrade --quiet pip setuptools wheel; + + @mkdir -p build/ + @touch build/.install.make_marker + + +clean: + rm -f build/envs.txt + rm -f build/.setup_conda_envs.make_marker + rm -f build/.install.make_marker + + +# NOTE (mb 2018-08-23): The linter has an issue running with +# python 3.7 because some code in pycodestyle=2.3.1 +# but we have to wait for a flake8 update because +# reasons... https://github.com/PyCQA/pycodestyle/issues/728 + + +lint: build/.install.make_marker + @echo -n "lint.." + @$(PYTHON36) -m flake8 src/pycalver/ + @echo "ok" + + +mypy: build/.install.make_marker + @echo -n "mypy.." + @MYPYPATH=stubs/ $(PYTHON36) -m mypy \ + src/pycalver/ + @echo "ok" + + +test: build/.install.make_marker + @PYTHONPATH=src/:$$PYTHONPATH \ + $(PYTHON36) -m pytest \ + --cov-report html \ + --cov=pycalver \ + test/ + + +devtest: build/.install.make_marker + PYTHONPATH=src/:$$PYTHONPATH \ + $(PYTHON36) -m pytest -v \ + --cov-report term \ + --cov=pycalver \ + --capture=no \ + --exitfirst \ + test/ + + +build/.coverage_percent.txt: test + @mkdir -p build/ + @grep -oP '>[0-9]+%' htmlcov/index.html \ + | head -n 1 \ + | grep -oP '[.0-9]+' \ + > build/.coverage_percent.txt + + +README.rst: build/.coverage_percent.txt + @sed -i "s/coverage-[0-9]*/coverage-$$(cat build/.coverage_percent.txt)/" README.rst + + +build/README.html: build/.install.make_marker README.rst CHANGELOG.rst + @cat README.rst > build/.full_readme.rst + @echo "\n" >> build/.full_readme.rst + @cat CHANGELOG.rst >> build/.full_readme.rst + @$(PYENV36)/bin/rst2html5 --strict \ + build/.full_readme.rst > build/README.html.tmp + @mv build/README.html.tmp build/README.html + @echo "updated build/README.html" + + +readme: build/README.html + + +build/.src_files.txt: setup.py build/envs.txt src/pycalver/*.py + @mkdir -p build/ + @ls -l setup.py build/envs.txt src/pycalver/*.py > build/.src_files.txt.tmp + @mv build/.src_files.txt.tmp build/.src_files.txt + + +rm_site_packages: + rm -rf $(PYENV36)/lib/python3.6/site-packages/pycalver/ + rm -rf $(PYENV36)/lib/python3.6/site-packages/pycalver*.dist-info/ + rm -rf $(PYENV36)/lib/python3.6/site-packages/pycalver*.egg-info/ + rm -f $(PYENV36)/lib/python3.6/site-packages/pycalver*.egg + + +build/.local_install.make_marker: build/.src_files.txt rm_site_packages + @echo "installing pycalver.." + @$(PYTHON36) setup.py install --no-compile --verbose + @mkdir -p build/ + @$(PYTHON36) -c "import pycalver" + @echo "install completed for pycalver" + @touch build/.local_install.make_marker + + +build: build/.local_install.make_marker + @mkdir -p $(BUILD_LOG_DIR) + @echo "writing full build log to $(BUILD_LOG_FILE)" + @echo "building pycalver.." + @$(PYTHON36) setup.py bdist_wheel --python-tag=py2.py3 >> $(BUILD_LOG_FILE) + @echo "build completed for pycalver" + + +upload: build/.install.make_marker build/README.html + $(PYTHON36) setup.py bdist_wheel --python-tag=py2.py3 + $(PYENV36)/bin/twine upload $(BDIST_WHEEL_LIB3TO6) + + +setup_conda_envs: build/.setup_conda_envs.make_marker + +install: build/.install.make_marker + +run_main: + PYTHONPATH=src/:$$PYTHONPATH $(PYTHON36) -m pycalver --help diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..7775999 --- /dev/null +++ b/README.rst @@ -0,0 +1,546 @@ +PyCalVer: Automatic CalVer Versioning for Python Packages +========================================================= + +PyCalVer is a very simple versioning system, +which is compatible with python packaging software +( +`setuptools `_, +`PEP440 `_ +). + +.. start-badges + +.. list-table:: + :stub-columns: 1 + + * - package + - | |license| |wheel| |pyversions| |pypi| |version| + * - tests + - | |travis| |mypy| |coverage| + +.. |travis| image:: https://api.travis-ci.org/mbarkhau/pycalver.svg?branch=master + :target: https://travis-ci.org/mbarkhau/pycalver + :alt: Build Status + +.. |mypy| image:: http://www.mypy-lang.org/static/mypy_badge.svg + :target: http://mypy-lang.org/ + :alt: Checked with mypy + +.. |coverage| image:: https://img.shields.io/badge/coverage-86%25-green.svg + :target: https://travis-ci.org/mbarkhau/pycalver + :alt: Code Coverage + +.. |license| image:: https://img.shields.io/pypi/l/pycalver.svg + :target: https://github.com/mbarkhau/pycalver/blob/master/LICENSE + :alt: MIT License + +.. |pypi| image:: https://img.shields.io/pypi/v/pycalver.svg + :target: https://pypi.python.org/pypi/pycalver + :alt: PyPI Version + +.. |version| image:: https://img.shields.io/badge/CalVer-v201809.0001--beta-blue.svg + :target: https://calver.org/ + :alt: CalVer v201809.0001-beta + +.. |wheel| image:: https://img.shields.io/pypi/wheel/pycalver.svg + :target: https://pypi.org/project/pycalver/#files + :alt: PyPI Wheel + +.. |pyversions| image:: https://img.shields.io/pypi/pyversions/pycalver.svg + :target: https://pypi.python.org/pypi/pycalver + :alt: Supported Python Versions + + +The PyCalVer package provides the ``pycalver`` command and +module to generate version strings which follow the format: +``v{calendar_version}.{build_number}[-{tag}]`` + +Some examples: + + +.. code-block:: + + v201711.0001-alpha + v201712.0027-beta + v201801.0031 + v201801.0032-post + ... + v202207.18133 + v202207.18134 + + +The ``pycalver bump`` command will parse specified/configured +files for such strings and rewrite them with an updated version +string. + +The format accepted by PyCalVer can be parsed with this regular +expression: + + +.. code-block:: python + + import re + + pycalver_re = re.compile(r""" + # https://regex101.com/r/fnj60p/9 + \b + (?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d{4,}) + ) + (?: + \- # "-" release prefix + (?P + alpha|beta|dev|rc|post + ) + )? + )(?:\s|$) + """, flags=re.VERBOSE) + + version_str = "v201712.0001-alpha" + version_info = pycalver_re.match(version_str).groupdict() + + assert version_info == { + "version" : "v201712.0001-alpha", + "calver" : "v201712", + "year" : "2017", + "month" : "12", + "build" : "0001", + "release" : "alpha", + } + + version_str = "v201712.0033" + version_info = pycalver_re.match(version_str).groupdict() + + assert version_info == { + "version" : "v201712.0033", + "calver" : "v201712", + "year" : "2017", + "month" : "12", + "build" : "0033", + "release" : None, + } + + +Usage +----- + +Before we look at project setup, we can simply install and test +by passing a version string to ``pycalver bump``. + + +.. code-block:: bash + + $ pip install pycalver + $ pycalver bump v201801.0033-beta + v201809.0034-beta + + +The CalVer component is set to the current year and month, the +build number is incremented by one and the optional release tag +is preserved as is, unless specified otherwise via the +``--release=`` parameter. + +To setup a project, add the following lines to your ``setup.cfg`` + + +.. code-block:: ini + + [pycalver] + current_version = v201809.0001-beta + commit = True + tag = True + + [pycalver:file:setup.cfg] + patterns = + current_version = {version} + + [pycalver:file:setup.py] + patterns = + version="{pep440_version}", + + [pycalver:file:src/myproject.py] + patterns = + __version__ = "{version}" + + [pycalver:file:README.rst] + patterns = + badge/CalVer-{calver}{build}-{release}-blue.svg + :alt: CalVer {version} + + +The above setup.cfg file is very explicit, and can be shortened quite a bit. + + +.. code-block:: ini + + [pycalver] + current_version = v201809.0001-beta + commit = True + tag = True + + [pycalver:file:setup.cfg] + [pycalver:file:setup.py] + [pycalver:file:src/myproject.py] + [pycalver:file:README.rst] + patterns = + badge/CalVer-{calver}{build}-{release}-blue.svg + :alt: CalVer {version} + + +This makes use of the default ``patterns = {version}``, which +will replace all occurrences of a PyCalVer version string with +the updated ``current_version``. + +Now we can call ``pycalver bump`` to bump all occurrences of +version strings in these files. Normally this will change local +files, but the ``--dry`` flag will instead display a diff of the +changes that would be applied. + + +.. code-block:: bash + + $ pycalver show + Current Version: v201809.0001-beta + PEP440 Version: 201809.1b0 + + $ pycalver bump --dry + TODO + Don't forget to git push --tags + + + +Other Versioning Software +------------------------- + +This project is very similar to bumpversion, upon which it is +partially based, but since the PyCalVer version strings can be +generated automatically, usage is quite a bit more simple. Users +do not have to deal with parsing and generating version strings. +Most of the interaction that users will have is reduced to two +commands: + + +.. code-block:: bash + + $ pycalver bump + TODO: Output + + +More rarely, when changing the release type: + +.. code-block:: bash + + $ pycalver bump --release beta + TODO: Output + + $ pycalver bump --release final + TODO: Output + + +Some Details +------------ + + - Version numbers are for public releases. For the purposes of + development of the project itself, reference VCS branches and + commit ids are more appropriate. + - There should be only one person or system responsible for + updating the version number at the time of release, otherwise + the same version number may be generated for different builds. + - Lexeographical order is + + +Canonical PyCalVer version strings can be parsed with this +regular expression: + + +These are the full version strings, for public announcements and +conversations it will often be sufficient to refer simply to +``v201801``, by which the most recent ``post`` release build of +that month is meant. + + + + version_str = "v201712.0027-beta" + version_dict = pycalver_re.match("v201712.0027-beta").groupdict() + import pkg_resources # from setuptools + version = pkg_resources.parse_version(version_str) + -- + + In [2]: version_dict + {'year': '2017', 'month': '12', 'build_nr': '0027', 'tag': 'beta'} + >>> str(version) + 201712.27b0 + + +Lexical Ids +----------- + +Most projects will be served perfectly well by the default four +digit zero padded build number. Depending on your build system +however, you may get into higher build numbers. Since build +numbers have no semantic meaning (beyond larger = later/newer), +they are incremented in a way that preserves lexical ordering as +well as numerical order. Examples will perhaps illustrate more +clearly. + +.. code-block:: python + + "0001" + "0002" + "0003" + ... + "0999" + "11000" + "11001" + ... + "19998" + "19999" + "220000" + "220001" + +What is happening here is that the left-most digit is incremented +early, whenever the left-most digit changes. The formula is very simple: + +.. code-block:: python + + prev_id = "0999" + next_id = str(int(prev_id, 10) + 1) # "1000" + if prev_id[0] != next_id[0]: # "0" != "1" + next_id = str(int(next_id, 10) * 11) # 1000 * 11 = 11000 + + +In practice you can just ignore the left-most digit, in case you +do want to read something into the semantically meaningless +build number. + + +Realities of Verion Numbers +--------------------------- + +Nobody knows what the semantics of a version number are, because +nobody can guarantee that a given release adheres to whatever +convention one would like to imbibe it with. Lets just keep things +simple. + + - Version numbers should be recognizable as such, that's what + the "v" prefix does. + - A number like 201808 is recognizable to many as a number + derived from a calendar. + - alpha, beta are common parlance indicating software which is + still under development. + +Some additional constraints are applied to conform with PEP440 + + +Should I use PyCalVer for my Project? +------------------------------------- + +If your project is 1. not useful by itself, but only when used +by other software, 2. has a finite scope/a definition of "done", +3. your project has CI, a test suite with and decent code +coverage, then PyCalVer is worth considering. +You release at most once per month. + + +Marketing/Vanity +---------------- + +Quotes from http://sedimental.org/designing_a_version.html + + +Rational +-------- + +PyCalVer is opinionated software. This keeps things simple, +when the opintions match yours, but makes it useless for +everybody else. + +The less machine parsable semantics you put in your version +string, the better. The ideal would be to only have a single +semantic: newer == better. + +Some projects depend recursively on hundreds of libraries, so +compatability issues generated by your project can be a heavy +burdon on thousands of users; users who learn of the existance +of your library for the first time in the form of a stacktrace. +PyCalVer is for projects that are comitted to and can maintain +backward compatability. Newer versions are always better, +updates are always safe, an update won't break things, and if it +does, the maintainer's hair is on fire and they will publish a +new release containing a fix ASAP. + +Ideally, your user can just declare your library as a +dependency, without any extra version qualifier, and never have +to think about it again. If you do break something by accident, +their remedy is not to change their code, but to temporarily pin +an earlier version, until your bugfix release is ready. + +PyCalVer is for projects which are the mundane but dependable +foundations of other big shiny projects, which get to do their +big and exciting 2.0 major releases. + + +Breaking Things is a Big Deal +----------------------------- + +Using an increment in a version string to express that a release +may break client code is not tennable. A developer cannot be +expected to think about how their code may or may not break as a +consequence of your decision to rename some functions. As the +author of any software, there is a great temptation to move fast +and break things. This is great when no other software depends +on yours. If something breaks, you jump up and fix it. The story +is quite different even when only a few dozen people depend on +your software. + + +The less the users of your library have to know about your +project, the better. The less they have to deal with issues +of compatability, the better. SemVer can be overly specifc +for some kinds of projects. If you are writing a library +and you have a commitment to backward compatability + +PyCalVer version strings can be parsed according to PEP440 +https://www.python.org/dev/peps/pep-0440/ + + +A Word on Marketing +------------------- + +This setup of expectations for users can go one of two ways, + +We use version numbers to communicate between the authors +of software and its users. For users of libraries Particularly +for libraries, it pays to keep things as simple as possible for +your human users. + + +Commitment to Compatability +--------------------------- + +Software projects can depend on many libraries. Consider that one +package introducing a breaking change is enough to mess up your +day. Especially in the case of libraries, your users should be +able to write code that uses it and not have that code break at +any point in the future. Users cannot be asked to keep track of +all the changes to every little library that they use. + +PyCalVer is explicitly non semantic. A PyCalVer version number does +not express anything about + + - Don't ever break things. When users depend on your + software, backward compatability matters and the way to + express backward incompatible changes is not to bump a + version number, but to change the package name. A change + in the package name clearly communicates that a user must + change their code so that it will work with the changed + API. Everybody who does not have the bandwith for those + changes, doesn't even have to be aware of your new + release. + + - When you do break something, that should be considered a + bug that has to be fixed as quickly as possible in a new + version. It should always be safe for a user to update + their dependencies. If something does break, users have to + temporarilly pin an older (known good) version, or update + to a newer fixed version. + + - Version numbers should not require a parser (present + package excluded of course). A newer version number should + always be lexeographically greater than an older one. + TODO: + https://setuptools.readthedocs.io/en/latest/setuptools.html#specifying-your-project-s-version + + +The main component of the version number is based on the +calendar date. This is allows you to show your commitment (or +lack thereof) to the maintenance of your libarary. It also +allows users to see at a glance that their dependency might be +out of date. In this versioning scheme it is completely +reasonable to bump the version number without any changes, +simply to express to your users, that you are still actively +maintaining the software and that it is in a known good state. + + +For a much more detailed exposition of CalVer, see +http://sedimental.org/designing_a_version.html +https://calver.org/ + +from pkg_resources import parse_version + + +The Life of a Library +--------------------- + +mylib v201711.001-alpha # birth of a project (in alpha) +mylib v201711.002-alpha # new features (in alpha) +mylib v201712.003-beta # bugfix release (in beta) +mylib v201712.004-rc # release candidate +mylib v201712.005 # stable release +mylib v201712.006 # stable bugfix release + +mylib2 v201712.007-beta # breaking change (new package name!) +mylib2 v201801.008-beta # new features (in beta) +mylib2 v201801.009 # stable release + +mylib v201802.007 # security fix for legacy version +mylib2 v201802.010 # security fix + +mylib2 v202604.9900 # threshold for four digit build numbers +mylib2 v202604.9901 # still four digits in the same month +mylib2 v202604.9911 # last build number with four digits +mylib2 v202605.09912 # build number zero padding added with date turnover +mylib2 v202605.09913 # stable release + +mylib2 v203202.16051-rc # release candidate +mylib2 v203202.16052 # stable release + + ... + v202008.500 # 500 is the limit for four digit build numbers, but + v202008.508 # zero padding is added only after the turnover to + v202009.0509 # a new month, so that lexical ordering is preserved. + +The date portion of the version, gives the user an indication of +how up their dependency is, whether or not a project is still +being maintained. + +The build number, gives the user an idea of the maturity of the +project. A project which has been around long enough to produce +hundreds of builds, might be considered mature, or at least a +project that is only on build number 10, is probably still in +early development. + + +FAQ +--- + +Q: "So you're trying to tell me I need to create a whole new +package every time I introduce a introduce a breaking change?!". + +A: First of all, what the hell are you doing? Secondly, YES! +Let's assume your little package has even just 100 users. Do you +have any idea about the total effort that will be expended +because you decided it would be nice to change the name of a +function? It is completely reasonable introduce that the +friction for the package author when the price to users is +orders of magnitude larger. + + +1801 + +https://calver.org/ + +I have given up on the idea that version numbers express +anything about changes made between versions. Trying to +express such information assumes 1. that the author of a package +is aware of how a given change needs to be reflected in a +version number and 2. that users and packaging softare correctly +parse that meaning. When I used semantic versioning, I realized that +the major version number of my packages would never change, because I don't think breaking changes should ever be One of the biggest offenses expres diff --git a/license.header b/license.header new file mode 100644 index 0000000..2628705 --- /dev/null +++ b/license.header @@ -0,0 +1,10 @@ +Individual files contain the following tag instead of the full license text. + + This file is part of the pycalver project + https://github.com/mbarkhau/pycalver + + (C) 2018 Manuel Barkhau (@mbarkhau) + SPDX-License-Identifier: MIT + +This enables machine processing of license information based on the SPDX +License Identifiers that are here available: https://spdx.org/licenses/ diff --git a/scratch.py b/scratch.py new file mode 100644 index 0000000..8283627 --- /dev/null +++ b/scratch.py @@ -0,0 +1,109 @@ +from pkg_resources import parse_version +import re + +canonical_version_re = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d{3,}) + ) +)(?:\s|$) +""", flags=re.VERBOSE) + + +full_version_re = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d+) + ) + (?: + \- # "-" tag prefix + (?Pa|alpha|b|beta|dev|c|rc|pre|preview|post) + (?P\d*) + )? +)(?:\s|$) +""", flags=re.VERBOSE) + +versions = [ + "v201711.0001-alpha", + "v201711.0002-alpha", + "v201712.0003-beta3", + "v201712.0004-preview", + "v201712.0005", + "v201712.0006", + "v201712.0007-beta", + "v201801.0008-beta", + "v201801.0008-dev", + "v201801.0009", + "v201802.0010", + "v201802.0007", + "v201904.0050", + "v201905.0051", + "v201905.0052", + "v201712.0027-beta1", + "v201712.beta-0027", + "v201712.post-0027", + "v201712.post-0027", +] + +for vstr in versions: + v = parse_version(vstr) + print(vstr.ljust(20), repr(v).ljust(30), int(v.is_prerelease)) + v = full_version_re.match(vstr) + print("\t", v and v.groupdict()) + v = canonical_version_re.match(vstr) + print("\t", v and v.groupdict()) + +a = "v201711.beta-0001" +b = "v201711.0002-beta" +c = "v201711.0002" +d = "0.9.2" + +va = parse_version(a) +vb = parse_version(b) +vc = parse_version(c) +vd = parse_version(d) + +print(a, repr(va)) +print(b, repr(vb)) +print(c, repr(vc)) +print(d, repr(vd), vd < vc) + + +# https://regex101.com/r/fnj60p/3 +pycalver_re = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + . # "." build nr prefix + (?P\d{4,}) + ) +)(?:\s|$) +""", flags=re.VERBOSE) + +print(pycalver_re.match("v201712.0027").groupdict()) + +print(repr(parse_version("v201712.0027"))) +print(repr(parse_version("v201712.0027"))) +print(repr(parse_version("v201712.0027"))) +print(repr(parse_version("v201712") == parse_version("v201712.0"))) + +print("v201712.0027" > "v201712.0028") diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8d593ad --- /dev/null +++ b/setup.cfg @@ -0,0 +1,35 @@ +[flake8] +ignore = E203 +max-line-length = 110 +exclude = .git,__pycache__,.eggs/,dist/,.mypy_cache + +[mypy] +check_untyped_defs = True +disallow_untyped_calls = True +follow_imports = silent +strict_optional = True + +[bdist_wheel] +universal = 1 + +[pycalver] +current_version = v201808.0001-beta +commit = True +tag = True + +[pycalver:file:setup.cfg] +patterns = + current_version = {version} + +[pycalver:file:setup.py] +patterns = + __version__ = "{version}" + +[pycalver:file:src/pycalver/__init__.py] +patterns = + __version__ = "{version}" + +[pycalver:file:README.rst] +patterns = + badge/CalVer-{calver}{build}-{release}-blue.svg + :alt: CalVer {version} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e74cdad --- /dev/null +++ b/setup.py @@ -0,0 +1,80 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (mbarkhau@gmail.com) +# SPDX-License-Identifier: MIT + +import os +import sys +import setuptools + + +def project_path(filename): + dirpath = os.path.abspath(os.path.dirname(__file__)) + return os.path.join(dirpath, filename) + + +def read(filename): + with open(project_path(filename), mode="rb") as fh: + return fh.read().decode("utf-8") + + +packages = setuptools.find_packages(project_path("src")) +package_dir = {"": "src"} + + +if any(arg.startswith("bdist") for arg in sys.argv): + import lib3to6 + package_dir = lib3to6.fix(package_dir) + + +long_description = ( + read("README.rst") + + "\n\n" + + read("CHANGELOG.rst") +) + + +setuptools.setup( + name="pycalver", + license="MIT", + author="Manuel Barkhau", + author_email="mbarkhau@gmail.com", + url="https://github.com/mbarkhau/pycalver", + version="201809.1a0", + + description="CalVer versioning for python projects", + long_description=long_description, + long_description_content_type="text/x-rst", + + packages=packages, + package_dir=package_dir, + zip_safe=True, + install_requires=["typing", "click", "setuptools"], + setup_requires=["lib3to6==v201809.0017-alpha"], + entry_points=''' + [console_scripts] + pycalver=pycalver.__main__:cli + ''', + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Other Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/src/pycalver/__init__.py b/src/pycalver/__init__.py new file mode 100644 index 0000000..f042c95 --- /dev/null +++ b/src/pycalver/__init__.py @@ -0,0 +1,11 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import os + +__version__ = "v201809.0001-beta" + +DEBUG = os.environ.get("PYCALVER_DEBUG", "0") == "1" diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py new file mode 100644 index 0000000..9aa0f78 --- /dev/null +++ b/src/pycalver/__main__.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import io +import os +import sys +import click +import logging +import typing as typ + +from . import DEBUG +from . import vcs +from . import parse +from . import config +from . import version +from . import rewrite + + +log = logging.getLogger("pycalver.__main__") + + +def _init_loggers(verbose: bool) -> None: + if DEBUG: + log_formatter = logging.Formatter('%(levelname)s - %(name)s - %(message)s') + log_level = logging.DEBUG + elif verbose: + log_formatter = logging.Formatter('%(levelname)s - %(message)s') + log_level = logging.INFO + else: + log_formatter = logging.Formatter('%(message)s') + log_level = logging.WARNING + + loggers = [log, vcs.log, parse.log, config.log, rewrite.log, version.log] + + for logger in loggers: + if len(logger.handlers) == 0: + ch = logging.StreamHandler(sys.stderr) + ch.setFormatter(log_formatter) + logger.addHandler(ch) + logger.setLevel(log_level) + + log.debug("Loggers initialized.") + + +@click.group() +def cli(): + """parse and update project versions automatically.""" + + +@cli.command() +def show() -> None: + _init_loggers(verbose=False) + + cfg = config.parse() + if cfg is None: + return + + print(f"Current Version: {cfg['current_version']}") + print(f"PEP440 Version: {cfg['pep440_version']}") + + +@cli.command() +@click.option( + "--dry", + default=False, + is_flag=True, + help="Display diff of changes, don't rewrite files.", +) +def init(dry: bool) -> None: + """Initialize [pycalver] configuration in setup.cfg""" + _init_loggers(verbose=False) + + cfg = config.parse() + if cfg: + log.error("Configuration already initialized in setup.cfg") + return + + cfg_lines = config.default_config_lines() + + if dry: + print("Exiting because of '--dry'. Would have written to setup.cfg:") + print("\n " + "\n ".join(cfg_lines)) + return + + if os.path.exists("setup.cfg"): + cfg_content = "\n" + "\n".join(cfg_lines) + with io.open("setup.cfg", mode="at", encoding="utf-8") as fh: + fh.write(cfg_content) + print("Updated setup.cfg") + else: + cfg_content = "\n".join(cfg_lines) + with io.open("setup.cfg", mode="at", encoding="utf-8") as fh: + fh.write(cfg_content) + print("Created setup.cfg") + + +@cli.command() +@click.option( + "--verbose", + default=False, + is_flag=True, + help="Log applied changes to stderr", +) +@click.option( + "--dry", + default=False, + is_flag=True, + help="Display diff of changes, don't rewrite files.", +) +@click.option( + "--release", + default=None, + metavar="", + help="Override release name of current_version", +) +def bump(verbose: bool, dry: bool, release: typ.Optional[str] = None) -> None: + _init_loggers(verbose) + if release and release not in parse.VALID_RELESE_VALUES: + log.error(f"Invalid argument --release={release}") + log.error(f"Valid arguments are: {', '.join(parse.VALID_RELESE_VALUES)}") + return + + cfg = config.parse() + + if cfg is None: + log.error("Unable to parse pycalver configuration from setup.cfg") + return + + old_version = cfg["current_version"] + new_version = version.bump(old_version, release=release) + + log.info(f"Old Version: {old_version}") + log.info(f"New Version: {new_version}") + + matches: typ.List[parse.PatternMatch] + for filepath, patterns in cfg["file_patterns"].items(): + with io.open(filepath, mode="rt", encoding="utf-8") as fh: + content = fh.read() + lines = content.splitlines() + matches = parse.parse_patterns(lines, patterns) + for m in matches: + print(m) diff --git a/src/pycalver/config.py b/src/pycalver/config.py new file mode 100644 index 0000000..83d1aa6 --- /dev/null +++ b/src/pycalver/config.py @@ -0,0 +1,122 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import io +import os +import configparser +import pkg_resources +import typing as typ +import datetime as dt + +import logging + +from .parse import PYCALVER_RE + +log = logging.getLogger("pycalver.config") + + +def parse(config_file="setup.cfg") -> typ.Optional[typ.Dict[str, typ.Any]]: + if not os.path.exists(config_file): + log.error("File not found: setup.cfg") + return None + + cfg_parser = configparser.RawConfigParser("") + with io.open(config_file, mode="rt", encoding="utf-8") as fh: + cfg_parser.readfp(fh) + + if "pycalver" not in cfg_parser: + log.error("setup.cfg does not contain a [pycalver] section.") + return None + + cfg = dict(cfg_parser.items("pycalver")) + + if "current_version" not in cfg: + log.error("setup.cfg does not have 'pycalver.current_version'") + return None + + current_version = cfg["current_version"] + if PYCALVER_RE.match(current_version) is None: + log.error(f"setup.cfg 'pycalver.current_version is invalid") + log.error(f"current_version = {current_version}") + return None + + cfg["pep440_version"] = str(pkg_resources.parse_version(current_version)) + + cfg["tag"] = cfg.get("tag", "").lower() in ("yes", "true", "1", "on") + cfg["commit"] = cfg.get("commit", "").lower() in ("yes", "true", "1", "on") + + cfg["file_patterns"] = {} + + for section_name in cfg_parser.sections(): + if not section_name.startswith("pycalver:file:"): + continue + + filepath = section_name.split(":", 2)[-1] + if not os.path.exists(filepath): + log.error(f"No such file: {filepath} from {section_name} in setup.cfg") + return None + + section = dict(cfg_parser.items(section_name)) + + if "patterns" in section: + cfg["file_patterns"][filepath] = [ + line.strip() + for line in section["patterns"].splitlines() + if line.strip() + ] + else: + cfg["file_patterns"][filepath] = ["{version}", "{pep440_version}"] + + if not cfg["file_patterns"]: + cfg["file_patterns"]["setup.cfg"] = ["{version}", "{pep440_version}"] + + log.debug(f"Config Parsed: {cfg}") + + return cfg + + +def default_config_lines() -> typ.List[str]: + initial_version = dt.datetime.now().strftime("v%Y%m.0001-dev") + + cfg_lines = [ + "[pycalver]", + f"current_version = {initial_version}", + "commit = True", + "tag = True", + "", + "[pycalver:file:setup.cfg]", + "patterns = ", + " current_version = {version}", + "", + ] + + if os.path.exists("setup.py"): + cfg_lines.extend([ + "[pycalver:file:setup.py]", + "patterns = ", + " \"{version}\"", + " \"{pep440_version}\"", + "", + ]) + + if os.path.exists("README.rst"): + cfg_lines.extend([ + "[pycalver:file:README.rst]", + "patterns = ", + " {version}", + " {pep440_version}", + "", + ]) + + if os.path.exists("README.md"): + cfg_lines.extend([ + "[pycalver:file:README.md]", + " {version}", + " {pep440_version}", + "", + ]) + + return cfg_lines diff --git a/src/pycalver/lex_id.py b/src/pycalver/lex_id.py new file mode 100644 index 0000000..9f70c4a --- /dev/null +++ b/src/pycalver/lex_id.py @@ -0,0 +1,137 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +""" +This is a simple scheme for numerical ids which are ordered both +numerically and lexically. + +Throughout the sequence this expression remains true, whether you +are dealing with integers or strings: + + older_id < newer_id + +The left most character/digit is only used to maintain lexical +order, so that the position in the sequence is maintained in the +remaining digits. + + sequence_pos = int(idval[1:], 10) + +lexical sequence_pos +0 0 +11 1 +12 2 +... +19 9 +220 20 +221 21 +... +298 98 +299 99 +3300 300 +3301 301 +... +3998 998 +3999 999 +44000 4000 +44001 4001 +... +899999998 99999998 +899999999 99999999 +9900000000 900000000 +9900000001 900000001 +... +9999999998 999999998 +9999999999 999999999 # maximum value + +You can add leading zeros to delay the expansion and/or increase +the maximum possible value. + +lexical sequence_pos +0001 1 +0002 2 +0003 3 +... +0999 999 +11000 1000 +11001 1001 +11002 1002 +... +19998 9998 +19999 9999 +220000 20000 +220001 20001 +... +899999999998 99999999998 +899999999999 99999999999 +9900000000000 900000000000 +9900000000001 900000000001 +... +9999999999998 999999999998 +9999999999999 999999999999 # maximum value + +This scheme is useful when you just want an ordered sequence of +numbers, but the numbers don't have any particular meaning or +arithmetical relation. The only relation they have to each other +is that numbers generated later in the sequence are greater than +ones generated earlier. +""" + + +MINIMUM_ID = "0" + + +def next_id(prev_id: str) -> str: + num_digits = len(prev_id) + _prev_id = int(prev_id, 10) + _next_id = int(_prev_id) + 1 + next_id = f"{_next_id:0{num_digits}}" + if prev_id[0] != next_id[0]: + next_id = str(_next_id * 11) + return next_id + + +def ord_val(lex_id: str) -> int: + if len(lex_id) == 1: + return int(lex_id, 10) + return int(lex_id[1:], 10) + + +def main(): + _curr_id = "01" + print(f"{'lexical':<13} {'numerical':>12}") + + while True: + print(f"{_curr_id:<13} {ord_val(_curr_id):>12}") + _next_id = next_id(_curr_id) + + assert _curr_id < next_id + assert int(_curr_id, 10) < int(next_id, 10) + assert ord_val(_curr_id) < ord_val(next_id) + + # while next_id.startswith("0") and int(next_id) < 1000: + # _next_id = next_id(_next_id) + + if next_id.count("9") == len(next_id): + # all nines, we're done + print(f"{next_id:<13} {ord_val(next_id):>12}") + break + + if _next_id[0] != _curr_id[0] and len(_curr_id) > 1: + print(f"{_next_id:<13} {ord_val(_next_id):>12}") + _next_id = next_id(_next_id) + print(f"{_next_id:<13} {ord_val(_next_id):>12}") + _next_id = next_id(_next_id) + + print("...") + + # skip ahead + _next_id = _next_id[:1] + "9" * (len(_next_id) - 2) + "8" + + _curr_id = _next_id + + +if __name__ == '__main__': + main() diff --git a/src/pycalver/parse.py b/src/pycalver/parse.py new file mode 100644 index 0000000..8568bf2 --- /dev/null +++ b/src/pycalver/parse.py @@ -0,0 +1,94 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import re +import io +import os +import sys +import logging +import typing as typ +import datetime as dt + +from pkg_resources import parse_version + +from . import lex_id + +log = logging.getLogger("pycalver.parse") + + +VALID_RELESE_VALUES = ("alpha", "beta", "dev", "rc", "post") + + +PYCALVER_RE: typ.re.Pattern[str] = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d{4,}) + ) + (?: + \- # "-" release prefix + (?P + alpha|beta|dev|rc|post + ) + )? +)(?:\s|$) +""", flags=re.VERBOSE) + + +RE_PATTERN_PARTS = { + "pep440_version" : r"\d{6}\.\d+(a|b|dev|rc|post)?\d*", + "version" : r"v\d{6}\.\d{4,}\-(?:alpha|beta|dev|rc|post)", + "calver" : r"v\d{6}", + "build" : r"\.\d{4,}", + "release" : r"(\-(?:alpha|beta|dev|rc|post))?", +} + + +class PatternMatch(typ.NamedTuple): + + lineno : int + line : str + pattern : str + span : typ.Tuple[int, int] + match : str + + +MaybeMatch = typ.Optional[typ.re.Match[str]] +PyCalVerInfo = typ.Dict[str, str] + + +def iter_pattern_matches(lines: typ.List[str], pattern: str) -> typ.Iterable[PatternMatch]: + # The pattern is escaped, so that everything besides the format + # string variables is treated literally. + pattern_re = re.compile( + pattern + .replace("\\", "\\\\") + .replace("-", "\\-") + .replace(".", "\\.") + .replace("+", "\\+") + .replace("*", "\\*") + .replace("[", "\\[") + .replace("(", "\\(") + .format(**RE_PATTERN_PARTS) + ) + for i, line in enumerate(lines): + match = pattern_re.search(line) + if match: + lineno = i + 1 + yield PatternMatch(lineno, line, pattern, match.span(), match.group(0)) + + +def parse_patterns(lines: typ.List[str], patterns: typ.List[str]) -> typ.List[PatternMatch]: + all_matches: typ.List[PatternMatch] = [] + for pattern in patterns: + all_matches.extend(iter_pattern_matches(lines, pattern)) + return all_matches diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py new file mode 100644 index 0000000..f9649b8 --- /dev/null +++ b/src/pycalver/rewrite.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import logging +import difflib + +log = logging.getLogger("pycalver.rewrite") + + +def rewrite_file(file: str, pattern: str, dry=False) -> None: + difflib.unified_diff( + file_content_before.splitlines(), + file_content_after.splitlines(), + lineterm="", + fromfile="a/" + file, + tofile="b/" + file, + ) diff --git a/src/pycalver/vcs.py b/src/pycalver/vcs.py new file mode 100644 index 0000000..42ce2da --- /dev/null +++ b/src/pycalver/vcs.py @@ -0,0 +1,111 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT +# +# pycalver/vcs.py (this file) is based on code from the +# bumpversion project: https://github.com/peritus/bumpversion +# MIT License - (C) 2013-2014 Filip Noetzel + +import os +import logging +import tempfile +import subprocess as sp + + +log = logging.getLogger("pycalver.vcs") + + +class WorkingDirectoryIsDirtyException(Exception): + + def __init__(self, message): + self.message = message + + +class BaseVCS: + + @classmethod + def commit(cls, message): + f = tempfile.NamedTemporaryFile("wb", delete=False) + f.write(message.encode("utf-8")) + f.close() + cmd = cls._COMMIT_COMMAND + [f.name] + env_items = list(os.environ.items()) + [(b"HGENCODING", b"utf-8")] + sp.check_output(cmd, env=dict(env_items)) + os.unlink(f.name) + + @classmethod + def is_usable(cls): + try: + return sp.call( + cls._TEST_USABLE_COMMAND, + stderr=sp.PIPE, + stdout=sp.PIPE, + ) == 0 + except OSError as e: + if e.errno == 2: + # mercurial is not installed then, ok. + return False + raise + + @classmethod + def assert_nondirty(cls): + status_output = sp.check_output(cls._STATUS_COMMAND) + lines = [ + line.strip() + for line in status_output.splitlines() + if not line.strip().startswith(b"??") + ] + + if lines: + cleaned_output = b"\n".join(lines) + cls_name = cls.__name__ + raise WorkingDirectoryIsDirtyException( + f"{cls_name} working directory is not clean:\n{cleaned_output}" + ) + + +class Git(BaseVCS): + + _TEST_USABLE_COMMAND = ["git", "rev-parse", "--git-dir"] + _COMMIT_COMMAND = ["git", "commit", "-F"] + _STATUS_COMMAND = ["git", "status", "--porcelain"] + + @classmethod + def tag(cls, name): + sp.check_output(["git", "tag", name]) + + +class Mercurial(BaseVCS): + + _TEST_USABLE_COMMAND = ["hg", "root"] + _COMMIT_COMMAND = ["hg", "commit", "--logfile"] + _STATUS_COMMAND = ["hg", "status", "-mard"] + + @classmethod + def tag(cls, name): + sp.check_output(["hg", "tag", name]) + + +VCS = [Git, Mercurial] + + +def get_vcs(allow_dirty=False): + for vcs in VCS: + if not vcs.is_usable(): + continue + + if not allow_dirty: + try: + vcs.assert_nondirty() + except WorkingDirectoryIsDirtyException as e: + log.warn( + f"{e.message}\n\n" + f"Use --allow-dirty to override this if you know what you're doing." + ) + raise + + return vcs + + return None diff --git a/src/pycalver/version.py b/src/pycalver/version.py new file mode 100644 index 0000000..53e53b7 --- /dev/null +++ b/src/pycalver/version.py @@ -0,0 +1,71 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import logging +import datetime as dt + +from . import lex_id +log = logging.getLogger("pycalver.version") + + +def current_calver() -> str: + return dt.datetime.utcnow().strftime("v%Y%m") + + +def bump(old_version: str, release: str=None) -> str: + # old_version is assumed to be a valid calver string, + # validated in pycalver.config.parse. + + old_calver, rest = old_version.split(".") + old_build, old_release = rest.split("-") + + new_calver = current_calver() + new_build = lex_id.next_id(old_build) + if release is None: + # preserve existing release + new_release = old_release + else: + new_release = release + + new_version = new_calver + "." + new_build + if new_release: + new_version += "-" + new_release + return new_version + + +def incr_version(old_version: str, *, tag: str="__sentinel__") -> str: + maybe_match: MaybeMatch = VERSION_RE.search(old_version) + + if maybe_match is None: + raise ValueError(f"Invalid version string: {old_version}") + + prev_version_info: PyCalVerInfo = maybe_match.groupdict() + + prev_calver: str = prev_version_info["calver"] + next_calver: str = current_calver() + + prev_build: str = prev_version_info["build"] + if prev_calver > next_calver: + log.warning( + f"'incr_version' called with '{old_version}', " + + f"which is from the future, " + + f"maybe your system clock is out of sync." + ) + next_calver = prev_calver # leave calver as is + + next_build = lex_id.next_id(prev_build) + new_version = f"{next_calver}.{next_build}" + if tag != "__sentinel__": + if tag is None: + pass # tag explicitly ignored/removed + else: + new_version += "-" + tag + elif "tag" in prev_version_info: + # preserve previous tag + new_version += "-" + prev_version_info["tag"] + + assert old_version < new_version, f"{old_version} {new_version}" + return new_version diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 0000000..bfcea44 --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,30 @@ +import datetime as dt +import pycalver.version + + +def test_calver(): + import random + + first_version_str = "v201808.0001-dev" + padding = len(first_version_str) + 3 + version_str = first_version_str + + def _current_calver() -> str: + _current_calver.delta += dt.timedelta(days=int(random.random() * 5)) + + return (dt.datetime.utcnow() + _current_calver.delta).strftime("v%Y%m") + + _current_calver.delta = dt.timedelta(days=1) + + global current_calver + current_calver = _current_calver + + for i in range(1050): + version_str = incr_version(version_str, tag=random.choice([ + None, "alpha", "beta", "rc" + ])) + print(f"{version_str:<{padding}}", end=" ") + if (i + 1) % 8 == 0: + print() + + print()