WIP: mostly scratch code still

This commit is contained in:
Manuel Barkhau 2018-09-02 21:48:12 +02:00
parent e2e218bce9
commit 3471560eaa
20 changed files with 1757 additions and 1 deletions

66
.gitignore vendored Normal file
View file

@ -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

7
CHANGELOG.rst Normal file
View file

@ -0,0 +1,7 @@
Changelog for pycalver
======================
v201809.0001-alpha
------------------
- Initial release

View file

@ -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

160
Makefile Normal file
View file

@ -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]+%</td>' 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

546
README.rst Normal file
View file

@ -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 <https://setuptools.readthedocs.io/en/latest/setuptools.html#specifying-your-project-s-version>`_,
`PEP440 <https://www.python.org/dev/peps/pep-0440/>`_
).
.. 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<version>
(?P<calver>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?:
\. # "." build nr prefix
(?P<build>\d{4,})
)
(?:
\- # "-" release prefix
(?P<release>
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=<tag>`` 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

10
license.header Normal file
View file

@ -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/

109
scratch.py Normal file
View file

@ -0,0 +1,109 @@
from pkg_resources import parse_version
import re
canonical_version_re = re.compile(r"""
\b
(?P<version>
(?P<calver>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?:
\. # "." build nr prefix
(?P<build_nr>\d{3,})
)
)(?:\s|$)
""", flags=re.VERBOSE)
full_version_re = re.compile(r"""
\b
(?P<version>
(?P<calver>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?:
\. # "." build nr prefix
(?P<build>\d+)
)
(?:
\- # "-" tag prefix
(?P<tag>a|alpha|b|beta|dev|c|rc|pre|preview|post)
(?P<tag_nr>\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<full_version>
(?P<calver>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?:
. # "." build nr prefix
(?P<build_nr>\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")

35
setup.cfg Normal file
View file

@ -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}

80
setup.py Normal file
View file

@ -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",
],
)

11
src/pycalver/__init__.py Normal file
View file

@ -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"

146
src/pycalver/__main__.py Normal file
View file

@ -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="<name>",
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)

122
src/pycalver/config.py Normal file
View file

@ -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

137
src/pycalver/lex_id.py Normal file
View file

@ -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()

94
src/pycalver/parse.py Normal file
View file

@ -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<version>
(?P<calver>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?:
\. # "." build nr prefix
(?P<build>\d{4,})
)
(?:
\- # "-" release prefix
(?P<release>
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

21
src/pycalver/rewrite.py Normal file
View file

@ -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,
)

111
src/pycalver/vcs.py Normal file
View file

@ -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

71
src/pycalver/version.py Normal file
View file

@ -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

0
test/__init__.py Normal file
View file

0
test/test_config.py Normal file
View file

30
test/test_version.py Normal file
View file

@ -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()