Merge branch 'pycalver2' into 'master'

Pycalver2

Closes #8, #12, #2, #11, #10, #9, and #7

See merge request mbarkhau/pycalver!4
This commit is contained in:
Manuel Barkhau 2020-10-18 21:04:36 +00:00
commit 8d48b88a1c
63 changed files with 6752 additions and 3266 deletions

View file

@ -1,10 +1,6 @@
name: CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
on: [push, pull_request]
jobs:
@ -21,9 +17,9 @@ jobs:
path: |
~/miniconda3
build/*.txt
key: ${{ runner.OS }}-conda-cache-${{ hashFiles('requirements/*.txt', 'setup.py', 'makefile*') }}
key: ${{ runner.OS }}-conda-cache-${{ hashFiles('requirements/*.txt', 'setup.py', 'Makefile*') }}
restore-keys: |
${{ runner.OS }}-conda-cache-${{ hashFiles('requirements/*.txt', 'setup.py', 'makefile*') }}
${{ runner.OS }}-conda-cache-${{ hashFiles('requirements/*.txt', 'setup.py', 'Makefile*') }}
- name: make conda
run:

View file

@ -18,18 +18,17 @@ lint:
allow_failure: false
unit:
test:
# NOTE: Resource_group is conservative and can be disabled
# for simple tests. It should be enabled if the tests
# need exclusive access to some resource common external
# resource. This will prevent multiple pipelines from
# need exclusive access to some common resource. The
# resource_group will prevent multiple pipelines from
# running concurrently.
# resource_group: test-unit
stage: test
image: registry.gitlab.com/mbarkhau/pycalver/base
script:
- make test
- make test_compat
coverage: '/^(TOTAL|src).*?(\d+\%)$/'
artifacts:
reports:
@ -39,6 +38,18 @@ unit:
- reports/testcov/
allow_failure: false
test_compat:
# NOTE: Resource_group is conservative and can be disabled
# for simple tests. It should be enabled if the tests
# need exclusive access to some common resource. The
# resource_group will prevent multiple pipelines from
# running concurrently.
# resource_group: test-unit
stage: test
image: registry.gitlab.com/mbarkhau/pycalver/base
script:
- make test_compat
allow_failure: false
pages:
stage: build

View file

@ -1,70 +1,133 @@
# Changelog for https://gitlab.com/mbarkhau/pycalver
# Changelog for https://github.com/mbarkhau/pycalver
## v201907.0036
## BumpVer 2020.1100-beta
- Fix: Don't use git/hg command if `commit=False` is configured (thanks @valentin87)
Rename package and module from PyCalVer to BumpVer. This name change is due to confusion that this project is either Python specific, or only suitible for CalVer versioning schemes, neither of which is the case.
This release includes a new syntax for patterns.
```
version_pattern = "vYYYY0M.BUILD[-RELEASE]" # new style
version_pattern = "v{year}{month}{build}{release}" # old style
version_pattern = "MAJOR.MINOR.PATCH" # new style semver
version_pattern = "{MAJOR}.{MINOR}.{PATCH}" # old style semver
```
The main reasons for this switch were:
- To enable optional parts using braces `[PART]`.
- To align the syntax with the conventions used on CalVer.org
The previous syntax will continue to be supported, but all documentation has been updated to primarily reference new style patterns.
- Switch main repo from gitlab to github.
- New [gitlab#7][gitlab_i7]: New style pattern syntax.
- Better support for week numbers.
- Better support for optional parts.
- New: `BUILD` part now starts at `1000` instead of `0001` to avoid truncation of leading zeros.
- New: Add `INC0` (0-based) and `INC1` (1-based) parts that do auto increment and rollover.
- New: `MAJOR`/`MINOR`/`PATCH`/`INC` will roll over when a date part changes to their left.
- New [gitlab#2][gitlab_i2]: Added `grep` sub-command to help with debugging of patterns.
- New [gitlab#10][gitlab_i10]: `--pin-date` to keep date parts unchanged, and only increment non-date parts.
- New: Added `--date=<iso-date>` parameter to set explicit date (instead of current date).
- New: Added `--release-num` to increment the `alphaN`/`betaN`/`a0`/`b0`/etc. release number
- New: Added better error messages to debug regular expressions.
- New [gitlab#9][gitlab_i9]: Make commit message configurable.
- Fix [gitlab#12][gitlab_i12]: Error with sorting non-lexical version tags (e.g. SemVer).
- Fix [gitlab#11][gitlab_i11]: Show regexp when `--verbose` is used.
- Fix [gitlab#8][gitlab_i8]: `bumpver update` will now also push HEAD (previously only the tag itself was pushed).
- Fix: Disallow `--release=dev`. The semantics of a `dev` releases are different than for other release tags and further development would be required to support them correctly.
- Fix: Entries in `file_patterns` were ignored if there were multiple entries for the same file.
This release no longer includes the `pycalver.lexid` module, which has been moved into its own package: [pypi.org/project/lexid/](https://pypi.org/project/lexid/).
Many thanks to contributors of this release: @LucidOne, @khanguslee, @chaudum
[gitlab_i7]:https://gitlab.com/mbarkhau/pycalver/-/issues/7
[gitlab_i2]: https://gitlab.com/mbarkhau/pycalver/-/issues/2
[gitlab_i10]: https://gitlab.com/mbarkhau/pycalver/-/issues/10
[gitlab_i9]: https://gitlab.com/mbarkhau/pycalver/-/issues/9
[gitlab_i12]: https://gitlab.com/mbarkhau/pycalver/-/issues/12
[gitlab_i11]: https://gitlab.com/mbarkhau/pycalver/-/issues/11
[gitlab_i8]: https://gitlab.com/mbarkhau/pycalver/-/issues/8
## v201907.0035
## PyCalVer v202010.1042
- Fix gitlab#6: Add parts `{month_short}`, `{dom_short}`, `{doy_short}`.
- Fix gitlab#5: Better warning when using bump with semver (one of --major/--minor/--patch is required)
- Fix gitlab#4: Make {release} part optional, so that versions generated by --release=final are parsed.
- Add deprication warning to README.md
## v201903.0030
## PyCalVer v201907.0036
- Fix: Use pattern from config instead of hardcoded {pycalver} pattern.
- Fix: Better error messages for git/hg issues.
- Add: Implicit default pattern for config file.
- Fix: Don't use git/hg command if `commit=False` is configured (thanks @valentin87)
## v201903.0028
## PyCalVer v201907.0035
- Fix: Add warnings when configured files are not under version control.
- Add: Coloured output for bump --dry
- Fix [gitlab#6][gitlab_i6]: Add parts `{month_short}`, `{dom_short}`, `{doy_short}`.
- Fix [gitlab#5][gitlab_i5]: Better warning when using bump with SemVer (one of --major/--minor/--patch is required)
- Fix [gitlab#4][gitlab_i4]: Make {release} part optional, so that versions generated by --release=final are parsed.
[gitlab_i6]: https://gitlab.com/mbarkhau/pycalver/-/issues/6
[gitlab_i5]: https://gitlab.com/mbarkhau/pycalver/-/issues/5
[gitlab_i4]: https://gitlab.com/mbarkhau/pycalver/-/issues/4
## v201902.0027
## PyCalVer v201903.0030
- Fix: Allow --release=post
- Fix: Better error reporting for bad patterns
- Fix: Regex escaping issue with "?"
- Fix: Use pattern from config instead of hard-coded {pycalver} pattern.
- Fix: Better error messages for git/hg issues.
- Add: Implicit default pattern for config file.
## v201902.0024
## PyCalVer v201903.0028
- Added: Support for globs in file patterns.
- Fixed: Better error reporting for invalid config.
- Fix: Add warnings when configured files are not under version control.
- Add: Colored output for bump --dry
## v201902.0020
## PyCalVer v201902.0027
- Added: Support for many more custom version patterns.
- Fix: Allow --release=post
- Fix: Better error reporting for bad patterns
- Fix: Regex escaping issue with "?"
## v201812.0018
## PyCalVer v201902.0024
- Fixed: Better handling of pattern replacements with "-final" releases.
- Added: Support for globs in file patterns.
- Fixed: Better error reporting for invalid config.
## v201812.0017
## PyCalVer v201902.0020
- Fixed github#2. `pycalver init` was broken.
- Fixed pattern escaping issues.
- Added lots more tests for cli.
- Cleaned up documentation.
- Added: Support for many more custom version patterns.
## v201812.0011-beta
## PyCalVer v201812.0018
- Add version tags using git/hg.
- Use git/hg tags as SSOT for most recent version.
- Start using https://gitlab.com/mbarkhau/bootstrapit
- Move to https://gitlab.com/mbarkhau/pycalver
- Fixed: Better handling of pattern replacements with "-final" releases.
## v201809.0001-alpha
## PyCalVer v201812.0017
- Initial release
- Fixed [github#2]. `pycalver init` was broken.
- Fixed pattern escaping issues.
- Added lots more tests for cli.
- Cleaned up documentation.
[gihlab_i2]: https://github.com/mbarkhau/pycalver/-/issues/2
## PyCalVer v201812.0011-beta
- Add version tags using git/hg.
- Use git/hg tags as SSOT for most recent version.
- Start using https://gitlab.com/mbarkhau/bootstrapit
- Move to https://gitlab.com/mbarkhau/pycalver
## PyCalVer v201809.0001-alpha
- Initial release

View file

@ -213,9 +213,9 @@ projects by reducing the burden of project setup to a minimum.
CHANGELOG.md # short documentation of release history
LICENSE # for public libraries (MIT preferred)
makefile # project specific configuration
Makefile # project specific configuration
# variables and make targets
makefile.bootstrapit.make # bootstrapit make include library
Makefile.bootstrapit.make # bootstrapit make include library
docker_base.Dockerfile # base image for CI (only conda envs)
Dockerfile # image with source of the project

View file

@ -10,8 +10,8 @@ ADD pylint-ignore.md pylint-ignore.md
ADD README.md README.md
ADD CHANGELOG.md CHANGELOG.md
ADD LICENSE LICENSE
ADD makefile makefile
ADD makefile.bootstrapit.make makefile.bootstrapit.make
ADD Makefile Makefile
ADD Makefile.bootstrapit.make Makefile.bootstrapit.make
ADD scripts/exit_0_if_empty.py scripts/exit_0_if_empty.py
ENV PYTHONPATH="src/:vendor/"

View file

@ -1,4 +1,4 @@
MIT License Copyright (c) 2020 Manuel Barkhau (mbarkhau@gmail.com)
MIT License Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -21,7 +21,7 @@ DEVELOPMENT_PYTHON_VERSION := python=3.8
SUPPORTED_PYTHON_VERSIONS := python=2.7 python=3.5 python=3.6 python=3.8 pypy2.7 pypy3.5
include makefile.bootstrapit.make
include Makefile.bootstrapit.make
## -- Extra/Custom/Project Specific Tasks --
@ -61,3 +61,19 @@ test_compat: $(COMPAT_TEST_FILES)
ENABLE_BACKTRACE=0 PYTHONPATH="" ENV=$${ENV-dev} \
$${env_py} -m pytest --verbose compat_test/; \
done;
rm -rf compat_test/
pycalver_deps.svg:
pydeps src/pycalver \
--no-show --noise-level 3 \
--reverse --include-missing \
-x 'click.*' 'toml.*' 'pretty_traceback.*' \
-o pycalver_deps.svg
## Update cli reference in README.md
README.md: src/pycalver2/cli.py scripts/update_readme_examples.py Makefile
@git add README.md
@$(DEV_ENV)/bin/python scripts/update_readme_examples.py

View file

@ -57,8 +57,7 @@ CONDA_ENV_BIN_PYTHON_PATHS := \
empty :=
literal_space := $(empty) $(empty)
BDIST_WHEEL_PYTHON_TAG := \
$(subst python,py,$(subst $(literal_space),.,$(subst .,,$(subst =,,$(SUPPORTED_PYTHON_VERSIONS)))))
BDIST_WHEEL_PYTHON_TAG := py2.py3
SDIST_FILE_CMD = ls -1t dist/*.tar.gz | head -n 1
@ -182,7 +181,7 @@ help:
helpMessage = ""; \
} \
}' \
makefile.bootstrapit.make makefile
Makefile.bootstrapit.make Makefile
@if [[ ! -f $(DEV_ENV_PY) ]]; then \
echo "Missing python interpreter at $(DEV_ENV_PY) !"; \
@ -236,7 +235,7 @@ helpverbose:
helpMessage = ""; \
} \
}' \
makefile.bootstrapit.make makefile
Makefile.bootstrapit.make Makefile
## -- Project Setup --
@ -301,7 +300,6 @@ git_hooks:
lint_isort:
@printf "isort ...\n"
@$(DEV_ENV)/bin/isort \
--recursive \
--check-only \
--line-width=$(MAX_LINE_LEN) \
--project $(MODULE_NAME) \
@ -336,6 +334,15 @@ lint_flake8:
@printf "\e[1F\e[9C ok\n"
## Run pylint --errors-only.
.PHONY: lint_pylint_errors
lint_pylint_errors:
@printf "pylint ..\n";
@$(DEV_ENV)/bin/pylint --errors-only --jobs=4 --rcfile=setup.cfg \
src/ test/
@printf "\e[1F\e[9C ok\n"
## Run pylint.
.PHONY: lint_pylint
lint_pylint:
@ -395,7 +402,7 @@ test:
--cov-report term \
--html=reports/pytest/index.html \
--junitxml reports/pytest.xml \
-k "$${PYTEST_FILTER}" \
-k "$${PYTEST_FILTER-$${FLTR}}" \
$(shell cd src/ && ls -1 */__init__.py | awk '{ sub(/\/__init__.py/, "", $$1); print "--cov "$$1 }') \
test/ src/;
@ -417,7 +424,6 @@ test:
.PHONY: fmt_isort
fmt_isort:
@$(DEV_ENV)/bin/isort \
--recursive \
--line-width=$(MAX_LINE_LEN) \
--project $(MODULE_NAME) \
src/ test/;
@ -515,7 +521,7 @@ devtest:
--capture=no \
--exitfirst \
--failed-first \
-k "$${PYTEST_FILTER}" \
-k "$${PYTEST_FILTER-$${FLTR}}" \
test/ src/;
@rm -rf "src/__pycache__";
@ -552,14 +558,16 @@ freeze:
## Bump Version number in all files
.PHONY: bump_version
bump_version:
$(DEV_ENV)/bin/pycalver bump;
$(DEV_ENV)/bin/bumpver update;
## Create python sdist and bdist_wheel files
.PHONY: dist_build
dist_build:
@rm -rf build/lib3to6_out/
@rm -rf build/bdist*
$(DEV_ENV_PY) setup.py sdist;
$(DEV_ENV_PY) setup.py bdist_wheel --python-tag=py2.py3;
$(DEV_ENV_PY) setup.py bdist_wheel --python-tag=$(BDIST_WHEEL_PYTHON_TAG);
@rm -rf src/*.egg-info

1755
README.md

File diff suppressed because it is too large Load diff

View file

@ -4,19 +4,22 @@
AUTHOR_NAME="Manuel Barkhau"
AUTHOR_EMAIL="mbarkhau@gmail.com"
KEYWORDS="version versioning bumpversion calver"
DESCRIPTION="CalVer for python packages."
KEYWORDS="version bumpver calver semver versioning bumpversion pep440"
DESCRIPTION="Bump version numbers in project files."
LICENSE_ID="MIT"
PACKAGE_NAME="pycalver"
PACKAGE_NAME="bumpver"
GIT_REPO_NAMESPACE="mbarkhau"
GIT_REPO_DOMAIN="gitlab.com"
GIT_REPO_DOMAIN="github.com"
PACKAGE_VERSION="v202007.0036"
PACKAGE_VERSION="2020.1041-beta"
DEFAULT_PYTHON_VERSION="python=3.8"
SUPPORTED_PYTHON_VERSIONS="python=2.7 python=3.6 pypy2.7 pypy3.5 python=3.8"
DOCKER_REGISTRY_DOMAIN=registry.gitlab.com
DEFAULT_PYTHON_VERSION="python=3.6"
SUPPORTED_PYTHON_VERSIONS="python=2.7 python=3.5 python=3.6 python=3.7 pypy2.7 pypy3.5"
IS_PUBLIC=1

View file

@ -1,7 +1,7 @@
# Stages:
# root : Common image, both for the builder and for the final image.
# This contains only minimal dependencies required in both cases
# for miniconda and the makefile.
# for miniconda and the Makefile.
# env_builder: stage in which the conda envrionment is created
# and dependencies are installed
# base : the final image containing only the required environment files,
@ -37,8 +37,8 @@ RUN if ! test -z "${ENV_SSH_PRIVATE_RSA_KEY}"; then \
ADD requirements/ requirements/
ADD scripts/ scripts/
ADD makefile.bootstrapit.make makefile.bootstrapit.make
ADD makefile makefile
ADD Makefile.bootstrapit.make Makefile.bootstrapit.make
ADD Makefile Makefile
# install envs (relatively stable)
ADD requirements/conda.txt requirements/conda.txt

View file

@ -1,9 +1,9 @@
Individual files contain the following tag instead of the full license text.
This file is part of the pycalver project
https://gitlab.com/mbarkhau/pycalver
https://github.com/mbarkhau/pycalver
Copyright (c) 2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
SPDX-License-Identifier: MIT
This enables machine processing of license information based on the SPDX

428
pycalver1k.svg Normal file
View file

@ -0,0 +1,428 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="128"
height="128"
viewBox="0 0 33.866666 33.866668"
version="1.1"
id="svg8"
inkscape:version="1.0 (b51213c273, 2020-08-10)"
sodipodi:docname="pycalver1k.svg"
inkscape:export-filename="/home/mbarkhau/foss/pycalver/pycalver1k2_128.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient1163">
<stop
style="stop-color:#e89a00;stop-opacity:1"
offset="0"
id="stop1159" />
<stop
style="stop-color:#ffd42a;stop-opacity:1"
offset="1"
id="stop1161" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1163"
id="linearGradient1165"
x1="17.506153"
y1="278.55835"
x2="17.63979"
y2="282.83472"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.94649427,0,0,1.0042072,0.90603086,-0.95465177)" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath1193">
<g
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="use1195"
inkscape:label="Clip">
<g
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="g1278"
transform="translate(0,1.1994176)">
<circle
cy="270.14941"
cx="23.779428"
id="circle1274"
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
r="2.3812499" />
<rect
y="264.03714"
x="22.339876"
height="6.5171237"
width="2.8791037"
id="rect1276"
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
<g
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="g1284"
transform="translate(0,1.5112466)">
<circle
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="circle1280"
cx="10.087241"
cy="269.573"
r="2.3812499" />
<rect
style="fill:#45b848;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect1282"
width="2.8791037"
height="6.5171237"
x="8.6476898"
y="263.9899" />
</g>
</g>
</clipPath>
<mask
maskUnits="userSpaceOnUse"
id="mask1425">
<g
id="g1441"
transform="translate(-1.5416735,-2.3386165)">
<rect
y="267.51743"
x="-0.8018086"
height="9.5214758"
width="38.553631"
id="rect1427"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#aa8800;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers" />
<g
transform="translate(1.541672,3.8452511)"
id="g1433"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<circle
r="2.3812499"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="circle1429"
cx="23.779428"
cy="270.14941" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect1431"
width="2.8791037"
height="6.5171237"
x="22.339876"
y="264.03714" />
</g>
<g
transform="translate(1.541672,4.1570801)"
id="g1439"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<circle
r="2.3812499"
cy="269.573"
cx="10.087241"
id="circle1435"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
<rect
y="263.9899"
x="8.6476898"
height="6.5171237"
width="2.8791037"
id="rect1437"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
</g>
</mask>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1163"
id="linearGradient1165-3"
x1="17.605284"
y1="269.99991"
x2="17.63979"
y2="282.83472"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.0541065,0,0,0.87055183,-0.916204,-223.64659)" />
<mask
maskUnits="userSpaceOnUse"
id="mask1129">
<g
id="g1145"
transform="translate(-1.5416733,-2.3386129)">
<rect
y="267.51743"
x="-0.8018086"
height="9.5214758"
width="38.553631"
id="rect1131"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#aa8800;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers" />
<g
transform="translate(0.07525963,4.2889947)"
id="g1137"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<ellipse
cy="268.73132"
cx="10.111843"
id="ellipse1133"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
rx="2.2523122"
ry="2.629046" />
<rect
y="263.9899"
x="8.7405252"
height="6.5171237"
width="2.7426364"
id="rect1135"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
<g
transform="translate(16.669414,4.2889947)"
id="g1143"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<ellipse
cy="268.73132"
cx="10.111843"
id="ellipse1139"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
rx="2.2523122"
ry="2.629046" />
<rect
y="263.9899"
x="8.7405252"
height="6.5171237"
width="2.7426364"
id="rect1141"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
</g>
</mask>
<mask
maskUnits="userSpaceOnUse"
id="mask1129-3">
<g
id="g1145-6"
transform="translate(-1.5416733,-2.3386129)">
<rect
y="267.51743"
x="-0.8018086"
height="9.5214758"
width="38.553631"
id="rect1131-7"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#aa8800;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers" />
<g
transform="translate(0.07525963,4.2889947)"
id="g1137-5"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<ellipse
cy="268.73132"
cx="10.111843"
id="ellipse1133-3"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
rx="2.2523122"
ry="2.629046" />
<rect
y="263.9899"
x="8.7405252"
height="6.5171237"
width="2.7426364"
id="rect1135-5"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
<g
transform="translate(16.669414,4.2889947)"
id="g1143-6"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<ellipse
cy="268.73132"
cx="10.111843"
id="ellipse1139-2"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
rx="2.2523122"
ry="2.629046" />
<rect
y="263.9899"
x="8.7405252"
height="6.5171237"
width="2.7426364"
id="rect1141-9"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" />
</g>
</g>
</mask>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="8"
inkscape:cx="61.591322"
inkscape:cy="64.987937"
inkscape:document-units="mm"
inkscape:current-layer="layer8"
showgrid="false"
units="px"
inkscape:window-width="2512"
inkscape:window-height="1376"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:document-rotation="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="debug_bg"
style="display:none"
sodipodi:insensitive="true">
<rect
style="fill:#ff00ff;fill-opacity:1;stroke:none;stroke-width:1.92171;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect954"
width="33.848568"
height="33.868378"
x="0.044362877"
y="0.0074945446" />
</g>
<g
inkscape:groupmode="layer"
id="layer6"
inkscape:label="bg"
style="display:inline">
<path
id="rect880"
style="display:inline;fill:#ffffff;stroke-width:7.16557"
d="m 1.585409,3.7099638 30.618783,-0.052171 0.725957,0.7967657 -0.0046,27.3188725 -0.528162,0.557572 L 1.1974464,32.32312 0.94904837,32.088188 0.93651797,4.3514519 Z"
sodipodi:nodetypes="ccccccccc" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="gradient"
style="display:inline">
<rect
style="display:inline;opacity:1;fill:url(#linearGradient1165-3);fill-opacity:1;stroke:#000000;stroke-width:1.21634;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect815"
width="29.469652"
height="13.742671"
x="2.1985073"
y="17.262745" />
</g>
<g
inkscape:label="text bottom"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-263.13332)"
style="display:inline">
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:14.8167px;line-height:0.85;font-family:Monoid;-inkscape-font-specification:Monoid;letter-spacing:-0.111125px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
x="2.2388713"
y="292.59897"
id="text859"><tspan
sodipodi:role="line"
x="2.2388713"
y="292.59897"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14.8167px;font-family:'Iosevka Term SS05';-inkscape-font-specification:'Iosevka Term SS05 Bold';stroke-width:0.264583"
id="tspan953">2020</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer9"
inkscape:label="frame top"
style="display:inline">
<rect
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect815-0"
width="30.546177"
height="13.588312"
x="1.6602445"
y="-15.922193"
transform="scale(1,-1)" />
</g>
<g
inkscape:groupmode="layer"
id="layer8"
inkscape:label="text top"
style="display:inline">
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:16.9333px;line-height:1.25;font-family:sans-serif;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.265;stroke-miterlimit:4;stroke-dasharray:none"
x="4.7286582"
y="13.507061"
id="text921"><tspan
sodipodi:role="line"
id="tspan919"
x="4.7286582"
y="13.507061"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:16.9333px;font-family:'Iosevka Fixed SS05';-inkscape-font-specification:'Iosevka Fixed SS05 Medium';fill:#ffffff;stroke-width:0.265;stroke-miterlimit:4;stroke-dasharray:none">ver</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="frame"
style="display:inline">
<rect
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#aa8800;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect1398"
width="30.684868"
height="0.76833469"
x="1.5910856"
y="16.622082" />
</g>
<g
inkscape:groupmode="layer"
id="layer7"
inkscape:label="top frame"
sodipodi:insensitive="true"
style="display:none">
<rect
style="display:inline;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:2.32913;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="rect900"
width="26.775116"
height="6.1376147"
x="3.5457754"
y="268.57437"
clip-path="none"
mask="url(#mask1129-3)"
transform="matrix(1.0545213,0,0,0.89332824,-0.92322741,-236.38373)" />
</g>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="pegs"
style="display:none"
sodipodi:insensitive="true">
<rect
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#aa8800;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect1400"
width="1.5874993"
height="5.499999"
x="7.3927369"
y="1.7978847" />
<rect
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#aa8800;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect1400-6"
width="1.5874993"
height="5.499999"
x="24.886431"
y="1.7978847" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

BIN
pycalver1k2_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
pycalver1k_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View file

@ -1,204 +1,22 @@
# `pylint-ignore`
# Pylint-Ignore
**WARNING: This file is programatically generated.**
This file is parsed by `pylint-ignore` to determine which `pylint`
messages should be ignored.
This file is parsed by [`pylint-ignore`](https://pypi.org/project/pylint-ignore/)
to determine which
[Pylint messages](https://pylint.pycqa.org/en/stable/technical_reference/features.html)
should be ignored.
- Do not edit this file manually.
- To update, use `pylint-ignore --update-ignorefile`
The recommended approach to using `pylint-ignore` is:
- If a message refers to a valid issue, update your code rather than
1. If a message refers to a valid issue, update your code rather than
ignoring the message.
- If a message should *always* be ignored (globally), then to do so
2. If a message should *always* be ignored (globally), then to do so
via the usual `pylintrc` or `setup.cfg` files rather than this
`pylint-ignore.md` file.
- If a message is a false positive, add a comment of this form to your code:
`# pylint:disable=<symbol> ; explanation why this is a false positive`
## File test/test_version.py - Line 145 - E1101 (no-member)
- `message: Class 'VersionInfo' has no '_fields' member`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T19:06:44`
```
134: def test_part_field_mapping():
...
143:
144: a_fields = set(version.PATTERN_PART_FIELDS.values())
> 145: b_fields = set(version.VersionInfo._fields)
146:
147: assert a_fields == b_fields
```
## File src/pycalver/rewrite.py - Line 168 - E1101 (no-member)
- `message: Instance of 'RewrittenFileData' has no '_replace' member`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
138: def iter_rewritten(
...
166:
167: rfd = rfd_from_content(pattern_strs, new_vinfo, content)
> 168: yield rfd._replace(path=str(file_path))
169:
170:
```
## File src/pycalver/rewrite.py - Line 217 - E1101 (no-member)
- `message: Instance of 'RewrittenFileData' has no '_replace' member`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
189: def diff(new_vinfo: version.VersionInfo, file_patterns: config.PatternsByGlob) -> str:
...
215: raise NoPatternMatch(errmsg)
216:
> 217: rfd = rfd._replace(path=str(file_path))
218: lines = diff_lines(rfd)
219: if len(lines) == 0:
```
## File src/pycalver/version.py - Line 235 - E1101 (no-member)
- `message: Class 'CalendarInfo' has no '_fields' member`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
221: def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool:
...
233: False
234: """
> 235: for field in CalendarInfo._fields:
236: maybe_val: typ.Any = getattr(nfo, field, None)
237: if isinstance(maybe_val, int):
```
## File src/pycalver/version.py - Line 481 - E1101 (no-member)
- `message: Instance of 'CalendarInfo' has no '_asdict' member`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
454: def incr(
...
479:
480: if old_date <= cur_date:
> 481: cur_vinfo = cur_vinfo._replace(**cur_cal_nfo._asdict())
482: else:
483: logger.warning(f"Version appears to be from the future '{old_version}'")
```
## File src/pycalver/version.py - Line 481 - E1101 (no-member)
- `message: Instance of 'VersionInfo' has no '_replace' member`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
454: def incr(
...
479:
480: if old_date <= cur_date:
> 481: cur_vinfo = cur_vinfo._replace(**cur_cal_nfo._asdict())
482: else:
483: logger.warning(f"Version appears to be from the future '{old_version}'")
```
## File test/util.py - Line 10 - R0903 (too-few-public-methods)
- `message: Too few public methods (1/2)`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T19:06:44`
```
8:
9:
> 10: class Shell:
11: def __init__(self, cwd):
12: self.cwd = cwd
```
## File src/pycalver/vcs.py - Line 75 - W0511 (fixme)
- `message: TODO (mb 2018-11-15): Detect encoding of output?`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
65: def __call__(self, cmd_name: str, env: Env = None, **kwargs: str) -> str:
...
73: output_data: bytes = sp.check_output(cmd_str.split(), env=env, stderr=sp.STDOUT)
74:
> 75: # TODO (mb 2018-11-15): Detect encoding of output?
76: _encoding = "utf-8"
77: return output_data.decode(_encoding)
```
## File src/pycalver/cli.py - Line 78 - W0603 (global-statement)
- `message: Using the global statement`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
76: def cli(verbose: int = 0) -> None:
77: """Automatically update PyCalVer version strings on python projects."""
> 78: global _VERBOSE
79: _VERBOSE = verbose
80:
```
## File src/pycalver/vcs.py - Line 104 - W0703 (broad-except)
- `message: Catching too general exception Exception`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
98: def has_remote(self) -> bool:
...
102: return False
103: return True
> 104: except Exception:
105: return False
106:
```
## File src/pycalver/cli.py - Line 292 - W0703 (broad-except)
- `message: Catching too general exception Exception`
- `author : Manuel Barkhau <mbarkhau@gmail.com>`
- `date : 2020-07-19T18:50:33`
```
289: def _try_print_diff(cfg: config.Config, new_version: str) -> None:
...
290: try:
291: _print_diff(cfg, new_version)
> 292: except Exception as ex:
293: logger.error(str(ex))
294: sys.exit(1)
```
3. If a message is a false positive, add a comment of this form to your code:
`# pylint:disable=<symbol> ; explain why this is a false positive`

View file

@ -46,3 +46,6 @@ graphviz
# run failed tests first
pytest-cache
# to update the readme examples
rich

View file

@ -18,17 +18,17 @@ flake8-docstrings
flake8-builtins
flake8-comprehensions
flake8-junit-report
flake8-2020
pylint-ignore>=2020.1013
mypy
# pylint doesn't support isort>=5 for now
# https://github.com/PyCQA/pylint/issues/3722
isort<5
mypy>=0.790
isort
# http://doc.pytest.org/en/latest/py27-py34-deprecation.html
# The pytest 4.6 series will be the last to support Python 2.7
# and 3.4, and is scheduled to be released by mid-2019.
# pytest 5.0 and onwards will support only Python 3.5+.
pytest<5.0
pytest; python_version >= "3.5"
pytest<5.0; python_version < "3.5"
pytest-cov
# https://github.com/pytest-dev/pytest-html/blob/master/CHANGES.rst
# pytest-html 2.0+ doesn't support python2.7

View file

@ -11,4 +11,9 @@ pathlib2
typing; python_version < "3.5"
click
toml
six
lexid
colorama>=0.4
# needed pkg_resources.parse_version
setuptools<46.0.0; python_version < "3.5"
setuptools>=46.0.0; python_version >= "3.5"

View file

@ -1,3 +0,0 @@
import sys
print(sys.version)

View file

@ -75,6 +75,20 @@ if [[ -f "makefile.extra.make" && -f "makefile.config.make" ]]; then
exit 1
fi
# One time update of makefile capitalization
if [[ -f "makefile" && -f "makefile.bootstrapit.make" ]]; then
printf "Change capitalization of makefile -> Makefile # because too many rustled jimmies\n\n"
printf " mv makefile Makefile\n"
printf " mv makefile.bootstrapit.make Makefile.bootstrapit.make\n"
sed -i 's/include makefile.bootstrapit.make/include Makefile.bootstrapit.make/g' makefile
git add makefile
git mv makefile Makefile;
git mv makefile.bootstrapit.make Makefile.bootstrapit.make;
printf "Please commit the renamed files and run bootstrapit_update.sh again."
exit 1
fi
# Argument parsing from
# https://stackoverflow.com/a/14203146/62997
UPDATE_ALL=0
@ -346,9 +360,7 @@ elif [[ -z "${IGNORE_IF_EXISTS[*]}" ]]; then
"CHANGELOG.md"
"README.md"
"setup.py"
"makefile"
"requirements/pypi.txt"
"requirements/development.txt"
"requirements/conda.txt"
"requirements/vendor.txt"
"src/${MODULE_NAME}/__init__.py"
@ -398,8 +410,8 @@ copy_template MANIFEST.in;
copy_template setup.py;
copy_template setup.cfg;
copy_template makefile;
copy_template makefile.bootstrapit.make;
copy_template Makefile;
copy_template Makefile.bootstrapit.make;
copy_template activate;
copy_template docker_base.Dockerfile;
copy_template Dockerfile;

View file

@ -0,0 +1,199 @@
import io
import sys
import shlex
import random
import difflib
import datetime as dt
import subprocess as sp
import pkg_resources
import rich
import rich.box
import rich.table
from bumpcalver import v2version
def update(content, marker, value):
begin_marker = f"<!-- BEGIN {marker} -->"
end_marker = f"<!-- END {marker} -->"
prefix, rest = content.split(begin_marker)
_ , suffix = rest.split(end_marker)
return prefix + begin_marker + value + end_marker + suffix
def _color_line(line):
if line.startswith("+++") or line.startswith("---"):
return line
elif line.startswith("+"):
return "\u001b[32m" + line + "\u001b[0m"
elif line.startswith("-"):
return "\u001b[31m" + line + "\u001b[0m"
elif line.startswith("@"):
return "\u001b[36m" + line + "\u001b[0m"
else:
return line
def print_diff(old_content, new_content):
diff_lines = difflib.unified_diff(
a=old_content.splitlines(),
b=new_content.splitlines(),
lineterm="",
)
for line in diff_lines:
print(_color_line(line))
def update_md_code_output(content, command):
output_data = sp.check_output(shlex.split(command))
output = output_data.decode("utf-8")
replacement = "\n\n```\n" + "$ " + command + "\n" + output + "```\n\n"
return update(content, command, replacement)
def weeknum_example():
base_date = dt.date(2020, 12, 26)
rows = []
for i in range(10):
d = base_date + dt.timedelta(days=i)
row = d.strftime("%Y-%m-%d (%a): %Y %W %U %G %V")
rows.append(row)
content = "\n".join([" YYYY WW UU GGGG VV"] + rows)
return "\n\n```\n" + content + "\n```\n\n"
def pattern_examples():
patterns = [
("MAJOR.MINOR.PATCH[PYTAGNUM]" , ""),
("MAJOR.MINOR[.PATCH[PYTAGNUM]]", ""),
("YYYY.BUILD[PYTAGNUM]" , ""),
("YYYY.BUILD[-TAG]" , ""),
("YYYY.INC0[PYTAGNUM]" , ""),
("YYYY0M.PATCH[-TAG]" , "¹"),
("YYYY0M.BUILD[-TAG]" , ""),
("YYYY.0M" , ""),
("YYYY.MM" , ""),
("YYYY.WW" , ""),
("YYYY.MM.PATCH[PYTAGNUM]" , ""),
("YYYY.0M.PATCH[PYTAGNUM]" , "¹"),
("YYYY.MM.INC0" , ""),
("YYYY.MM.DD" , ""),
("YYYY.0M.0D" , ""),
("YY.0M.PATCH" , "²"),
]
rand = random.Random(0)
field_values = [
{
'year_y': rand.randrange(2020, 2023),
'month' : rand.randrange( 1, 12),
'dom' : rand.randrange( 1, 28),
'major' : rand.randrange( 0, 1),
'minor' : rand.randrange( 0, 20),
'patch' : rand.randrange( 0, 20),
'inc0' : rand.randrange( 0, 20),
'bid' : rand.randrange(1000, 1500),
'tag' : rand.choice(["final", "beta"]),
}
for _ in range(100)
]
rows = []
for raw_pattern, lexico_caveat in patterns:
sort_keys = ['year_y']
if "0M" in raw_pattern or "MM" in raw_pattern:
sort_keys.append('month')
if "0D" in raw_pattern or "DD" in raw_pattern:
sort_keys.append('dom')
if "PATCH" in raw_pattern:
sort_keys.append('patch')
if "INC0" in raw_pattern:
sort_keys.append('inc0')
if "BUILD" in raw_pattern:
sort_keys.append('bid')
if "PYTAG" in raw_pattern:
sort_keys.append('tag')
field_values.sort(key=lambda fv: tuple(fv[k] for k in sort_keys))
field_values[-1]['year_y'] = 2101
example_versions = []
notag_versions = []
pep440_versions = []
for fvals in field_values:
vinfo = v2version.parse_field_values_to_vinfo(fvals)
example_version = v2version.format_version(vinfo, raw_pattern)
example_versions.append(example_version)
pep440_version = str(pkg_resources.parse_version(example_version))
pep440_versions.append(pep440_version)
notag_fvals = fvals.copy()
notag_fvals['tag'] = 'final'
notag_vinfo = v2version.parse_field_values_to_vinfo(notag_fvals)
notag_version = v2version.format_version(notag_vinfo, raw_pattern)
notag_versions.append(notag_version)
sample = rand.sample(sorted(example_versions, key=len, reverse=True)[:-1], 2)
sample.sort(key=pkg_resources.parse_version)
is_pep440 = pep440_versions == example_versions
is_lexico = sorted(notag_versions) == notag_versions
pattern_col = f"`{raw_pattern}`"
pep440_col = "yes" if is_pep440 else "no"
lexico_col = ("yes" if is_lexico else "no") + lexico_caveat
sample_str = " ".join([v.ljust(16) for v in sample]).strip()
examples_col = "`" + sample_str + "`"
# row = (pattern_col, examples_col, pep440_col)
# sort_key = (is_pep440 , -len(raw_pattern))
row = (pattern_col, examples_col, pep440_col, lexico_col)
sort_key = (is_pep440 , is_lexico , -len(raw_pattern))
rows.append((sort_key, row))
# rows.sort(reverse=True)
patterns_table = rich.table.Table(show_header=True, box=rich.box.ASCII)
patterns_table.add_column("pattern")
patterns_table.add_column("examples")
patterns_table.add_column("PEP440")
patterns_table.add_column("lexico.")
for _, row in rows:
patterns_table.add_row(*row)
buf = io.StringIO()
rich.print(patterns_table, file=buf)
table_str = buf.getvalue()
table_str = "\n".join(table_str.splitlines()[1:-1])
table_str = table_str.replace("-+-", "-|-")
return "\n\n" + table_str + "\n\n"
old_content = io.open("README.md").read()
new_content = old_content
new_content = update_md_code_output(new_content, "bumpver --help")
new_content = update_md_code_output(new_content, "bumpver update --help")
new_content = update(new_content, "pattern_examples", pattern_examples())
new_content = update(new_content, "weeknum_example" , weeknum_example())
if old_content == new_content:
print("Nothing changed")
elif "--dry" in sys.argv:
print_diff(old_content, new_content)
else:
with io.open("README.md", mode="w") as fobj:
fobj.write(new_content)

View file

@ -19,14 +19,15 @@ warn_redundant_casts = True
[tool:isort]
known_third_party = click,pathlib2
known_first_party = bumpver
known_third_party = click,pathlib2,lexid,pkg_resources
force_single_line = True
length_sort = True
[flake8]
max-line-length = 100
max-complexity = 10
max-complexity = 12
ignore =
# Missing trailing comma (handled by sjfmt)
C812
@ -74,7 +75,7 @@ ignore =
D400
# First line should be in imperative mood
D401
select = A,AAA,D,C,E,F,W,H,B,D212,D404,D405,D406,B901,B950
select = A,AAA,D,C,E,F,W,H,B,D212,D404,D405,D406,B901,B950,YTT
exclude =
.git
__pycache__
@ -82,37 +83,40 @@ exclude =
dist/
.mypy_cache
# Hopefully this can be resolved, so D404, D405 start working
# https://github.com/PyCQA/pydocstyle/pull/188
[tool:pytest]
addopts = --doctest-modules
[pycalver]
current_version = v202007.1036
version_pattern = "{pycalver}"
[bumpver]
current_version = "2020.1099-beta"
version_pattern = "YYYY.BUILD[-TAG]"
commit_message = "bump {old_version} -> {new_version}"
commit = True
tag = True
push = True
[pycalver:file_patterns]
[bumpver:file_patterns]
bootstrapit.sh =
PACKAGE_VERSION="{pycalver}"
PACKAGE_VERSION="{version}"
setup.cfg =
current_version = {pycalver}
current_version = "{version}"
setup.py =
version="{pep440_pycalver}"
src/pycalver/__init__.py =
__version__ = "{pycalver}"
src/pycalver/cli.py =
click.version_option(version="{pycalver}")
version="{pep440_version}",
src/bumpver/__init__.py =
__version__ = "{version}"
src/bumpver/cli.py =
@click.version_option(version="{version}")
src/bumpver/*.py =
Copyright (c) 2018-YYYY
LICENSE =
Copyright (c) 2018-YYYY
license.header =
Copyright (c) 2018-YYYY
README.md =
[PyCalVer {version}]
img.shields.io/static/v1.svg?label=PyCalVer&message={version}&color=blue
Successfully installed pycalver-{pep440_version}
pycalver, version {version}
\[CalVer {version}\]
img.shields.io/static/v1.svg?label=CalVer&message={version}&color=blue
Successfully installed bumpver-{pep440_version}
[tool:pylint]
@ -130,7 +134,10 @@ output-format = colorized
max-locals = 20
# Maximum number of arguments for function / method
max-args = 8
max-args = 12
# Maximum number of branch for function / method body
max-branches = 14
good-names = logger,i,ex
@ -146,6 +153,8 @@ ignore-comments=yes
ignore-docstrings=yes
ignore-imports=yes
ignored-argument-names=args|kwargs
# https://pylint.pycqa.org/en/stable/technical_reference/features.html
disable =
bad-continuation,
@ -161,3 +170,9 @@ disable =
missing-class-docstring,
missing-function-docstring,
raise-missing-from,
duplicate-code,
ungrouped-imports,
generated-members =
# members of typing.NamedTuple
"(_replace|_asdict|_fields)",

View file

@ -1,7 +1,7 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import os
@ -26,48 +26,11 @@ install_requires = [
]
package_dir = {"": "src"}
if any(arg.startswith("bdist") for arg in sys.argv):
try:
import lib3to6
package_dir = lib3to6.fix(package_dir)
except ImportError:
if sys.version_info < (3, 6):
raise
else:
sys.stderr.write((
"WARNING: Creating non-universal bdist of pycalver, "
"this should only be used for development.\n"
))
long_description = "\n\n".join((read("README.md"), read("CHANGELOG.md")))
setuptools.setup(
name="pycalver",
license="MIT",
author="Manuel Barkhau",
author_email="mbarkhau@gmail.com",
url="https://gitlab.com/mbarkhau/pycalver",
version="202007.36",
keywords="version versioning bumpversion calver",
description="CalVer for python libraries.",
long_description=long_description,
long_description_content_type="text/markdown",
packages=['pycalver'],
package_dir=package_dir,
install_requires=install_requires,
entry_points="""
[console_scripts]
pycalver=pycalver.cli:cli
""",
zip_safe=True,
# See https://pypi.python.org/pypi?%3Aaction=list_classifiers
classifiers=[
# See https://pypi.python.org/pypi?%3Aaction=list_classifiers
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Environment :: Other Environment",
@ -84,5 +47,35 @@ setuptools.setup(
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
],
]
package_dir = {"": "src"}
if any(arg.startswith("bdist") for arg in sys.argv):
import lib3to6
package_dir = lib3to6.fix(package_dir)
setuptools.setup(
name="bumpver",
license="MIT",
author="Manuel Barkhau",
author_email="mbarkhau@gmail.com",
url="https://github.com/mbarkhau/bumpver",
version="2020.1041b0",
keywords="version bumpver calver semver versioning bumpversion pep440",
description="Bump version numbers in project files.",
long_description=long_description,
long_description_content_type="text/markdown",
packages=setuptools.find_packages("src/"),
package_dir=package_dir,
install_requires=install_requires,
entry_points="""
[console_scripts]
bumpver=bumpver.cli:cli
""",
python_requires=">=2.7",
zip_safe=True,
classifiers=classifiers,
)

8
src/bumpver/__init__.py Normal file
View file

@ -0,0 +1,8 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""BumpVer: A CLI program for versioning."""
__version__ = "2020.1041-beta"

15
src/bumpver/__main__.py Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env python
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""
__main__ module for BumpVer.
Enables use as module: $ python -m bumpver --version
"""
from . import cli
if __name__ == '__main__':
cli.cli()

723
src/bumpver/cli.py Executable file
View file

@ -0,0 +1,723 @@
#!/usr/bin/env python
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""cli module for BumpVer."""
import io
import sys
import typing as typ
import logging
import datetime as dt
import subprocess as sp
import click
import colorama
import pkg_resources
from . import vcs
from . import config
from . import rewrite
from . import version
from . import patterns
from . import regexfmt
from . import v1rewrite
from . import v1version
from . import v2rewrite
from . import v2version
from . import v1patterns
from . import v2patterns
try:
import pretty_traceback
pretty_traceback.install()
except ImportError:
pass # no need to fail because of missing dev dependency
click.disable_unicode_literals_warning = True
logger = logging.getLogger("bumpver.cli")
_VERBOSE = 0
def _configure_logging(verbose: int = 0) -> None:
# pylint:disable=global-statement; global flag is global.
global _VERBOSE
_VERBOSE = verbose
if verbose >= 2:
log_format = "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-17s - %(message)s"
log_level = logging.DEBUG
elif verbose == 1:
log_format = "%(levelname)-7s - %(message)s"
log_level = logging.INFO
else:
log_format = "%(levelname)-7s - %(message)s"
log_level = logging.INFO
logging.basicConfig(level=log_level, format=log_format, datefmt="%Y-%m-%dT%H:%M:%S")
logger.debug("Logging configured.")
VALID_RELEASE_TAG_VALUES = ("alpha", "beta", "rc", "post", "final")
_current_date = dt.date.today().isoformat()
def _validate_date(date: typ.Optional[str], pin_date: bool) -> typ.Optional[dt.date]:
if date and pin_date:
logger.error(f"Can only use either --pin-date or --date='{date}', not both.")
sys.exit(1)
if date is None:
return None
try:
dt_val = dt.datetime.strptime(date, "%Y-%m-%d")
return dt_val.date()
except ValueError:
logger.error(
f"Invalid parameter --date='{date}', must match format YYYY-0M-0D.", exc_info=True
)
sys.exit(1)
def _validate_release_tag(tag: typ.Optional[str]) -> None:
if tag is None:
return
if tag in VALID_RELEASE_TAG_VALUES:
return
logger.error(f"Invalid argument --tag={tag}")
logger.error(f"Valid arguments are: {', '.join(VALID_RELEASE_TAG_VALUES)}")
sys.exit(1)
def _validate_flags(
raw_pattern: str,
major : bool,
minor : bool,
patch : bool,
) -> None:
if "{" in raw_pattern and "}" in raw_pattern:
# only validate for new style patterns
return
valid = True
if major and "MAJOR" not in raw_pattern:
logger.error(f"Flag --major is not applicable to pattern '{raw_pattern}'")
valid = False
if minor and "MINOR" not in raw_pattern:
logger.error(f"Flag --minor is not applicable to pattern '{raw_pattern}'")
valid = False
if patch and "PATCH" not in raw_pattern:
logger.error(f"Flag --patch is not applicable to pattern '{raw_pattern}'")
valid = False
if not valid:
sys.exit(1)
def _log_no_change(subcmd: str, version_pattern: str, old_version: str) -> None:
msg = f"Invalid version '{old_version}' and/or pattern '{version_pattern}'."
logger.error(msg)
is_semver = "{semver}" in version_pattern or (
"MAJOR" in version_pattern and "MAJOR" in version_pattern and "PATCH" in version_pattern
)
if is_semver:
logger.warning(f"calver {subcmd} [--major/--minor/--patch] required for use with SemVer.")
else:
available_flags = [
"--" + part.lower() for part in ['MAJOR', 'MINOR', 'PATCH'] if part in version_pattern
]
if available_flags:
available_flags_str = "/".join(available_flags)
logger.info(f"Perhaps try: bumpver {subcmd} {available_flags_str} ")
def _get_normalized_pattern(raw_pattern: str, version_pattern: typ.Optional[str]) -> str:
is_version_pattern_required = "{version}" in raw_pattern or "{pep440_version}" in raw_pattern
if is_version_pattern_required and version_pattern is None:
logger.error(
"Argument --version-pattern=<PATTERN> is required"
" for placeholders: {version}/{pep440_version}."
)
sys.exit(1)
elif version_pattern is None:
_version_pattern = "INVALID" # pacify mypy, it's not referenced in raw_pattern
else:
_version_pattern = version_pattern
if is_version_pattern_required:
return v2patterns.normalize_pattern(_version_pattern, raw_pattern)
else:
return raw_pattern
@click.group()
@click.version_option(version="2020.1041-beta")
@click.help_option()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
def cli(verbose: int = 0) -> None:
"""Automatically update CalVer version strings in plaintext files."""
if verbose:
_configure_logging(verbose=max(_VERBOSE, verbose))
@cli.command()
@click.argument("old_version")
@click.argument("pattern")
@click.option('-v' , '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option("--major", is_flag=True, default=False, help="Increment major component.")
@click.option("-m" , "--minor", is_flag=True, default=False, help="Increment minor component.")
@click.option("-p" , "--patch", is_flag=True, default=False, help="Increment patch component.")
@click.option(
"--tag",
default=None,
metavar="<NAME>",
help=(
f"Override release tag of current_version. Valid options are: "
f"{', '.join(VALID_RELEASE_TAG_VALUES)}."
),
)
@click.option(
"--tag-num",
is_flag=True,
default=False,
help="Increment release tag number (rc1, rc2, rc3..).",
)
@click.option("--pin-date", is_flag=True, default=False, help="Leave date components unchanged.")
@click.option(
"--date",
default=None,
metavar="<ISODATE>",
help=f"Set explicit date in format YYYY-0M-0D (e.g. {_current_date}).",
)
def test(
old_version: str,
pattern : str,
verbose : int = 0,
tag : str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag_num : bool = False,
pin_date : bool = False,
date : typ.Optional[str] = None,
) -> None:
"""Increment a version number for demo purposes."""
_configure_logging(verbose=max(_VERBOSE, verbose))
_validate_release_tag(tag)
raw_pattern = pattern # use internal naming convention
_validate_flags(raw_pattern, major, minor, patch)
_date = _validate_date(date, pin_date)
new_version = incr_dispatch(
old_version,
raw_pattern=raw_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=_date,
)
if new_version is None:
_log_no_change('test', raw_pattern, old_version)
sys.exit(1)
pep440_version = version.to_pep440(new_version)
click.echo(f"New Version: {new_version}")
if new_version != pep440_version:
click.echo(f"PEP440 : {pep440_version}")
def _grep_text(pattern: patterns.Pattern, text: str, color: bool) -> typ.Iterable[str]:
all_lines = text.splitlines()
for match in pattern.regexp.finditer(text):
match_start, match_end = match.span()
line_idx = text[:match_start].count("\n")
line_start = text.rfind("\n", 0, match_start) + 1
line_end = text.find("\n", match_end, -1)
if color:
matched_line = (
text[line_start:match_start]
+ colorama.Style.BRIGHT
+ text[match_start:match_end]
+ colorama.Style.RESET_ALL
+ text[match_end:line_end]
)
else:
matched_line = (
text[line_start:match_start]
+ text[match_start:match_end]
+ text[match_end:line_end]
)
lines_offset = max(0, line_idx - 1) + 1
lines = all_lines[line_idx - 1 : line_idx + 2]
if line_idx == 0:
lines[0] = matched_line
else:
lines[1] = matched_line
prefixed_lines = [f"{lines_offset + i:>4}: {line}" for i, line in enumerate(lines)]
yield "\n".join(prefixed_lines)
def _grep(
raw_pattern: str,
file_ios : typ.Tuple[io.TextIOWrapper],
color : bool,
) -> None:
pattern = v2patterns.compile_pattern(raw_pattern)
match_count = 0
for file_io in file_ios:
text = file_io.read()
match_strs = list(_grep_text(pattern, text, color))
if len(match_strs) > 0:
if len(file_ios) > 1:
print(file_io.name)
for match_str in match_strs:
print(match_str)
print()
match_count += len(match_strs)
if match_count == 0:
logger.error(f"Pattern not found: '{raw_pattern}'")
if match_count == 0 or _VERBOSE:
pyexpr_regex = regexfmt.pyexpr_regex(pattern.regexp.pattern)
print("# " + regexfmt.regex101_url(pattern.regexp.pattern))
print(pyexpr_regex)
print()
if match_count == 0:
sys.exit(1)
@cli.command()
@click.option(
"-v",
"--verbose",
count=True,
help="Control log level. -vv for debug level.",
)
@click.option(
"--version-pattern",
default=None,
metavar="<PATTERN>",
help="Pattern to use for placeholders: {version}/{pep440_version}",
)
@click.argument("pattern")
@click.argument('files', nargs=-1, type=click.File('r'))
def grep(
pattern : str,
files : typ.Tuple[io.TextIOWrapper],
version_pattern: typ.Optional[str] = None,
verbose : int = 0,
) -> None:
"""Search file(s) for a version pattern."""
verbose = max(_VERBOSE, verbose)
_configure_logging(verbose)
raw_pattern = pattern # use internal naming convention
normalized_pattern = _get_normalized_pattern(raw_pattern, version_pattern)
isatty = getattr(sys.stdout, 'isatty', lambda: False)
if isatty():
colorama.init()
try:
_grep(normalized_pattern, files, color=True)
finally:
colorama.deinit()
else:
_grep(normalized_pattern, files, color=False)
@cli.command()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"-f/-n", "--fetch/--no-fetch", is_flag=True, default=True, help="Sync tags from remote origin."
)
def show(verbose: int = 0, fetch: bool = True) -> None:
"""Show current version of your project."""
_configure_logging(verbose=max(_VERBOSE, verbose))
_, cfg = config.init(project_path=".")
if cfg is None:
logger.error("Could not parse configuration. Perhaps try 'bumpver init'.")
sys.exit(1)
cfg = _update_cfg_from_vcs(cfg, fetch)
click.echo(f"Current Version: {cfg.current_version}")
click.echo(f"PEP440 : {cfg.pep440_version}")
def _colored_diff_lines(diff: str) -> typ.Iterable[str]:
for line in diff.splitlines():
if line.startswith("+++") or line.startswith("---"):
yield line
elif line.startswith("+"):
yield "\u001b[32m" + line + "\u001b[0m"
elif line.startswith("-"):
yield "\u001b[31m" + line + "\u001b[0m"
elif line.startswith("@"):
yield "\u001b[36m" + line + "\u001b[0m"
else:
yield line
def _v2_get_diff(cfg: config.Config, new_version: str) -> str:
old_vinfo = v2version.parse_version_info(cfg.current_version, cfg.version_pattern)
new_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern)
return v2rewrite.diff(old_vinfo, new_vinfo, cfg.file_patterns)
def _v1_get_diff(cfg: config.Config, new_version: str) -> str:
old_vinfo = v1version.parse_version_info(cfg.current_version, cfg.version_pattern)
new_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
return v1rewrite.diff(old_vinfo, new_vinfo, cfg.file_patterns)
def get_diff(cfg, new_version) -> str:
if cfg.is_new_pattern:
return _v2_get_diff(cfg, new_version)
else:
return _v1_get_diff(cfg, new_version)
def _print_diff_str(diff: str) -> None:
colored_diff = "\n".join(_colored_diff_lines(diff))
if sys.stdout.isatty():
click.echo(colored_diff)
else:
click.echo(diff)
def _print_diff(cfg: config.Config, new_version: str) -> None:
try:
diff = get_diff(cfg, new_version)
_print_diff_str(diff)
except OSError as err:
logger.error(str(err))
sys.exit(1)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
def incr_dispatch(
old_version: str,
raw_pattern: str,
*,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : str = None,
tag_num : bool = False,
pin_date: bool = False,
date : typ.Optional[dt.date] = None,
) -> typ.Optional[str]:
v1_parts = list(v1patterns.PART_PATTERNS) + list(v1patterns.FULL_PART_FORMATS)
has_v1_part = any("{" + part + "}" in raw_pattern for part in v1_parts)
if _VERBOSE:
if has_v1_part:
pattern = v1patterns.compile_pattern(raw_pattern)
else:
pattern = v2patterns.compile_pattern(raw_pattern)
logger.info("Using pattern " + raw_pattern)
logger.info("regex = " + regexfmt.pyexpr_regex(pattern.regexp.pattern))
if has_v1_part:
new_version = v1version.incr(
old_version,
raw_pattern=raw_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=date,
)
else:
new_version = v2version.incr(
old_version,
raw_pattern=raw_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=date,
)
if new_version is None:
return None
elif pkg_resources.parse_version(new_version) <= pkg_resources.parse_version(old_version):
logger.error("Invariant violated: New version must be greater than old version ")
logger.error(f" Failed Invariant: '{new_version}' > '{old_version}'")
return None
else:
return new_version
def _update(
cfg : config.Config,
new_version : str,
commit_message: str,
allow_dirty : bool = False,
) -> None:
vcs_api: typ.Optional[vcs.VCSAPI] = None
if cfg.commit:
try:
vcs_api = vcs.get_vcs_api()
except OSError:
logger.warning("Version Control System not found, skipping commit.")
filepaths = set(cfg.file_patterns.keys())
if vcs_api:
vcs.assert_not_dirty(vcs_api, filepaths, allow_dirty)
try:
if cfg.is_new_pattern:
new_v2_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern)
v2rewrite.rewrite_files(cfg.file_patterns, new_v2_vinfo)
else:
new_v1_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
v1rewrite.rewrite_files(cfg.file_patterns, new_v1_vinfo)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
if vcs_api:
vcs.commit(cfg, vcs_api, filepaths, new_version, commit_message)
def _try_update(
cfg : config.Config,
new_version : str,
commit_message: str,
allow_dirty : bool = False,
) -> None:
try:
_update(cfg, new_version, commit_message, allow_dirty)
except sp.CalledProcessError as ex:
logger.error(f"Error running subcommand: {ex.cmd}")
if ex.stdout:
sys.stdout.write(ex.stdout.decode('utf-8'))
if ex.stderr:
sys.stderr.write(ex.stderr.decode('utf-8'))
sys.exit(1)
@cli.command()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
'-d', "--dry", default=False, is_flag=True, help="Display diff of changes, don't rewrite files."
)
def init(verbose: int = 0, dry: bool = False) -> None:
"""Initialize [calver] configuration."""
_configure_logging(verbose=max(_VERBOSE, verbose))
ctx, cfg = config.init(project_path=".", cfg_missing_ok=True)
if cfg:
logger.error(f"Configuration already initialized in {ctx.config_rel_path}")
sys.exit(1)
if dry:
click.echo(f"Exiting because of '-d/--dry'. Would have written to {ctx.config_rel_path}:")
cfg_text: str = config.default_config(ctx)
click.echo("\n " + "\n ".join(cfg_text.splitlines()))
sys.exit(0)
config.write_content(ctx)
def get_latest_vcs_version_tag(cfg: config.Config, fetch: bool) -> typ.Optional[str]:
all_tags = vcs.get_tags(fetch=fetch)
if cfg.is_new_pattern:
version_tags = [tag for tag in all_tags if v2version.is_valid(tag, cfg.version_pattern)]
else:
version_tags = [tag for tag in all_tags if v1version.is_valid(tag, cfg.version_pattern)]
if version_tags:
version_tags.sort(key=pkg_resources.parse_version, reverse=True)
_debug_tags = ", ".join(version_tags[:3])
logger.debug(f"found tags: {_debug_tags} ... ({len(version_tags)} in total)")
return version_tags[0]
else:
return None
def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
latest_version_tag = get_latest_vcs_version_tag(cfg, fetch)
if latest_version_tag is None:
logger.debug("no vcs tags found")
return cfg
else:
latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version:
# current_version already newer/up-to-date
return cfg
else:
logger.info(f"Working dir version : {cfg.current_version}")
logger.info(f"Latest version from VCS tag: {latest_version_tag}")
return cfg._replace(
current_version=latest_version_tag,
pep440_version=latest_version_pep440,
)
@cli.command()
@click.option(
"-v",
"--verbose",
count=True,
help="Control log level. -vv for debug level.",
)
@click.option(
"-f/-n",
"--fetch/--no-fetch",
is_flag=True,
default=True,
help="Sync tags from remote origin.",
)
@click.option(
"-d",
"--dry",
default=False,
is_flag=True,
help="Display diff of changes, don't rewrite files.",
)
@click.option(
"--allow-dirty",
default=False,
is_flag=True,
help=(
"Commit even when working directory is has uncomitted changes. "
"(WARNING: The commit will still be aborted if there are uncomitted "
"to files with version strings."
),
)
@click.option("--major", is_flag=True, default=False, help="Increment major component.")
@click.option("-m", "--minor", is_flag=True, default=False, help="Increment minor component.")
@click.option("-p", "--patch", is_flag=True, default=False, help="Increment patch component.")
@click.option(
"-t",
"--tag",
default=None,
metavar="<NAME>",
help=(
f"Override release tag of current_version. Valid options are: "
f"{', '.join(VALID_RELEASE_TAG_VALUES)}."
),
)
@click.option(
"--tag-num",
is_flag=True,
default=False,
help="Increment release tag number (rc1, rc2, rc3..).",
)
@click.option("--pin-date", is_flag=True, default=False, help="Leave date components unchanged.")
@click.option(
"--date",
default=None,
metavar="<ISODATE>",
help=f"Set explicit date in format YYYY-0M-0D (e.g. {_current_date}).",
)
def update(
verbose : int = 0,
dry : bool = False,
allow_dirty: bool = False,
fetch : bool = True,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : typ.Optional[str] = None,
tag_num : bool = False,
pin_date : bool = False,
date : typ.Optional[str] = None,
) -> None:
"""Update project files with the incremented version string."""
verbose = max(_VERBOSE, verbose)
_configure_logging(verbose)
_validate_release_tag(tag)
_date = _validate_date(date, pin_date)
_, cfg = config.init(project_path=".")
if cfg is None:
logger.error("Could not parse configuration. Perhaps try 'bumpver init'.")
sys.exit(1)
cfg = _update_cfg_from_vcs(cfg, fetch)
old_version = cfg.current_version
new_version = incr_dispatch(
old_version,
raw_pattern=cfg.version_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=_date,
)
if new_version is None:
_log_no_change('update', cfg.version_pattern, old_version)
sys.exit(1)
logger.info(f"Old Version: {old_version}")
logger.info(f"New Version: {new_version}")
if dry or verbose >= 2:
_print_diff(cfg, new_version)
if dry:
return
commit_message_kwargs = {
'new_version' : new_version,
'old_version' : old_version,
'new_version_pep440': version.to_pep440(new_version),
'old_version_pep440': version.to_pep440(old_version),
}
commit_message = cfg.commit_message.format(**commit_message_kwargs)
_try_update(cfg, new_version, commit_message, allow_dirty)
if __name__ == '__main__':
cli()

621
src/bumpver/config.py Normal file
View file

@ -0,0 +1,621 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Parse bumpver.toml, setup.cfg or pyproject.toml files."""
import re
import glob
import typing as typ
import logging
import datetime as dt
import configparser
import toml
import pathlib2 as pl
from . import version
from . import v1version
from . import v2version
from . import v1patterns
from . import v2patterns
from .patterns import Pattern
logger = logging.getLogger("bumpver.config")
RawPatterns = typ.List[str]
RawPatternsByFile = typ.Dict[str, RawPatterns]
FileRawPatternsItem = typ.Tuple[str, RawPatterns]
PatternsByFile = typ.Dict[str, typ.List[Pattern]]
FilePatternsItem = typ.Tuple[str, typ.List[Pattern]]
SUPPORTED_CONFIGS = ["setup.cfg", "pyproject.toml", "pycalver.toml", "bumpver.toml"]
DEFAULT_COMMIT_MESSAGE = "bump version to {new_version}"
class ProjectContext(typ.NamedTuple):
"""Container class for project info."""
path : pl.Path
config_filepath: pl.Path
config_rel_path: str
config_format : str
vcs_type : typ.Optional[str]
def _parse_config_and_format(path: pl.Path) -> typ.Tuple[pl.Path, str, str]:
if (path / "pycalver.toml").exists():
config_filepath = path / "pycalver.toml"
config_format = 'toml'
elif (path / "bumpver.toml").exists():
config_filepath = path / "bumpver.toml"
config_format = 'toml'
elif (path / "pyproject.toml").exists():
config_filepath = path / "pyproject.toml"
config_format = 'toml'
elif (path / "setup.cfg").exists():
config_filepath = path / "setup.cfg"
config_format = 'cfg'
else:
# fallback to creating a new bumpver.toml
config_filepath = path / "bumpver.toml"
config_format = 'toml'
if config_filepath.is_absolute():
config_rel_path = str(config_filepath.relative_to(path.absolute()))
else:
config_rel_path = str(config_filepath)
config_filepath = pl.Path.cwd() / config_filepath
return (config_filepath, config_rel_path, config_format)
def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> ProjectContext:
"""Initialize ProjectContext from a path."""
if isinstance(project_path, pl.Path):
path = project_path
elif project_path is None:
path = pl.Path(".")
else:
# assume it's a str/unicode
path = pl.Path(project_path)
config_filepath, config_rel_path, config_format = _parse_config_and_format(path)
vcs_type: typ.Optional[str]
if (path / ".git").exists():
vcs_type = 'git'
elif (path / ".hg").exists():
vcs_type = 'hg'
else:
vcs_type = None
return ProjectContext(path, config_filepath, config_rel_path, config_format, vcs_type)
RawConfig = typ.Dict[str, typ.Any]
MaybeRawConfig = typ.Optional[RawConfig]
class Config(typ.NamedTuple):
"""Container for parameters parsed from a config file."""
current_version: str
version_pattern: str
pep440_version : str
commit_message : str
commit : bool
tag : bool
push : bool
is_new_pattern: bool
file_patterns: PatternsByFile
MaybeConfig = typ.Optional[Config]
def _debug_str(cfg: Config) -> str:
cfg_str_parts = [
"Config Parsed: Config(",
f"\n current_version='{cfg.current_version}',",
f"\n version_pattern='{cfg.version_pattern}',",
f"\n pep440_version='{cfg.pep440_version}',",
f"\n commit_message='{cfg.commit_message}',",
f"\n commit={cfg.commit},",
f"\n tag={cfg.tag},",
f"\n push={cfg.push},",
f"\n is_new_pattern={cfg.is_new_pattern},",
"\n file_patterns={",
]
for filepath, patterns in sorted(cfg.file_patterns.items()):
for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern.raw_pattern}',")
cfg_str_parts += ["\n }\n)"]
return "".join(cfg_str_parts)
def _parse_cfg_file_patterns(
cfg_parser: configparser.RawConfigParser,
) -> typ.Iterable[FileRawPatternsItem]:
file_pattern_items: typ.List[typ.Tuple[str, str]]
if cfg_parser.has_section("pycalver:file_patterns"):
file_pattern_items = cfg_parser.items("pycalver:file_patterns")
elif cfg_parser.has_section("bumpver:file_patterns"):
file_pattern_items = cfg_parser.items("bumpver:file_patterns")
else:
return
for filepath, patterns_str in file_pattern_items:
maybe_patterns = (line.strip() for line in patterns_str.splitlines())
patterns = [p for p in maybe_patterns if p]
yield filepath, patterns
class _ConfigParser(configparser.RawConfigParser):
# pylint:disable=too-many-ancestors ; from our perspective, it's just one
"""Custom parser, simply to override optionxform behaviour."""
def optionxform(self, optionstr: str) -> str:
"""Non-xforming (ie. uppercase preserving) override.
This is important because our option names are actually
filenames, so case sensitivity is relevant. The default
behaviour is to do optionstr.lower()
"""
return optionstr
OptionVal = typ.Union[str, bool, None]
BOOL_OPTIONS: typ.Mapping[str, OptionVal] = {'commit': False, 'tag': None, 'push': None}
def _parse_cfg(cfg_buffer: typ.IO[str]) -> RawConfig:
cfg_parser = _ConfigParser()
if hasattr(cfg_parser, 'read_file'):
cfg_parser.read_file(cfg_buffer)
else:
cfg_parser.readfp(cfg_buffer) # python2 compat
raw_cfg: RawConfig
if cfg_parser.has_section("pycalver"):
raw_cfg = dict(cfg_parser.items("pycalver"))
elif cfg_parser.has_section("bumpver"):
raw_cfg = dict(cfg_parser.items("bumpver"))
else:
raise ValueError("Missing [bumpver] section.")
for option, default_val in BOOL_OPTIONS.items():
val: OptionVal = raw_cfg.get(option, default_val)
if isinstance(val, (bytes, str)):
val = val.lower() in ("yes", "true", "1", "on")
raw_cfg[option] = val
raw_cfg['file_patterns'] = dict(_parse_cfg_file_patterns(cfg_parser))
_set_raw_config_defaults(raw_cfg)
return raw_cfg
def _parse_toml(cfg_buffer: typ.IO[str]) -> RawConfig:
raw_full_cfg: typ.Any = toml.load(cfg_buffer)
raw_cfg : RawConfig
if 'bumpver' in raw_full_cfg:
raw_cfg = raw_full_cfg['bumpver']
elif 'pycalver' in raw_full_cfg:
raw_cfg = raw_full_cfg['pycalver']
else:
raw_cfg = {}
for option, default_val in BOOL_OPTIONS.items():
raw_cfg[option] = raw_cfg.get(option, default_val)
_set_raw_config_defaults(raw_cfg)
return raw_cfg
def _iter_glob_expanded_file_patterns(
raw_patterns_by_file: RawPatternsByFile,
) -> typ.Iterable[FileRawPatternsItem]:
for filepath_glob, raw_patterns in raw_patterns_by_file.items():
filepaths = glob.glob(filepath_glob)
if filepaths:
for filepath in filepaths:
yield filepath, raw_patterns
else:
logger.warning(f"Invalid config, no such file: {filepath_glob}")
# fallback to treating it as a simple path
yield filepath_glob, raw_patterns
def _compile_v1_file_patterns(raw_cfg: RawConfig) -> typ.Iterable[FilePatternsItem]:
"""Create inernal/compiled representation of the file_patterns config field.
The result the same, regardless of the config format.
"""
# current_version: str = raw_cfg['current_version']
# current_pep440_version = version.pep440_version(current_version)
version_pattern : str = raw_cfg['version_pattern']
raw_patterns_by_file: RawPatternsByFile = raw_cfg['file_patterns']
for filepath, raw_patterns in _iter_glob_expanded_file_patterns(raw_patterns_by_file):
compiled_patterns = v1patterns.compile_patterns(version_pattern, raw_patterns)
yield filepath, compiled_patterns
def _compile_v2_file_patterns(raw_cfg: RawConfig) -> typ.Iterable[FilePatternsItem]:
"""Create inernal/compiled representation of the file_patterns config field.
The result the same, regardless of the config format.
"""
version_pattern : str = raw_cfg['version_pattern']
raw_patterns_by_file: RawPatternsByFile = raw_cfg['file_patterns']
for filepath, raw_patterns in _iter_glob_expanded_file_patterns(raw_patterns_by_file):
compiled_patterns = v2patterns.compile_patterns(version_pattern, raw_patterns)
yield filepath, compiled_patterns
def _compile_file_patterns(raw_cfg: RawConfig, is_new_pattern: bool) -> PatternsByFile:
if is_new_pattern:
_file_pattern_items = _compile_v2_file_patterns(raw_cfg)
else:
_file_pattern_items = _compile_v1_file_patterns(raw_cfg)
# NOTE (mb 2020-10-03): There can be multiple items for the same
# path, so this is not an option:
#
# return dict(_file_pattern_items)
file_patterns: PatternsByFile = {}
for path, patterns in _file_pattern_items:
if path in file_patterns:
file_patterns[path].extend(patterns)
else:
file_patterns[path] = patterns
return file_patterns
def _validate_version_with_pattern(
current_version: str,
version_pattern: str,
is_new_pattern : bool,
) -> None:
"""Provoke ValueError if version_pattern and current_version are not compatible."""
try:
if is_new_pattern:
v2version.parse_version_info(current_version, version_pattern)
else:
v1version.parse_version_info(current_version, version_pattern)
except version.PatternError:
errmsg = (
"Invalid configuration. "
f"current_version='{current_version}' is invalid for "
f"version_pattern='{version_pattern}'"
)
raise ValueError(errmsg)
if is_new_pattern:
invalid_chars = re.search(r"([\s]+)", version_pattern)
if invalid_chars:
errmsg = (
f"Invalid character(s) '{invalid_chars.group(1)}'"
f' in version_pattern = "{version_pattern}"'
)
raise ValueError(errmsg)
if not v2version.is_valid_week_pattern(version_pattern):
errmsg = f"Invalid week number pattern: {version_pattern}"
raise ValueError(errmsg)
def _parse_config(raw_cfg: RawConfig) -> Config:
"""Parse configuration which was loaded from an .ini/.cfg or .toml file."""
commit_message: str = raw_cfg.get('commit_message', DEFAULT_COMMIT_MESSAGE)
commit_message = raw_cfg['commit_message'] = commit_message.strip("'\" ")
current_version: str = raw_cfg['current_version']
current_version = raw_cfg['current_version'] = current_version.strip("'\" ")
version_pattern: str = raw_cfg['version_pattern']
version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ")
is_new_pattern = "{" not in version_pattern and "}" not in version_pattern
_validate_version_with_pattern(current_version, version_pattern, is_new_pattern)
pep440_version = version.to_pep440(current_version)
file_patterns = _compile_file_patterns(raw_cfg, is_new_pattern)
commit = raw_cfg['commit']
tag = raw_cfg['tag']
push = raw_cfg['push']
if tag is None:
tag = raw_cfg['tag'] = False
if push is None:
push = raw_cfg['push'] = False
if tag and not commit:
raise ValueError("commit=True required if tag=True")
if push and not commit:
raise ValueError("commit=True required if push=True")
cfg = Config(
current_version=current_version,
version_pattern=version_pattern,
pep440_version=pep440_version,
commit_message=commit_message,
commit=commit,
tag=tag,
push=push,
is_new_pattern=is_new_pattern,
file_patterns=file_patterns,
)
logger.debug(_debug_str(cfg))
return cfg
def _parse_current_version_default_pattern(raw_cfg: RawConfig, raw_cfg_text: str) -> str:
is_config_section = False
for line in raw_cfg_text.splitlines():
if is_config_section and line.startswith("current_version"):
current_version: str = raw_cfg['current_version']
version_pattern: str = raw_cfg['version_pattern']
return line.replace(current_version, version_pattern)
if line.strip() == "[pycalver]":
is_config_section = True
elif line.strip() == "[bumpver]":
is_config_section = True
elif line and line[0] == "[" and line[-1] == "]":
is_config_section = False
raise ValueError("Could not parse 'current_version'")
def _set_raw_config_defaults(raw_cfg: RawConfig) -> None:
if 'version_pattern' not in raw_cfg:
raise TypeError("Missing version_pattern")
elif not isinstance(raw_cfg['version_pattern'], str):
err = f"Invalid type for version_pattern = {raw_cfg['version_pattern']}"
raise TypeError(err)
if 'current_version' not in raw_cfg:
raise ValueError("Missing 'current_version' configuration")
elif not isinstance(raw_cfg['current_version'], str):
err = f"Invalid type for current_version = {raw_cfg['current_version']}"
raise TypeError(err)
if 'file_patterns' not in raw_cfg:
raw_cfg['file_patterns'] = {}
def _parse_raw_config(ctx: ProjectContext) -> RawConfig:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
if ctx.config_format == 'toml':
raw_cfg = _parse_toml(fobj)
elif ctx.config_format == 'cfg':
raw_cfg = _parse_cfg(fobj)
else:
err_msg = (
f"Invalid config_format='{ctx.config_format}'."
"Supported formats are 'setup.cfg' and 'pyproject.toml'"
)
raise RuntimeError(err_msg)
if ctx.config_rel_path not in raw_cfg['file_patterns']:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
raw_cfg_text = fobj.read()
# NOTE (mb 2020-09-19): By default we always add
# a pattern for the config section itself.
raw_version_pattern = _parse_current_version_default_pattern(raw_cfg, raw_cfg_text)
raw_cfg['file_patterns'][ctx.config_rel_path] = [raw_version_pattern]
return raw_cfg
def parse(ctx: ProjectContext, cfg_missing_ok: bool = False) -> MaybeConfig:
"""Parse config file if available."""
if ctx.config_filepath.exists():
try:
raw_cfg = _parse_raw_config(ctx)
return _parse_config(raw_cfg)
except (TypeError, ValueError) as ex:
logger.warning(f"Couldn't parse {ctx.config_rel_path}: {str(ex)}")
return None
else:
if not cfg_missing_ok:
logger.warning(f"File not found: {ctx.config_rel_path}")
return None
def init(
project_path : typ.Union[str, pl.Path, None] = ".",
cfg_missing_ok: bool = False,
) -> typ.Tuple[ProjectContext, MaybeConfig]:
ctx = init_project_ctx(project_path)
cfg = parse(ctx, cfg_missing_ok)
return (ctx, cfg)
DEFAULT_CONFIGPARSER_BASE_TMPL = """
[bumpver]
current_version = "{initial_version}"
version_pattern = "YYYY.BUILD[-TAG]"
commit_message = "bump version {{old_version}} -> {{new_version}}"
commit = True
tag = True
push = True
[bumpver:file_patterns]
""".lstrip()
DEFAULT_CONFIGPARSER_SETUP_CFG_STR = """
setup.cfg =
current_version = "{version}"
""".lstrip()
DEFAULT_CONFIGPARSER_SETUP_PY_STR = """
setup.py =
"{version}"
"{pep440_version}"
""".lstrip()
DEFAULT_CONFIGPARSER_README_RST_STR = """
README.rst =
{version}
{pep440_version}
""".lstrip()
DEFAULT_CONFIGPARSER_README_MD_STR = """
README.md =
{version}
{pep440_version}
""".lstrip()
DEFAULT_TOML_BASE_TMPL = """
[bumpver]
current_version = "{initial_version}"
version_pattern = "YYYY.BUILD[-TAG]"
commit_message = "bump version {{old_version}} -> {{new_version}}"
commit = true
tag = true
push = true
[bumpver.file_patterns]
""".lstrip()
DEFAULT_TOML_PYCALVER_STR = """
"pycalver.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_BUMPVER_STR = """
"bumpver.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_PYPROJECT_STR = """
"pyproject.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_SETUP_PY_STR = """
"setup.py" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
DEFAULT_TOML_README_RST_STR = """
"README.rst" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
DEFAULT_TOML_README_MD_STR = """
"README.md" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
def _initial_version() -> str:
return dt.datetime.utcnow().strftime("%Y.1001-alpha")
def _initial_version_pep440() -> str:
return dt.datetime.utcnow().strftime("%Y.1001a0")
def default_config(ctx: ProjectContext) -> str:
"""Generate initial default config."""
fmt = ctx.config_format
if fmt == 'cfg':
base_tmpl = DEFAULT_CONFIGPARSER_BASE_TMPL
default_pattern_strs_by_filename = {
"setup.cfg" : DEFAULT_CONFIGPARSER_SETUP_CFG_STR,
"setup.py" : DEFAULT_CONFIGPARSER_SETUP_PY_STR,
"README.rst": DEFAULT_CONFIGPARSER_README_RST_STR,
"README.md" : DEFAULT_CONFIGPARSER_README_MD_STR,
}
elif fmt == 'toml':
base_tmpl = DEFAULT_TOML_BASE_TMPL
default_pattern_strs_by_filename = {
"pyproject.toml": DEFAULT_TOML_PYPROJECT_STR,
"pycalver.toml" : DEFAULT_TOML_PYCALVER_STR,
"bumpver.toml" : DEFAULT_TOML_BUMPVER_STR,
"setup.py" : DEFAULT_TOML_SETUP_PY_STR,
"README.rst" : DEFAULT_TOML_README_RST_STR,
"README.md" : DEFAULT_TOML_README_MD_STR,
}
else:
raise ValueError(f"Invalid config_format='{fmt}', must be either 'toml' or 'cfg'.")
cfg_str = base_tmpl.format(initial_version=_initial_version())
for filename, default_str in default_pattern_strs_by_filename.items():
if (ctx.path / filename).exists():
cfg_str += default_str
has_config_file = any((ctx.path / fn).exists() for fn in SUPPORTED_CONFIGS)
if not has_config_file:
if ctx.config_format == 'cfg':
cfg_str += DEFAULT_CONFIGPARSER_SETUP_CFG_STR
if ctx.config_format == 'toml':
cfg_str += DEFAULT_TOML_BUMPVER_STR
cfg_str += "\n"
return cfg_str
def write_content(ctx: ProjectContext) -> None:
"""Update project config file with initial default config."""
fobj: typ.IO[str]
cfg_content = default_config(ctx)
if ctx.config_filepath.exists():
cfg_content = "\n" + cfg_content
with ctx.config_filepath.open(mode="at", encoding="utf-8") as fobj:
fobj.write(cfg_content)
print(f"Updated {ctx.config_rel_path}")

85
src/bumpver/parse.py Normal file
View file

@ -0,0 +1,85 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Parse PyCalVer strings from files."""
import typing as typ
from .patterns import Pattern
LineNo = int
Start = int
End = int
class LineSpan(typ.NamedTuple):
lineno: LineNo
start : Start
end : End
LineSpans = typ.List[LineSpan]
def _has_overlap(needle: LineSpan, haystack: LineSpans) -> bool:
for span in haystack:
# assume needle is in the center
has_overlap = (
span.lineno == needle.lineno
# needle starts before (or at) span end
and needle.start <= span.end
# needle ends after (or at) span start
and needle.end >= span.start
)
if has_overlap:
return True
return False
class PatternMatch(typ.NamedTuple):
"""Container to mark a version string in a file."""
lineno : LineNo # zero based
line : str
pattern: Pattern
span : typ.Tuple[Start, End]
match : str
PatternMatches = typ.Iterable[PatternMatch]
def _iter_for_pattern(lines: typ.List[str], pattern: Pattern) -> PatternMatches:
for lineno, line in enumerate(lines):
match = pattern.regexp.search(line)
if match:
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
def iter_matches(lines: typ.List[str], patterns: typ.List[Pattern]) -> PatternMatches:
"""Iterate over all matches of any pattern on any line.
>>> from . import v1patterns
>>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> version_pattern = "{pycalver}"
>>> raw_patterns = ["{pycalver}", "{pep440_pycalver}"]
>>> patterns = [v1patterns.compile_pattern(version_pattern, p) for p in raw_patterns]
>>> matches = list(iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch(
... lineno = 0,
... line = "__version__ = 'v201712.0002-alpha'",
... pattern= v1patterns.compile_pattern(version_pattern),
... span = (15, 33),
... match = "v201712.0002-alpha",
... )
"""
matched_spans: LineSpans = []
for pattern in patterns:
for match in _iter_for_pattern(lines, pattern):
needle_span = LineSpan(match.lineno, *match.span)
if not _has_overlap(needle_span, matched_spans):
yield match
matched_spans.append(needle_span)

29
src/bumpver/patterns.py Normal file
View file

@ -0,0 +1,29 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
class Pattern(typ.NamedTuple):
version_pattern: str # "{pycalver}", "{year}.{month}", "vYYYY0M.BUILD"
raw_pattern : str # '__version__ = "{version}"', "Copyright (c) YYYY"
regexp : typ.Pattern[str]
RE_PATTERN_ESCAPES = [
("\u005c", "\u005c\u005c"),
("-" , "\u005c-"),
("." , "\u005c."),
("+" , "\u005c+"),
("*" , "\u005c*"),
("?" , "\u005c?"),
("{" , "\u005c{"),
("}" , "\u005c}"),
("[" , "\u005c["),
("]" , "\u005c]"),
("(" , "\u005c("),
(")" , "\u005c)"),
]

47
src/bumpver/pysix.py Normal file
View file

@ -0,0 +1,47 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import sys
import typing as typ
PY2 = sys.version_info.major < 3
try:
from urllib.parse import quote as py3_stdlib_quote
except ImportError:
from urllib import quote as py2_stdlib_quote # type: ignore
# NOTE (mb 2016-05-23): quote in python2 expects bytes argument.
def quote(
string : str,
safe : str = "/",
encoding: typ.Optional[str] = None,
errors : typ.Optional[str] = None,
) -> str:
if not isinstance(string, str):
errmsg = f"Expected str/unicode but got {type(string)}" # type: ignore
raise TypeError(errmsg)
if encoding is None:
_encoding = "utf-8"
else:
_encoding = encoding
if errors is None:
_errors = "strict"
else:
_errors = errors
if PY2:
data = string.encode(_encoding)
res = py2_stdlib_quote(data, safe=safe.encode(_encoding))
return res.decode(_encoding, errors=_errors)
else:
return py3_stdlib_quote(string, safe=safe, encoding=_encoding, errors=_errors)

76
src/bumpver/regexfmt.py Normal file
View file

@ -0,0 +1,76 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import re
import logging
import textwrap
from . import pysix
logger = logging.getLogger("bumpver.regexfmt")
def format_regex(regex: str) -> str:
r"""Format a regex pattern suitible for flags=re.VERBOSE.
>>> regex = r"\[CalVer v(?P<year_y>[1-9][0-9]{3})(?P<month>(?:1[0-2]|0[1-9]))"
>>> print(format_regex(regex))
\[CalVer[ ]v
(?P<year_y>[1-9][0-9]{3})
(?P<month>
(?:1[0-2]|0[1-9])
)
"""
# provoke error for invalid regex
re.compile(regex)
tmp_regex = regex.replace(" ", r"[ ]")
tmp_regex = tmp_regex.replace('"', r'\"')
tmp_regex, _ = re.subn(r"([^\\])?\)(\?)?", "\\1)\\2\n", tmp_regex)
tmp_regex, _ = re.subn(r"([^\\])\(" , "\\1\n(" , tmp_regex)
tmp_regex, _ = re.subn(r"^\)\)" , ")\n)" , tmp_regex, flags=re.MULTILINE)
lines = tmp_regex.splitlines()
indented_lines = []
level = 0
for line in lines:
if line.strip():
increment = line.count("(") - line.count(")")
if increment >= 0:
line = " " * level + line
level += increment
else:
level += increment
line = " " * level + line
indented_lines.append(line)
formatted_regex = "\n".join(indented_lines)
# provoke error if there is a bug in the formatting code
re.compile(formatted_regex)
return formatted_regex
def pyexpr_regex(regex: str) -> str:
try:
formatted_regex = format_regex(regex)
formatted_regex = textwrap.indent(formatted_regex.rstrip(), " ")
return 're.compile(r"""\n' + formatted_regex + '\n""", flags=re.VERBOSE)'
except re.error:
return f"re.compile({repr(regex)})"
def regex101_url(regex_pattern: str) -> str:
try:
regex_pattern = format_regex(regex_pattern)
except re.error:
logger.warning(f"Error formatting regex '{repr(regex_pattern)}'")
return "".join(
(
"https://regex101.com/",
"?flavor=python",
"&flags=gmx" "&regex=" + pysix.quote(regex_pattern),
)
)

90
src/bumpver/rewrite.py Normal file
View file

@ -0,0 +1,90 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
import difflib
import pathlib2 as pl
from . import config
from .patterns import Pattern
class NoPatternMatch(Exception):
"""Pattern not found in content.
logger.error is used to show error info about the patterns so
that users can debug what is wrong with them. The class
itself doesn't capture that info. This approach is used so
that all patter issues can be shown, rather than bubbling
all the way up the stack on the very first pattern with no
matches.
"""
def detect_line_sep(content: str) -> str:
r"""Parse line separator from content.
>>> detect_line_sep('\r\n')
'\r\n'
>>> detect_line_sep('\r')
'\r'
>>> detect_line_sep('\n')
'\n'
>>> detect_line_sep('')
'\n'
"""
if "\r\n" in content:
return "\r\n"
elif "\r" in content:
return "\r"
else:
return "\n"
class RewrittenFileData(typ.NamedTuple):
"""Container for line-wise content of rewritten files."""
path : str
line_sep : str
old_lines: typ.List[str]
new_lines: typ.List[str]
PathPatternsItem = typ.Tuple[pl.Path, typ.List[Pattern]]
def iter_path_patterns_items(
file_patterns: config.PatternsByFile,
) -> typ.Iterable[PathPatternsItem]:
for filepath_str, patterns in file_patterns.items():
filepath_obj = pl.Path(filepath_str)
if filepath_obj.exists():
yield (filepath_obj, patterns)
else:
errmsg = f"File does not exist: '{filepath_str}'"
raise IOError(errmsg)
def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
r"""Generate unified diff.
>>> rfd = RewrittenFileData(
... path = "<path>",
... line_sep = "\n",
... old_lines = ["foo"],
... new_lines = ["bar"],
... )
>>> diff_lines(rfd)
['--- <path>', '+++ <path>', '@@ -1 +1 @@', '-foo', '+bar']
"""
lines = difflib.unified_diff(
a=rfd.old_lines,
b=rfd.new_lines,
lineterm="",
fromfile=rfd.path,
tofile=rfd.path,
)
return list(lines)

24
src/bumpver/utils.py Normal file
View file

@ -0,0 +1,24 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
import functools
# NOTE (mb 2020-09-24): The main use of the memo function is
# not as a performance optimization, but to reduce logging
# spam.
def memo(func: typ.Callable) -> typ.Callable:
cache = {}
@functools.wraps(func)
def wrapper(*args):
key = str(args)
if key not in cache:
cache[key] = func(*args)
return cache[key]
return wrapper

View file

@ -1,7 +1,7 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Compose Regular Expressions from Patterns.
@ -32,6 +32,13 @@
import re
import typing as typ
import logging
from . import utils
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("bumpver.v1patterns")
# https://regex101.com/r/fnj60p/10
PYCALVER_PATTERN = r"""
@ -56,21 +63,6 @@ PYCALVER_PATTERN = r"""
PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)
PATTERN_ESCAPES = [
("\u005c", "\u005c\u005c"),
("-" , "\u005c-"),
("." , "\u005c."),
("+" , "\u005c+"),
("*" , "\u005c*"),
("?" , "\u005c?"),
("{" , "\u005c{"),
("}" , "\u005c}"),
("[" , "\u005c["),
("]" , "\u005c]"),
("(" , "\u005c("),
(")" , "\u005c)"),
]
COMPOSITE_PART_PATTERNS = {
'pep440_pycalver': r"{year}{month}\.{BID}(?:{pep440_tag})?",
'pycalver' : r"v{year}{month}\.{bid}(?:-{tag})?",
@ -122,6 +114,44 @@ PART_PATTERNS = {
}
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'month_short': 'month',
'pep440_tag' : 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'dom_short' : 'dom',
'doy_short' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
FULL_PART_FORMATS = {
'pep440_pycalver': "{year}{month:02}.{BID}{pep440_tag}",
'pycalver' : "v{year}{month:02}.{bid}{release}",
@ -130,7 +160,7 @@ FULL_PART_FORMATS = {
'release_tag' : "{tag}",
'build' : ".{bid}",
# NOTE (mb 2019-01-04): since release is optional, it
# is treates specially in version.format
# is treated specially in v1version.format_version
# 'release' : "-{tag}",
'month' : "{month:02}",
'month_short': "{month}",
@ -147,56 +177,17 @@ FULL_PART_FORMATS = {
}
PART_FORMATS = {
'major' : "[0-9]+",
'minor' : "[0-9]{3,}",
'patch' : "[0-9]{3,}",
'bid' : "[0-9]{4,}",
'MAJOR' : "[0-9]+",
'MINOR' : "[0-9]+",
'MM' : "[0-9]{2,}",
'MMM' : "[0-9]{3,}",
'MMMM' : "[0-9]{4,}",
'MMMMM' : "[0-9]{5,}",
'MMMMMM' : "[0-9]{6,}",
'MMMMMMM': "[0-9]{7,}",
'PATCH' : "[0-9]+",
'PP' : "[0-9]{2,}",
'PPP' : "[0-9]{3,}",
'PPPP' : "[0-9]{4,}",
'PPPPP' : "[0-9]{5,}",
'PPPPPP' : "[0-9]{6,}",
'PPPPPPP': "[0-9]{7,}",
'BID' : "[1-9][0-9]*",
'BB' : "[1-9][0-9]{1,}",
'BBB' : "[1-9][0-9]{2,}",
'BBBB' : "[1-9][0-9]{3,}",
'BBBBB' : "[1-9][0-9]{4,}",
'BBBBBB' : "[1-9][0-9]{5,}",
'BBBBBBB': "[1-9][0-9]{6,}",
}
def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
for part_name, part_pattern in PART_PATTERNS.items():
named_part_pattern = f"(?P<{part_name}>{part_pattern})"
placeholder = "\u005c{" + part_name + "\u005c}"
pattern = pattern.replace(placeholder, named_part_pattern)
return pattern
def compile_pattern_str(pattern: str) -> str:
for char, escaped in PATTERN_ESCAPES:
pattern = pattern.replace(char, escaped)
return _replace_pattern_parts(pattern)
def compile_pattern(pattern: str) -> typ.Pattern[str]:
pattern_str = compile_pattern_str(pattern)
return re.compile(pattern_str)
def _init_composite_patterns() -> None:
for part_name, part_pattern in COMPOSITE_PART_PATTERNS.items():
part_pattern = part_pattern.replace("{", "\u005c{").replace("}", "\u005c}")
@ -205,3 +196,44 @@ def _init_composite_patterns() -> None:
_init_composite_patterns()
def _compile_pattern_re(normalized_pattern: str) -> typ.Pattern[str]:
escaped_pattern = normalized_pattern
for char, escaped in RE_PATTERN_ESCAPES:
escaped_pattern = escaped_pattern.replace(char, escaped)
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
def _normalized_pattern(version_pattern: str, raw_pattern: str) -> str:
res = raw_pattern.replace(r"{version}", version_pattern)
if version_pattern == r"{pycalver}":
res = res.replace(r"{pep440_version}", r"{pep440_pycalver}")
elif version_pattern == r"{semver}":
res = res.replace(r"{pep440_version}", r"{semver}")
elif version_pattern == r"v{year}{month}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}{month}.{BID}{pep440_tag}")
elif version_pattern == r"{year}{month}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}{month}.{BID}{pep440_tag}")
elif version_pattern == r"v{year}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}.{BID}{pep440_tag}")
elif version_pattern == r"{year}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}.{BID}{pep440_tag}")
elif r"{pep440_version}" in raw_pattern:
logger.warning(f"No mapping of '{version_pattern}' to '{{pep440_version}}'")
return res
@utils.memo
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern:
_raw_pattern = version_pattern if raw_pattern is None else raw_pattern
normalized_pattern = _normalized_pattern(version_pattern, _raw_pattern)
regexp = _compile_pattern_re(normalized_pattern)
return Pattern(version_pattern, normalized_pattern, regexp)
def compile_patterns(version_pattern: str, raw_patterns: typ.List[str]) -> typ.List[Pattern]:
return [compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns]

154
src/bumpver/v1rewrite.py Normal file
View file

@ -0,0 +1,154 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import typing as typ
import logging
from . import parse
from . import config
from . import rewrite
from . import version
from . import regexfmt
from . import v1version
from .patterns import Pattern
logger = logging.getLogger("bumpver.v1rewrite")
def rewrite_lines(
patterns : typ.List[Pattern],
new_vinfo: version.V1VersionInfo,
old_lines: typ.List[str],
) -> typ.List[str]:
"""Replace occurances of patterns in old_lines with new_vinfo."""
found_patterns: typ.Set[Pattern] = set()
new_lines = old_lines[:]
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern)
replacement = v1version.format_version(new_vinfo, match.pattern.raw_pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(patterns) - found_patterns
if non_matched_patterns:
for nmp in non_matched_patterns:
logger.error(f"No match for pattern '{nmp.raw_pattern}'")
msg = (
"\n# "
+ regexfmt.regex101_url(nmp.regexp.pattern)
+ "\nregex = "
+ regexfmt.pyexpr_regex(nmp.regexp.pattern)
)
logger.error(msg)
raise rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
patterns : typ.List[Pattern],
new_vinfo: version.V1VersionInfo,
content : str,
path : str = "<path>",
) -> rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> version_pattern = "{pycalver}"
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> from .v1patterns import compile_pattern
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pycalver}"')]
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>> patterns = [compile_pattern('{semver}', '__version__ = "v{semver}"')]
>>> new_vinfo = v1version.parse_version_info("v1.2.3", "v{semver}")
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_vinfo, old_lines)
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V1VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
"""Iterate over files with version string replaced."""
fobj: typ.IO[str]
for file_path, pattern_strs in rewrite.iter_path_patterns_items(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff(
old_vinfo : version.V1VersionInfo,
new_vinfo : version.V1VersionInfo,
file_patterns: config.PatternsByFile,
) -> str:
"""Generate diffs of rewritten files."""
full_diff = ""
fobj: typ.IO[str]
for file_path, patterns in sorted(rewrite.iter_path_patterns_items(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
has_updated_version = False
for pattern in patterns:
old_str = v1version.format_version(old_vinfo, pattern.raw_pattern)
new_str = v1version.format_version(new_vinfo, pattern.raw_pattern)
if old_str != new_str:
has_updated_version = True
try:
rfd = rfd_from_content(patterns, new_vinfo, content)
except rewrite.NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for file '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
if len(lines) == 0 and has_updated_version:
errmsg = f"No patterns matched for file '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite_files(
file_patterns: config.PatternsByFile,
new_vinfo : version.V1VersionInfo,
) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

View file

@ -1,7 +1,7 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Functions related to version string manipulation."""
@ -9,94 +9,45 @@ import typing as typ
import logging
import datetime as dt
import pkg_resources
import lexid
from . import lex_id
from . import patterns
from . import version
from . import v1patterns
logger = logging.getLogger("pycalver.version")
logger = logging.getLogger("bumpver.v1version")
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo]
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'month_short': 'month',
'pep440_tag' : 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'dom_short' : 'dom',
'doy_short' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool:
"""Is left > right for non-None fields."""
lvals = []
rvals = []
for field in version.V1CalendarInfo._fields:
lval = getattr(left , field)
rval = getattr(right, field)
if not (lval is None or rval is None):
lvals.append(lval)
rvals.append(rval)
return lvals > rvals
class CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year : int
quarter : int
month : int
dom : int
doy : int
iso_week: int
us_week : int
def _ver_to_cal_info(vnfo: version.V1VersionInfo) -> version.V1CalendarInfo:
return version.V1CalendarInfo(
vnfo.year,
vnfo.quarter,
vnfo.month,
vnfo.dom,
vnfo.doy,
vnfo.iso_week,
vnfo.us_week,
)
def _date_from_doy(year: int, doy: int) -> dt.date:
"""Parse date from year and day of year (1 indexed).
>>> cases = [
... (2016, 1), (2016, 31), (2016, 31 + 1), (2016, 31 + 29), (2016, 31 + 30),
... (2017, 1), (2017, 31), (2017, 31 + 1), (2017, 31 + 28), (2017, 31 + 29),
... ]
>>> dates = [_date_from_doy(year, month) for year, month in cases]
>>> assert [(d.month, d.day) for d in dates] == [
... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1),
... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1),
... ]
"""
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
def _quarter_from_month(month: int) -> int:
"""Calculate quarter (1 indexed) from month (1 indexed).
>>> [_quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def cal_info(date: dt.date = None) -> CalendarInfo:
def cal_info(date: dt.date = None) -> version.V1CalendarInfo:
"""Generate calendar components for current date.
>>> from datetime import date
@ -118,11 +69,11 @@ def cal_info(date: dt.date = None) -> CalendarInfo:
(2019, 2, 4, 7, 97, 13, 14)
"""
if date is None:
date = TODAY
date = version.TODAY
kwargs = {
'year' : date.year,
'quarter' : _quarter_from_month(date.month),
'quarter' : version.quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
@ -130,24 +81,7 @@ def cal_info(date: dt.date = None) -> CalendarInfo:
'us_week' : int(date.strftime("%U"), base=10),
}
return CalendarInfo(**kwargs)
class VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year : typ.Optional[int]
quarter : typ.Optional[int]
month : typ.Optional[int]
dom : typ.Optional[int]
doy : typ.Optional[int]
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
major : int
minor : int
patch : int
bid : str
tag : str
return version.V1CalendarInfo(**kwargs)
FieldKey = str
@ -158,24 +92,27 @@ PatternGroups = typ.Dict[MatchGroupKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey , MatchGroupStr]
def _parse_field_values(field_values: FieldValues) -> VersionInfo:
def _parse_field_values(field_values: FieldValues) -> version.V1VersionInfo:
fvals = field_values
tag = fvals.get('tag')
if tag is None:
tag = "final"
tag = TAG_ALIASES.get(tag, tag)
tag = version.TAG_BY_PEP440_TAG.get(tag, tag)
assert tag is not None
bid = fvals['bid'] if 'bid' in fvals else "0001"
year = int(fvals['year']) if 'year' in fvals else None
doy = int(fvals['doy' ]) if 'doy' in fvals else None
if year is not None and year < 100:
year += 2000
doy = int(fvals['doy']) if 'doy' in fvals else None
month: typ.Optional[int]
dom : typ.Optional[int]
if year and doy:
date = _date_from_doy(year, doy)
date = version.date_from_doy(year, doy)
month = date.month
dom = date.day
else:
@ -196,13 +133,13 @@ def _parse_field_values(field_values: FieldValues) -> VersionInfo:
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = _quarter_from_month(month)
quarter = version.quarter_from_month(month)
major = int(fvals['major']) if 'major' in fvals else 0
minor = int(fvals['minor']) if 'minor' in fvals else 0
patch = int(fvals['patch']) if 'patch' in fvals else 0
return VersionInfo(
return version.V1VersionInfo(
year=year,
quarter=quarter,
month=month,
@ -218,7 +155,7 @@ def _parse_field_values(field_values: FieldValues) -> VersionInfo:
)
def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool:
def _is_calver(cinfo: CalInfo) -> bool:
"""Check pattern for any calendar based parts.
>>> _is_calver(cal_info())
@ -232,46 +169,30 @@ def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool:
>>> _is_calver(vnfo)
False
"""
for field in CalendarInfo._fields:
maybe_val: typ.Any = getattr(nfo, field, None)
for field in version.V1CalendarInfo._fields:
maybe_val: typ.Any = getattr(cinfo, field, None)
if isinstance(maybe_val, int):
return True
return False
TAG_ALIASES: typ.Dict[str, str] = {'a': "alpha", 'b': "beta", 'pre': "rc"}
PEP440_TAGS: typ.Dict[str, str] = {
'alpha': "a",
'beta' : "b",
'final': "",
'rc' : "rc",
'dev' : "dev",
'post' : "post",
}
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
class PatternError(Exception):
pass
def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues:
for part_name in pattern_groups.keys():
is_valid_part_name = (
part_name in patterns.COMPOSITE_PART_PATTERNS or part_name in PATTERN_PART_FIELDS
part_name in v1patterns.COMPOSITE_PART_PATTERNS
or part_name in v1patterns.PATTERN_PART_FIELDS
)
if not is_valid_part_name:
err_msg = f"Invalid part '{part_name}'"
raise PatternError(err_msg)
raise version.PatternError(err_msg)
field_value_items = [
(field_name, pattern_groups[part_name])
for part_name, field_name in PATTERN_PART_FIELDS.items()
for part_name, field_name in v1patterns.PATTERN_PART_FIELDS.items()
if part_name in pattern_groups.keys()
]
@ -281,18 +202,22 @@ def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues:
if any(duplicate_fields):
err_msg = f"Multiple parts for same field {duplicate_fields}."
raise PatternError(err_msg)
raise version.PatternError(err_msg)
else:
return dict(field_value_items)
def _parse_version_info(pattern_groups: PatternGroups) -> VersionInfo:
"""Parse normalized VersionInfo from groups of a matched pattern.
def _parse_version_info(pattern_groups: PatternGroups) -> version.V1VersionInfo:
"""Parse normalized V1VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year': "18", 'month': "11"})
>>> (vnfo.year, vnfo.month, vnfo.quarter)
(2018, 11, 4)
>>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"})
>>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag)
(2018, 1, 11, '099', 'beta')
@ -309,42 +234,43 @@ def _parse_version_info(pattern_groups: PatternGroups) -> VersionInfo:
return _parse_field_values(field_values)
def parse_version_info(version_str: str, pattern: str = "{pycalver}") -> VersionInfo:
"""Parse normalized VersionInfo.
def parse_version_info(version_str: str, raw_pattern: str = "{pycalver}") -> version.V1VersionInfo:
"""Parse normalized V1VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> vnfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}")
>>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"})
>>> vnfo = parse_version_info("1.23.456", pattern="{semver}")
>>> vnfo = parse_version_info("1.23.456", raw_pattern="{semver}")
>>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"})
"""
regex = patterns.compile_pattern(pattern)
match = regex.match(version_str)
pattern = v1patterns.compile_pattern(raw_pattern)
match = pattern.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' for pattern '{pattern}'/'{regex.pattern}'"
f"Invalid version string '{version_str}' "
f"for pattern '{raw_pattern}'/'{pattern.regexp.pattern}'"
)
raise PatternError(err_msg)
raise version.PatternError(err_msg)
else:
return _parse_version_info(match.groupdict())
def is_valid(version_str: str, pattern: str = "{pycalver}") -> bool:
def is_valid(version_str: str, raw_pattern: str = "{pycalver}") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", pattern="{pycalver}")
>>> is_valid("v201712.0033-beta", raw_pattern="{pycalver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
>>> is_valid("v201712.0033-beta", raw_pattern="{semver}")
False
>>> is_valid("1.2.3", pattern="{semver}")
>>> is_valid("1.2.3", raw_pattern="{semver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
>>> is_valid("v201712.0033-beta", raw_pattern="{semver}")
False
"""
try:
parse_version_info(version_str, pattern)
parse_version_info(version_str, raw_pattern)
return True
except PatternError:
except version.PatternError:
return False
@ -374,60 +300,60 @@ ID_FIELDS_BY_PART = {
}
def format_version(vinfo: VersionInfo, pattern: str) -> str:
def format_version(vinfo: version.V1VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}")
>>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict())
>>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict())
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_a, pattern="v{yy}.{BID}{release}")
>>> format_version(vinfo_a, raw_pattern="v{yy}.{BID}{release}")
'v17.33-beta'
>>> format_version(vinfo_a, pattern="{pep440_version}")
>>> format_version(vinfo_a, raw_pattern="{pep440_version}")
'201701.33b0'
>>> format_version(vinfo_a, pattern="{pycalver}")
>>> format_version(vinfo_a, raw_pattern="{pycalver}")
'v201701.0033-beta'
>>> format_version(vinfo_b, pattern="{pycalver}")
>>> format_version(vinfo_b, raw_pattern="{pycalver}")
'v201712.0033-beta'
>>> format_version(vinfo_a, pattern="v{year}w{iso_week}.{BID}{release}")
>>> format_version(vinfo_a, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w00.33-beta'
>>> format_version(vinfo_b, pattern="v{year}w{iso_week}.{BID}{release}")
>>> format_version(vinfo_b, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33-beta'
>>> format_version(vinfo_a, pattern="v{year}d{doy}.{bid}{release}")
>>> format_version(vinfo_a, raw_pattern="v{year}d{doy}.{bid}{release}")
'v2017d001.0033-beta'
>>> format_version(vinfo_b, pattern="v{year}d{doy}.{bid}{release}")
>>> format_version(vinfo_b, raw_pattern="v{year}d{doy}.{bid}{release}")
'v2017d365.0033-beta'
>>> format_version(vinfo_c, pattern="v{year}w{iso_week}.{BID}-{tag}")
>>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}-{tag}")
'v2017w52.33-final'
>>> format_version(vinfo_c, pattern="v{year}w{iso_week}.{BID}{release}")
>>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33'
>>> format_version(vinfo_c, pattern="v{MAJOR}.{MINOR}.{PATCH}")
>>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MINOR}.{PATCH}")
'v1.2.34'
>>> format_version(vinfo_c, pattern="v{MAJOR}.{MM}.{PPP}")
>>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MM}.{PPP}")
'v1.02.034'
"""
full_pattern = pattern
for part_name, full_part_format in patterns.FULL_PART_FORMATS.items():
full_pattern = raw_pattern
for part_name, full_part_format in v1patterns.FULL_PART_FORMATS.items():
full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format)
kwargs: typ.Dict[str, typ.Union[str, int, None]] = vinfo._asdict()
tag = vinfo.tag
if tag == 'final':
release_tag = vinfo.tag
if release_tag == 'final':
kwargs['release' ] = ""
kwargs['pep440_tag'] = ""
else:
kwargs['release' ] = "-" + tag
kwargs['pep440_tag'] = PEP440_TAGS[tag] + "0"
kwargs['release' ] = "-" + release_tag
kwargs['pep440_tag'] = version.PEP440_TAG_BY_TAG[release_tag] + "0"
kwargs['release_tag'] = tag
kwargs['release_tag'] = release_tag
year = vinfo.year
if year:
@ -453,36 +379,35 @@ def format_version(vinfo: VersionInfo, pattern: str) -> str:
def incr(
old_version: str,
pattern : str = "{pycalver}",
raw_pattern: str = "{pycalver}",
*,
release: str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : typ.Optional[str] = None,
tag_num : bool = False,
pin_date: bool = False,
date : typ.Optional[dt.date] = None,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'pattern'
"""
try:
old_vinfo = parse_version_info(old_version, pattern)
except PatternError as ex:
old_vinfo = parse_version_info(old_version, raw_pattern)
except version.PatternError as ex:
logger.error(str(ex))
return None
cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info(date)
if _is_cal_gt(old_vinfo, cur_cinfo):
logger.warning(f"Old version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo
cur_cal_nfo = cal_info()
old_date = (old_vinfo.year or 0, old_vinfo.month or 0, old_vinfo.dom or 0)
cur_date = (cur_cal_nfo.year , cur_cal_nfo.month , cur_cal_nfo.dom)
if old_date <= cur_date:
cur_vinfo = cur_vinfo._replace(**cur_cal_nfo._asdict())
else:
logger.warning(f"Version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
cur_vinfo = cur_vinfo._replace(bid=lex_id.next_id(cur_vinfo.bid))
cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid))
if major:
cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0)
@ -490,22 +415,14 @@ def incr(
cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0)
if patch:
cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1)
if tag_num:
raise NotImplementedError("--tag-num not supported for old style patterns")
if tag:
cur_vinfo = cur_vinfo._replace(tag=tag)
if release:
cur_vinfo = cur_vinfo._replace(tag=release)
new_version = format_version(cur_vinfo, pattern)
new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.")
return None
else:
return new_version
def to_pep440(version: str) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
>>> to_pep440("v201811.0007-beta")
'201811.7b0'
"""
return str(pkg_resources.parse_version(version))

346
src/bumpver/v2patterns.py Normal file
View file

@ -0,0 +1,346 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Compose Regular Expressions from Patterns.
>>> pattern = compile_pattern("vYYYY0M.BUILD[-TAG]")
>>> version_info = pattern.regexp.match("v201712.0123-alpha")
>>> assert version_info.groupdict() == {
... "year_y" : "2017",
... "month" : "12",
... "bid" : "0123",
... "tag" : "alpha",
... }
>>>
>>> version_info = pattern.regexp.match("201712.1234")
>>> assert version_info is None
>>> version_info = pattern.regexp.match("v201713.1234")
>>> assert version_info is None
>>> version_info = pattern.regexp.match("v201712.1234")
>>> assert version_info.groupdict() == {
... "year_y" : "2017",
... "month" : "12",
... "bid" : "1234",
... "tag" : None,
... }
"""
import re
import typing as typ
import logging
from . import utils
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("bumpver.v2patterns")
# NOTE (mb 2020-09-17): For patterns with different options '(AAA|BB|C)', the
# patterns with more digits should be first/left of those with fewer digits:
#
# good: (?:1[0-2]|[1-9])
# bad: (?:[1-9]|1[0-2])
#
# This ensures that the longest match is done for a pattern.
#
# This implies that patterns for smaller numbers sometimes must be right of
# those for larger numbers. To be consistent we use this ordering not
# sometimes but always (even though in theory it wouldn't matter):
#
# good: (?:3[0-1]|[1-2][0-9]|[1-9])
# bad: (?:[1-2][0-9]|3[0-1]|[1-9])
PART_PATTERNS = {
# Based on calver.org
'YYYY': r"[1-9][0-9]{3}",
'YY' : r"[1-9][0-9]?",
'0Y' : r"[0-9]{2}",
'GGGG': r"[1-9][0-9]{3}",
'GG' : r"[1-9][0-9]?",
'0G' : r"[0-9]{2}",
'Q' : r"[1-4]",
'MM' : r"1[0-2]|[1-9]",
'0M' : r"1[0-2]|0[1-9]",
'DD' : r"3[0-1]|[1-2][0-9]|[1-9]",
'0D' : r"3[0-1]|[1-2][0-9]|0[1-9]",
'JJJ' : r"36[0-6]|3[0-5][0-9]|[1-2][0-9][0-9]|[1-9][0-9]|[1-9]",
'00J' : r"36[0-6]|3[0-5][0-9]|[1-2][0-9][0-9]|0[1-9][0-9]|00[1-9]",
# week numbering parts
'WW': r"5[0-2]|[1-4][0-9]|[0-9]",
'0W': r"5[0-2]|[0-4][0-9]",
'UU': r"5[0-2]|[1-4][0-9]|[0-9]",
'0U': r"5[0-2]|[0-4][0-9]",
'VV': r"5[0-3]|[1-4][0-9]|[1-9]",
'0V': r"5[0-3]|[1-4][0-9]|0[1-9]",
# non calver parts
'MAJOR': r"[0-9]+",
'MINOR': r"[0-9]+",
'PATCH': r"[0-9]+",
'BUILD': r"[0-9]+",
'BLD' : r"[1-9][0-9]*",
'TAG' : r"preview|final|alpha|beta|post|rc",
'PYTAG': r"post|rc|a|b",
'NUM' : r"[0-9]+",
'INC0' : r"[0-9]+",
'INC1' : r"[1-9][0-9]*",
}
PATTERN_PART_FIELDS = {
'YYYY' : 'year_y',
'YY' : 'year_y',
'0Y' : 'year_y',
'GGGG' : 'year_g',
'GG' : 'year_g',
'0G' : 'year_g',
'Q' : 'quarter',
'MM' : 'month',
'0M' : 'month',
'DD' : 'dom',
'0D' : 'dom',
'JJJ' : 'doy',
'00J' : 'doy',
'MAJOR': 'major',
'MINOR': 'minor',
'PATCH': 'patch',
'BUILD': 'bid',
'BLD' : 'bid',
'TAG' : 'tag',
'PYTAG': 'pytag',
'NUM' : 'num',
'INC0' : 'inc0',
'INC1' : 'inc1',
'WW' : 'week_w',
'0W' : 'week_w',
'UU' : 'week_u',
'0U' : 'week_u',
'VV' : 'week_v',
'0V' : 'week_v',
}
PEP440_PART_SUBSTITUTIONS = {
'0W' : "WW",
'0U' : "UU",
'0V' : "VV",
'0M' : "MM",
'0D' : "DD",
'00J' : "JJJ",
'BUILD': "BLD",
'TAG' : "PYTAG",
}
FieldValue = typ.Union[str, int]
def _fmt_num(val: FieldValue) -> str:
return str(val)
def _fmt_bld(val: FieldValue) -> str:
return str(int(val))
def _fmt_yy(year_y: FieldValue) -> str:
return str(int(str(year_y)[-2:]))
def _fmt_0y(year_y: FieldValue) -> str:
return "{0:02}".format(int(str(year_y)[-2:]))
def _fmt_gg(year_g: FieldValue) -> str:
return str(int(str(year_g)[-2:]))
def _fmt_0g(year_g: FieldValue) -> str:
return "{0:02}".format(int(str(year_g)[-2:]))
def _fmt_0m(month: FieldValue) -> str:
return "{0:02}".format(int(month))
def _fmt_0d(dom: FieldValue) -> str:
return "{0:02}".format(int(dom))
def _fmt_00j(doy: FieldValue) -> str:
return "{0:03}".format(int(doy))
def _fmt_0w(week_w: FieldValue) -> str:
return "{0:02}".format(int(week_w))
def _fmt_0u(week_u: FieldValue) -> str:
return "{0:02}".format(int(week_u))
def _fmt_0v(week_v: FieldValue) -> str:
return "{0:02}".format(int(week_v))
FormatterFunc = typ.Callable[[FieldValue], str]
PART_FORMATS: typ.Dict[str, FormatterFunc] = {
'YYYY' : _fmt_num,
'YY' : _fmt_yy,
'0Y' : _fmt_0y,
'GGGG' : _fmt_num,
'GG' : _fmt_gg,
'0G' : _fmt_0g,
'Q' : _fmt_num,
'MM' : _fmt_num,
'0M' : _fmt_0m,
'DD' : _fmt_num,
'0D' : _fmt_0d,
'JJJ' : _fmt_num,
'00J' : _fmt_00j,
'MAJOR': _fmt_num,
'MINOR': _fmt_num,
'PATCH': _fmt_num,
'BUILD': _fmt_num,
'BLD' : _fmt_bld,
'TAG' : _fmt_num,
'PYTAG': _fmt_num,
'NUM' : _fmt_num,
'INC0' : _fmt_num,
'INC1' : _fmt_num,
'WW' : _fmt_num,
'0W' : _fmt_0w,
'UU' : _fmt_num,
'0U' : _fmt_0u,
'VV' : _fmt_num,
'0V' : _fmt_0v,
}
def _convert_to_pep440(version_pattern: str) -> str:
# NOTE (mb 2020-09-20): This does not support some
# corner cases as specified in PEP440, in particular
# related to post and dev releases.
pep440_pattern = version_pattern
if pep440_pattern.startswith("v"):
pep440_pattern = pep440_pattern[1:]
pep440_pattern = pep440_pattern.replace(r"\[", "")
pep440_pattern = pep440_pattern.replace(r"\]", "")
pep440_pattern, _ = re.subn(r"[^a-zA-Z0-9\.\[\]]", "", pep440_pattern)
part_names = list(PATTERN_PART_FIELDS.keys())
part_names.sort(key=len, reverse=True)
for part_name in part_names:
if part_name not in version_pattern:
continue
if part_name not in PEP440_PART_SUBSTITUTIONS:
continue
substitution = PEP440_PART_SUBSTITUTIONS[part_name]
if substitution in pep440_pattern:
continue
is_numerical_part = part_name not in ('TAG', 'PYTAG')
if is_numerical_part:
part_index = pep440_pattern.find(part_name)
is_zero_truncation_part = part_index == 0 or pep440_pattern[part_index - 1] == "."
if is_zero_truncation_part:
pep440_pattern = pep440_pattern.replace(part_name, substitution)
else:
pep440_pattern = pep440_pattern.replace(part_name, substitution)
# PYTAG and NUM must be adjacent and also be the last (optional) part
if 'PYTAGNUM' not in pep440_pattern:
pep440_pattern = pep440_pattern.replace("PYTAG", "")
pep440_pattern = pep440_pattern.replace("NUM" , "")
pep440_pattern = pep440_pattern.replace("[]" , "")
pep440_pattern += "[PYTAGNUM]"
return pep440_pattern
def normalize_pattern(version_pattern: str, raw_pattern: str) -> str:
normalized_pattern = raw_pattern
if "{version}" in raw_pattern:
normalized_pattern = normalized_pattern.replace("{version}", version_pattern)
if "{pep440_version}" in normalized_pattern:
pep440_version_pattern = _convert_to_pep440(version_pattern)
normalized_pattern = normalized_pattern.replace("{pep440_version}", pep440_version_pattern)
return normalized_pattern
def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
while True:
new_pattern, _n = re.subn(r"([^\\]|^)\[", r"\1(?:", pattern)
new_pattern, _m = re.subn(r"([^\\]|^)\]", r"\1)?" , new_pattern)
pattern = new_pattern
if _n + _m == 0:
break
SortKey = typ.Tuple[int, int]
PostitionedPart = typ.Tuple[int, int, str]
part_patterns_by_index: typ.Dict[SortKey, PostitionedPart] = {}
for part_name, part_pattern in PART_PATTERNS.items():
start_idx = pattern.find(part_name)
if start_idx >= 0:
field = PATTERN_PART_FIELDS[part_name]
named_part_pattern = f"(?P<{field}>{part_pattern})"
end_idx = start_idx + len(part_name)
sort_key = (-end_idx, -len(part_name))
part_patterns_by_index[sort_key] = (start_idx, end_idx, named_part_pattern)
# NOTE (mb 2020-09-17): The sorting is done so that we process items:
# - right before left
# - longer before shorter
last_start_idx = len(pattern) + 1
result_pattern = pattern
for _, (start_idx, end_idx, named_part_pattern) in sorted(part_patterns_by_index.items()):
if end_idx <= last_start_idx:
result_pattern = (
result_pattern[:start_idx] + named_part_pattern + result_pattern[end_idx:]
)
last_start_idx = start_idx
return result_pattern
def _compile_pattern_re(normalized_pattern: str) -> typ.Pattern[str]:
escaped_pattern = normalized_pattern
for char, escaped in RE_PATTERN_ESCAPES:
# [] braces are used for optional parts, such as [-TAG]/[-beta]
# and need to be escaped manually.
is_semantic_char = char in "[]\\"
if not is_semantic_char:
# escape it so it is a literal in the re pattern
escaped_pattern = escaped_pattern.replace(char, escaped)
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
@utils.memo
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern:
_raw_pattern = version_pattern if raw_pattern is None else raw_pattern
normalized_pattern = normalize_pattern(version_pattern, _raw_pattern)
regexp = _compile_pattern_re(normalized_pattern)
return Pattern(version_pattern, normalized_pattern, regexp)
def compile_patterns(version_pattern: str, raw_patterns: typ.List[str]) -> typ.List[Pattern]:
return [compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns]

163
src/bumpver/v2rewrite.py Normal file
View file

@ -0,0 +1,163 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import typing as typ
import logging
from . import parse
from . import config
from . import rewrite
from . import version
from . import regexfmt
from . import v2version
from . import v2patterns
from .patterns import Pattern
logger = logging.getLogger("bumpver.v2rewrite")
def rewrite_lines(
patterns : typ.List[Pattern],
new_vinfo: version.V2VersionInfo,
old_lines: typ.List[str],
) -> typ.List[str]:
"""Replace occurances of patterns in old_lines with new_vinfo."""
found_patterns: typ.Set[Pattern] = set()
new_lines = old_lines[:]
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern)
normalized_pattern = v2patterns.normalize_pattern(
match.pattern.version_pattern, match.pattern.raw_pattern
)
replacement = v2version.format_version(new_vinfo, normalized_pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(patterns) - found_patterns
if non_matched_patterns:
for nmp in non_matched_patterns:
logger.error(f"No match for pattern '{nmp.raw_pattern}'")
msg = (
"\n# "
+ regexfmt.regex101_url(nmp.regexp.pattern)
+ "\nregex = "
+ regexfmt.pyexpr_regex(nmp.regexp.pattern)
)
logger.error(msg)
raise rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
patterns : typ.List[Pattern],
new_vinfo: version.V2VersionInfo,
content : str,
path : str = "<path>",
) -> rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> from .v2patterns import compile_pattern
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> new_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "vYYYY0M.BUILD[-TAG]"')]
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>> version_pattern = "vMAJOR.MINOR.PATCH"
>>> new_vinfo = v2version.parse_version_info("v1.2.3", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "vMAJOR.MINOR.PATCH"')]
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_vinfo, old_lines)
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines)
def _patterns_with_change(
old_vinfo: version.V2VersionInfo, new_vinfo: version.V2VersionInfo, patterns: typ.List[Pattern]
) -> int:
patterns_with_change = 0
for pattern in patterns:
old_str = v2version.format_version(old_vinfo, pattern.raw_pattern)
new_str = v2version.format_version(new_vinfo, pattern.raw_pattern)
if old_str != new_str:
patterns_with_change += 1
return patterns_with_change
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
"""Iterate over files with version string replaced."""
fobj: typ.IO[str]
for file_path, patterns in rewrite.iter_path_patterns_items(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(patterns, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff(
old_vinfo : version.V2VersionInfo,
new_vinfo : version.V2VersionInfo,
file_patterns: config.PatternsByFile,
) -> str:
r"""Generate diffs of rewritten files."""
full_diff = ""
fobj: typ.IO[str]
for file_path, patterns in sorted(rewrite.iter_path_patterns_items(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
try:
rfd = rfd_from_content(patterns, new_vinfo, content)
except rewrite.NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for file '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
patterns_with_change = _patterns_with_change(old_vinfo, new_vinfo, patterns)
if len(lines) == 0 and patterns_with_change > 0:
errmsg = f"No patterns matched for file '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite_files(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

753
src/bumpver/v2version.py Normal file
View file

@ -0,0 +1,753 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Functions related to version string manipulation."""
import typing as typ
import logging
import datetime as dt
import lexid
from . import version
from . import v2patterns
logger = logging.getLogger("bumpver.v2version")
CalInfo = typ.Union[version.V2CalendarInfo, version.V2VersionInfo]
def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool:
"""Is left > right for non-None fields."""
lvals = []
rvals = []
for field in version.V2CalendarInfo._fields:
lval = getattr(left , field)
rval = getattr(right, field)
if not (lval is None or rval is None):
lvals.append(lval)
rvals.append(rval)
return lvals > rvals
def _ver_to_cal_info(vinfo: version.V2VersionInfo) -> version.V2CalendarInfo:
return version.V2CalendarInfo(
vinfo.year_y,
vinfo.year_g,
vinfo.quarter,
vinfo.month,
vinfo.dom,
vinfo.doy,
vinfo.week_w,
vinfo.week_u,
vinfo.week_v,
)
def cal_info(date: dt.date = None) -> version.V2CalendarInfo:
"""Generate calendar components for current date.
>>> import datetime as dt
>>> c = cal_info(dt.date(2019, 1, 5))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 1, 1, 5, 5, 0, 0, 1)
>>> c = cal_info(dt.date(2019, 1, 6))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 1, 1, 6, 6, 0, 1, 1)
>>> c = cal_info(dt.date(2019, 1, 7))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 1, 1, 7, 7, 1, 1, 2)
>>> c = cal_info(dt.date(2019, 4, 7))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 2, 4, 7, 97, 13, 14, 14)
"""
if date is None:
date = version.TODAY
kwargs = {
'year_y' : date.year,
'year_g' : int(date.strftime("%G"), base=10),
'quarter': version.quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
'week_w' : int(date.strftime("%W"), base=10),
'week_u' : int(date.strftime("%U"), base=10),
'week_v' : int(date.strftime("%V"), base=10),
}
return version.V2CalendarInfo(**kwargs)
VALID_FIELD_KEYS = set(version.V2VersionInfo._fields) | {'version'}
MaybeInt = typ.Optional[int]
FieldKey = str
MatchGroupKey = str
MatchGroupStr = str
PatternGroups = typ.Dict[FieldKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey, MatchGroupStr]
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def parse_field_values_to_cinfo(field_values: FieldValues) -> version.V2CalendarInfo:
"""Parse normalized V2CalendarInfo from groups of a matched pattern.
>>> cinfo = parse_field_values_to_cinfo({'year_y': "2021", 'week_w': "02"})
>>> (cinfo.year_y, cinfo.week_w)
(2021, 2)
>>> cinfo = parse_field_values_to_cinfo({'year_y': "2021", 'week_u': "02"})
>>> (cinfo.year_y, cinfo.week_u)
(2021, 2)
>>> cinfo = parse_field_values_to_cinfo({'year_g': "2021", 'week_v': "02"})
>>> (cinfo.year_g, cinfo.week_v)
(2021, 2)
>>> cinfo = parse_field_values_to_cinfo({'year_y': "2021", 'month': "01", 'dom': "03"})
>>> (cinfo.year_y, cinfo.month, cinfo.dom)
(2021, 1, 3)
>>> (cinfo.year_y, cinfo.week_w, cinfo.year_y, cinfo.week_u,cinfo.year_g, cinfo.week_v)
(2021, 0, 2021, 1, 2020, 53)
"""
fvals = field_values
date: typ.Optional[dt.date] = None
year_y: MaybeInt = int(fvals['year_y']) if 'year_y' in fvals else None
year_g: MaybeInt = int(fvals['year_g']) if 'year_g' in fvals else None
if year_y is not None and year_y < 1000:
year_y += 2000
if year_g is not None and year_g < 1000:
year_g += 2000
month: MaybeInt = int(fvals['month']) if 'month' in fvals else None
doy : MaybeInt = int(fvals['doy' ]) if 'doy' in fvals else None
dom : MaybeInt = int(fvals['dom' ]) if 'dom' in fvals else None
week_w: MaybeInt = int(fvals['week_w']) if 'week_w' in fvals else None
week_u: MaybeInt = int(fvals['week_u']) if 'week_u' in fvals else None
week_v: MaybeInt = int(fvals['week_v']) if 'week_v' in fvals else None
if year_y and doy:
date = version.date_from_doy(year_y, doy)
month = date.month
dom = date.day
else:
month = int(fvals['month']) if 'month' in fvals else None
dom = int(fvals['dom' ]) if 'dom' in fvals else None
if year_y and month and dom:
date = dt.date(year_y, month, dom)
if date:
# derive all fields from other previous values
year_y = int(date.strftime("%Y"), base=10)
year_g = int(date.strftime("%G"), base=10)
month = int(date.strftime("%m"), base=10)
dom = int(date.strftime("%d"), base=10)
doy = int(date.strftime("%j"), base=10)
week_w = int(date.strftime("%W"), base=10)
week_u = int(date.strftime("%U"), base=10)
week_v = int(date.strftime("%V"), base=10)
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = version.quarter_from_month(month)
return version.V2CalendarInfo(
year_y=year_y,
year_g=year_g,
quarter=quarter,
month=month,
dom=dom,
doy=doy,
week_w=week_w,
week_u=week_u,
week_v=week_v,
)
def parse_field_values_to_vinfo(field_values: FieldValues) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo from groups of a matched pattern.
>>> vinfo = parse_field_values_to_vinfo({'year_y': "2018", 'month': "11", 'bid': "0099"})
>>> (vinfo.year_y, vinfo.month, vinfo.quarter, vinfo.bid, vinfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vinfo = parse_field_values_to_vinfo({'year_y': "18", 'month': "11"})
>>> (vinfo.year_y, vinfo.month, vinfo.quarter)
(2018, 11, 4)
>>> vinfo = parse_field_values_to_vinfo({'year_y': "2018", 'doy': "11", 'bid': "099", 'tag': "beta"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy, vinfo.bid, vinfo.tag)
(2018, 1, 11, 11, '099', 'beta')
>>> vinfo = parse_field_values_to_vinfo({'year_y': "2018", 'month': "6", 'dom': "15"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy)
(2018, 6, 15, 166)
>>> vinfo = parse_field_values_to_vinfo({'major': "1", 'minor': "23", 'patch': "45"})
>>> (vinfo.major, vinfo.minor, vinfo.patch)
(1, 23, 45)
>>> vinfo = parse_field_values_to_vinfo({'major': "1", 'minor': "023", 'patch': "0045"})
>>> (vinfo.major, vinfo.minor, vinfo.patch, vinfo.tag)
(1, 23, 45, 'final')
"""
# pylint:disable=dangerous-default-value; We don't mutate args, mypy would fail if we did.
for key in field_values:
assert key in VALID_FIELD_KEYS, key
cinfo = parse_field_values_to_cinfo(field_values)
fvals = field_values
tag = fvals.get('tag' ) or ""
pytag = fvals.get('pytag') or ""
if tag and not pytag:
pytag = version.PEP440_TAG_BY_TAG[tag]
elif pytag and not tag:
tag = version.TAG_BY_PEP440_TAG[pytag]
if not tag:
tag = "final"
# NOTE (mb 2020-09-18): If a part is optional, fvals[<field>] may be None
major = int(fvals.get('major') or 0)
minor = int(fvals.get('minor') or 0)
patch = int(fvals.get('patch') or 0)
num = int(fvals.get('num' ) or 0)
bid = fvals['bid'] if 'bid' in fvals else "1000"
inc0 = int(fvals.get('inc0') or 0)
inc1 = int(fvals.get('inc1') or 1)
return version.V2VersionInfo(
year_y=cinfo.year_y,
year_g=cinfo.year_g,
quarter=cinfo.quarter,
month=cinfo.month,
dom=cinfo.dom,
doy=cinfo.doy,
week_w=cinfo.week_w,
week_u=cinfo.week_u,
week_v=cinfo.week_v,
major=major,
minor=minor,
patch=patch,
num=num,
bid=bid,
tag=tag,
pytag=pytag,
inc0=inc0,
inc1=inc1,
)
def parse_version_info(
version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG]"
) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo.
>>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
>>> vinfo = parse_version_info("v201712.0033", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033"}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
>>> vinfo = parse_version_info("201712.33b0", raw_pattern="YYYY0M.BLD[PYTAGNUM]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "33", 'tag': "beta", 'num': 0}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
>>> vinfo = parse_version_info("1.23.456", raw_pattern="MAJOR.MINOR.PATCH")
>>> fvals = {'major': "1", 'minor': "23", 'patch': "456"}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
"""
pattern = v2patterns.compile_pattern(raw_pattern)
match = pattern.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{raw_pattern}'/'{pattern.regexp.pattern}'"
)
raise version.PatternError(err_msg)
elif len(match.group()) < len(version_str):
err_msg = (
f"Incomplete match '{match.group()}' for version string '{version_str}' "
f"with pattern '{raw_pattern}'/'{pattern.regexp.pattern}'"
)
raise version.PatternError(err_msg)
else:
field_values = match.groupdict()
return parse_field_values_to_vinfo(field_values)
def is_valid(version_str: str, raw_pattern: str = "vYYYY.BUILD[-TAG]") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
True
>>> is_valid("v201712.0033-beta", raw_pattern="MAJOR.MINOR.PATCH")
False
>>> is_valid("1.2.3", raw_pattern="MAJOR.MINOR.PATCH")
True
>>> is_valid("v201712.0033-beta", raw_pattern="MAJOR.MINOR.PATCH")
False
"""
try:
parse_version_info(version_str, raw_pattern)
return True
except version.PatternError:
return False
TemplateKwargs = typ.Dict[str, typ.Union[str, int, None]]
PartValues = typ.List[typ.Tuple[str, str]]
def _format_part_values(vinfo: version.V2VersionInfo) -> PartValues:
"""Generate kwargs for template from minimal V2VersionInfo.
The V2VersionInfo Tuple only has the minimal representation
of a parsed version, not the values suitable for formatting.
It may for example have month=9, but not the formatted
representation '09' for '0M'.
>>> vinfo = parse_version_info("v200709.1033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> kwargs = dict(_format_part_values(vinfo))
>>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['TAG'])
('2007', '09', '1033', 'beta')
>>> (kwargs['YY'], kwargs['0Y'], kwargs['MM'], kwargs['PYTAG'])
('7', '07', '9', 'b')
>>> vinfo = parse_version_info("200709.1033b1", raw_pattern="YYYY0M.BLD[PYTAGNUM]")
>>> kwargs = dict(_format_part_values(vinfo))
>>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['PYTAG'], kwargs['NUM'])
('2007', '09', '1033', 'b', '1')
"""
vnfo_kwargs: TemplateKwargs = vinfo._asdict()
kwargs : typ.Dict[str, str] = {}
for part, field in v2patterns.PATTERN_PART_FIELDS.items():
field_val = vnfo_kwargs[field]
if field_val is not None:
format_fn = v2patterns.PART_FORMATS[part]
kwargs[part] = format_fn(field_val)
return sorted(kwargs.items(), key=lambda item: -len(item[0]))
Segment = str
# mypy limitation wrt. cyclic definition
# SegmentTree = typ.List[typ.Union[Segment, "SegmentTree"]]
SegmentTree = typ.Any
def _parse_segtree(raw_pattern: str) -> SegmentTree:
"""Generate segment tree from pattern string.
>>> _parse_segtree('aa[bb[cc]]')
['aa', ['bb', ['cc']]]
>>> _parse_segtree('aa[bb[cc]dd[ee]ff]gg')
['aa', ['bb', ['cc'], 'dd', ['ee'], 'ff'], 'gg']
"""
internal_root: SegmentTree = []
branch_stack : typ.List[SegmentTree] = [internal_root]
segment_start_index = -1
raw_pattern = "[" + raw_pattern + "]"
for i, char in enumerate(raw_pattern):
is_escaped = i > 0 and raw_pattern[i - 1] == "\\"
if char in "[]" and not is_escaped:
start = segment_start_index + 1
end = i
if start < end:
branch_stack[-1].append(raw_pattern[start:end])
if char == "[":
new_branch: SegmentTree = []
branch_stack[-1].append(new_branch)
branch_stack.append(new_branch)
segment_start_index = i
elif char == "]":
if len(branch_stack) == 1:
err = f"Unbalanced brace(s) in '{raw_pattern}'"
raise ValueError(err)
branch_stack.pop()
segment_start_index = i
else:
raise NotImplementedError("Unreachable")
if len(branch_stack) > 1:
err = f"Unclosed brace in '{raw_pattern}'"
raise ValueError(err)
return internal_root[0]
FormattedSegmentParts = typ.List[str]
class FormatedSeg(typ.NamedTuple):
is_literal: bool
is_zero : bool
result : str
def _format_segment(seg: Segment, part_values: PartValues) -> FormatedSeg:
zero_part_count = 0
# find all parts, regardless of zero value
used_parts: typ.List[typ.Tuple[str, str]] = []
for part, part_value in part_values:
if part in seg:
used_parts.append((part, part_value))
if version.is_zero_val(part, part_value):
zero_part_count += 1
result = seg
# unescape braces
result = result.replace(r"\[", r"[")
result = result.replace(r"\]", r"]")
for part, part_value in used_parts:
result = result.replace(part, part_value)
# If a segment has no parts at all, it is a literal string
# (typically a prefix or sufix) and should be output as is.
is_literal_seg = len(used_parts) == 0
if is_literal_seg:
return FormatedSeg(True, False, result)
elif zero_part_count > 0 and zero_part_count == len(used_parts):
# all zero, omit segment completely
return FormatedSeg(False, True, result)
else:
return FormatedSeg(False, False, result)
def _format_segment_tree(
segtree : SegmentTree,
part_values: PartValues,
) -> FormatedSeg:
# NOTE (mb 2020-10-02): starting from the right, if there is any non-zero
# part, all further parts going left will be used. In other words, a part
# is only omitted, if all parts to the right of it were also omitted.
result_parts: typ.List[str] = []
is_zero = True
for seg in segtree:
if isinstance(seg, list):
formatted_seg = _format_segment_tree(seg, part_values)
else:
formatted_seg = _format_segment(seg, part_values)
if formatted_seg.is_literal:
result_parts.append(formatted_seg.result)
else:
is_zero = is_zero and formatted_seg.is_zero
result_parts.append(formatted_seg.result)
result = "" if is_zero else "".join(result_parts)
return FormatedSeg(False, is_zero, result)
def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v200712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2007, 1, 1))._asdict())
>>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2007, 12, 31))._asdict())
>>> format_version(vinfo_a, raw_pattern="vYY.BLD[-PYTAGNUM]")
'v7.33-b0'
>>> format_version(vinfo_a, raw_pattern="vYY.BLD[-PYTAGNUM]")
'v7.33-b0'
>>> format_version(vinfo_a, raw_pattern="YYYY0M.BUILD[PYTAG[NUM]]")
'200701.0033b'
>>> format_version(vinfo_a, raw_pattern="v0Y.BLD[-TAG]")
'v07.33-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYY0M.BUILD[-TAG]")
'v200701.0033-beta'
>>> format_version(vinfo_b, raw_pattern="vYYYY0M.BUILD[-TAG]")
'v200712.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYw0W.BUILD[-TAG]")
'v2007w01.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYwWW.BLD[-TAG]")
'v2007w1.33-beta'
>>> format_version(vinfo_b, raw_pattern="vYYYYw0W.BUILD[-TAG]")
'v2007w53.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYd00J.BUILD[-TAG]")
'v2007d001.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYdJJJ.BUILD[-TAG]")
'v2007d1.0033-beta'
>>> format_version(vinfo_b, raw_pattern="vYYYYd00J.BUILD[-TAG]")
'v2007d365.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vGGGGwVV.BLD[PYTAGNUM]")
'v2007w1.33b0'
>>> format_version(vinfo_a, raw_pattern="vGGGGw0V.BUILD[-TAG]")
'v2007w01.0033-beta'
>>> format_version(vinfo_b, raw_pattern="vGGGGw0V.BUILD[-TAG]")
'v2008w01.0033-beta'
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_c, raw_pattern="vYYYYwWW.BUILD-TAG")
'v2007w53.0033-final'
>>> format_version(vinfo_c, raw_pattern="vYYYYwWW.BUILD[-TAG]")
'v2007w53.0033'
>>> format_version(vinfo_c, raw_pattern="vMAJOR.MINOR.PATCH")
'v1.2.34'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='final')
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH-TAGNUM")
'v1.0.0-final0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH-TAG")
'v1.0.0-final'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH-TAG")
'v1.0.0-final'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH[-TAG]")
'v1.0.0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR[.PATCH[-TAG]]")
'v1.0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[-TAG]]]")
'v1'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=2, tag='rc', pytag='rc', num=0)
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH]]")
'v1.0.2'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[-TAG]]]")
'v1.0.2-rc'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[PYTAGNUM]]]")
'v1.0.2rc0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH]]")
'v1.0.2'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='rc', num=2)
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[-TAGNUM]]]")
'v1.0.0-rc2'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='rc', num=2)
>>> format_version(vinfo_d, raw_pattern='__version__ = "vMAJOR[.MINOR[.PATCH[-TAGNUM]]]"')
'__version__ = "v1.0.0-rc2"'
"""
part_values = _format_part_values(vinfo)
segtree = _parse_segtree(raw_pattern)
formatted_seg = _format_segment_tree(segtree, part_values)
return formatted_seg.result
def _iter_flat_segtree(segtree: SegmentTree) -> typ.Iterable[Segment]:
"""Flatten a SegmentTree (mixed nested list of lists or str).
>>> list(_iter_flat_segtree(['aa', ['bb', ['cc'], 'dd', ['ee'], 'ff'], 'gg']))
['aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg']
"""
for subtree in segtree:
if isinstance(subtree, list):
for seg in _iter_flat_segtree(subtree):
yield seg
else:
yield subtree
def _parse_pattern_fields(raw_pattern: str) -> typ.List[str]:
parts = list(v2patterns.PATTERN_PART_FIELDS.keys())
parts.sort(key=len, reverse=True)
segtree = _parse_segtree(raw_pattern)
segments = _iter_flat_segtree(segtree)
fields_by_index = {}
for segment_index, segment in enumerate(segments):
for part in parts:
part_index = segment.find(part)
if part_index >= 0:
field = v2patterns.PATTERN_PART_FIELDS[part]
fields_by_index[segment_index, part_index] = field
return [field for _, field in sorted(fields_by_index.items())]
def _iter_reset_field_items(
fields : typ.List[str],
old_vinfo: version.V2VersionInfo,
cur_vinfo: version.V2VersionInfo,
) -> typ.Iterable[typ.Tuple[str, str]]:
# Any field to the left of another can reset all to the right
has_reset = False
for field in fields:
initial_val = version.V2_FIELD_INITIAL_VALUES.get(field)
if has_reset and initial_val is not None:
yield field, initial_val
elif getattr(old_vinfo, field) != getattr(cur_vinfo, field):
has_reset = True
def _incr_numeric(
raw_pattern: str,
old_vinfo : version.V2VersionInfo,
cur_vinfo : version.V2VersionInfo,
major : bool,
minor : bool,
patch : bool,
tag : typ.Optional[str],
tag_num : bool,
) -> version.V2VersionInfo:
"""Increment (and reset to zero) non CalVer parts.
>>> raw_pattern = 'MAJOR.MINOR.PATCH[PYTAGNUM]'
>>> old_vinfo = parse_field_values_to_vinfo({'major': "1", 'minor': "2", 'patch': "3"})
>>> cur_vinfo = old_vinfo
>>> new_vinfo = _incr_numeric(
... raw_pattern,
... cur_vinfo,
... old_vinfo,
... major=False,
... minor=False,
... patch=True,
... tag='beta',
... tag_num=False,
... )
>>> (new_vinfo.major, new_vinfo.minor, new_vinfo.patch, new_vinfo.tag, new_vinfo.pytag, new_vinfo.num)
(1, 2, 4, 'beta', 'b', 0)
"""
# Reset major/minor/patch/num/inc to zero if any part to the left of it is incremented
fields = _parse_pattern_fields(raw_pattern)
reset_fields = dict(_iter_reset_field_items(fields, old_vinfo, cur_vinfo))
cur_kwargs = cur_vinfo._asdict()
cur_kwargs.update(reset_fields)
cur_vinfo = version.V2VersionInfo(**cur_kwargs)
# prevent truncation of leading zeros
if int(cur_vinfo.bid) < 1000:
cur_vinfo = cur_vinfo._replace(bid=str(int(cur_vinfo.bid) + 1000))
cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid))
if 'inc0' in reset_fields:
cur_vinfo = cur_vinfo._replace(inc0=0)
else:
cur_vinfo = cur_vinfo._replace(inc0=cur_vinfo.inc0 + 1)
if 'inc1' in reset_fields:
cur_vinfo = cur_vinfo._replace(inc1=1)
else:
cur_vinfo = cur_vinfo._replace(inc1=cur_vinfo.inc1 + 1)
if major and 'major' not in reset_fields:
cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0)
if minor and 'minor' not in reset_fields:
cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0)
if patch and 'patch' not in reset_fields:
cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1)
if tag_num and 'tag_num' not in reset_fields:
cur_vinfo = cur_vinfo._replace(num=cur_vinfo.num + 1)
if tag and 'tag' not in reset_fields:
if tag != cur_vinfo.tag:
cur_vinfo = cur_vinfo._replace(num=0)
cur_vinfo = cur_vinfo._replace(tag=tag)
if cur_vinfo.tag and not cur_vinfo.pytag:
pytag = version.PEP440_TAG_BY_TAG[cur_vinfo.tag]
cur_vinfo = cur_vinfo._replace(pytag=pytag)
elif cur_vinfo.pytag and not cur_vinfo.tag:
tag = version.TAG_BY_PEP440_TAG[cur_vinfo.pytag]
cur_vinfo = cur_vinfo._replace(tag=tag)
return cur_vinfo
def is_valid_week_pattern(raw_pattern: str) -> bool:
has_yy_part = any(part in raw_pattern for part in ["YYYY", "YY", "0Y"])
has_ww_part = any(part in raw_pattern for part in ["WW" , "0W", "UU", "0U"])
has_gg_part = any(part in raw_pattern for part in ["GGGG", "GG", "0G"])
has_vv_part = any(part in raw_pattern for part in ["VV" , "0V"])
if has_yy_part and has_vv_part:
alt1 = raw_pattern.replace("V", "W")
alt2 = raw_pattern.replace("Y", "G")
logger.error(f"Invalid pattern: '{raw_pattern}'. Maybe try {alt1} or {alt2}")
return False
elif has_gg_part and has_ww_part:
alt1 = raw_pattern.replace("W", "V").replace("U", "V")
alt2 = raw_pattern.replace("G", "Y")
logger.error(f"Invalid pattern: '{raw_pattern}'. Maybe try {alt1} or {alt2}")
return False
else:
return True
def incr(
old_version: str,
raw_pattern: str = "vYYYY0M.BUILD[-TAG]",
*,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : typ.Optional[str] = None,
tag_num : bool = False,
pin_date: bool = False,
date : typ.Optional[dt.date] = None,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'raw_pattern'
"""
if not is_valid_week_pattern(raw_pattern):
return None
try:
old_vinfo = parse_version_info(old_version, raw_pattern)
except version.PatternError as ex:
logger.error(str(ex))
return None
cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info(date)
if _is_cal_gt(old_vinfo, cur_cinfo):
logger.warning(f"Old version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo
else:
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
cur_vinfo = _incr_numeric(
raw_pattern,
old_vinfo,
cur_vinfo,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
)
new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.")
return None
else:
return new_version

View file

@ -1,12 +1,13 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
#
# pycalver/vcs.py (this file) is based on code from the
# bumpver/vcs.py (this file) is based on code from the
# bumpversion project: https://github.com/peritus/bumpversion
# Copyright (c) 2013-2014 Filip Noetzel - MIT License
"""Minimal Git and Mercirial API.
If terminology for similar concepts differs between git and
@ -15,12 +16,16 @@ mercurial, then the git terms are used. For example "fetch"
"""
import os
import sys
import shlex
import typing as typ
import logging
import tempfile
import subprocess as sp
logger = logging.getLogger("pycalver.vcs")
from . import config
logger = logging.getLogger("bumpver.vcs")
VCS_SUBCOMMANDS_BY_NAME = {
@ -30,9 +35,9 @@ VCS_SUBCOMMANDS_BY_NAME = {
'ls_tags' : "git tag --list",
'status' : "git status --porcelain",
'add_path' : "git add --update {path}",
'commit' : "git commit --file {path}",
'commit' : "git commit --message '{message}'",
'tag' : "git tag --annotate {tag} --message {tag}",
'push_tag' : "git push origin --follow-tags {tag}",
'push_tag' : "git push origin --follow-tags {tag} HEAD",
'show_remotes': "git config --get remote.origin.url",
},
'hg': {
@ -70,11 +75,10 @@ class VCSAPI:
logger.info(cmd_str)
else:
logger.debug(cmd_str)
output_data: bytes = sp.check_output(cmd_str.split(), env=env, stderr=sp.STDOUT)
cmd_parts = shlex.split(cmd_str)
output_data: bytes = sp.check_output(cmd_parts, env=env, stderr=sp.STDOUT)
# TODO (mb 2018-11-15): Detect encoding of output?
_encoding = "utf-8"
return output_data.decode(_encoding)
return output_data.decode("utf-8")
@property
def is_usable(self) -> bool:
@ -96,10 +100,12 @@ class VCSAPI:
@property
def has_remote(self) -> bool:
# pylint:disable=broad-except; Not sure how to anticipate all cases.
try:
output = self('show_remotes')
if output.strip() == "":
return False
else:
return True
except Exception:
return False
@ -139,9 +145,14 @@ class VCSAPI:
def commit(self, message: str) -> None:
"""Commit added files."""
message_data = message.encode("utf-8")
env: Env = os.environ.copy()
if self.name == 'git':
self('commit', env=env, message=message)
else:
message_data = message.encode("utf-8")
tmp_file = tempfile.NamedTemporaryFile("wb", delete=False)
try:
assert " " not in tmp_file.name
fobj: typ.IO[bytes]
@ -149,9 +160,9 @@ class VCSAPI:
with tmp_file as fobj:
fobj.write(message_data)
env: Env = os.environ.copy()
env['HGENCODING'] = "utf-8"
self('commit', env=env, path=tmp_file.name)
finally:
os.unlink(tmp_file.name)
def tag(self, tag_name: str) -> None:
@ -179,3 +190,57 @@ def get_vcs_api() -> VCSAPI:
return vcs_api
raise OSError("No such directory .git/ or .hg/ ")
# cli helper methods
def assert_not_dirty(vcs_api: VCSAPI, filepaths: typ.Set[str], allow_dirty: bool) -> None:
dirty_files = vcs_api.status(required_files=filepaths)
if dirty_files:
logger.warning(f"{vcs_api.name} working directory is not clean. Uncomitted file(s):")
for dirty_file in dirty_files:
logger.warning(" " + dirty_file)
if not allow_dirty and dirty_files:
sys.exit(1)
dirty_pattern_files = set(dirty_files) & filepaths
if dirty_pattern_files:
logger.error("Not commiting when pattern files are dirty:")
for dirty_file in dirty_pattern_files:
logger.warning(" " + dirty_file)
sys.exit(1)
def commit(
cfg : config.Config,
vcs_api : VCSAPI,
filepaths : typ.Set[str],
new_version : str,
commit_message: str,
) -> None:
for filepath in filepaths:
vcs_api.add(filepath)
vcs_api.commit(commit_message)
if cfg.commit and cfg.tag:
vcs_api.tag(new_version)
if cfg.commit and cfg.tag and cfg.push:
vcs_api.push(new_version)
def get_tags(fetch: bool) -> typ.List[str]:
try:
vcs_api = get_vcs_api()
logger.debug(f"vcs found: {vcs_api.name}")
if fetch:
logger.info("fetching tags from remote (to turn off use: -n / --no-fetch)")
vcs_api.fetch()
return vcs_api.ls_tags()
except OSError:
logger.debug("No vcs found")
return []

174
src/bumpver/version.py Normal file
View file

@ -0,0 +1,174 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
import datetime as dt
import pkg_resources
MaybeInt = typ.Optional[int]
class V1CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year : MaybeInt
quarter : MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
iso_week: MaybeInt
us_week : MaybeInt
class V1VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year : MaybeInt
quarter : MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
iso_week: MaybeInt
us_week : MaybeInt
major : int
minor : int
patch : int
bid : str
tag : str
class V2CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
class V2VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
major : int
minor : int
patch : int
num : int
bid : str
tag : str
pytag : str
inc0 : int
inc1 : int
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
TAG_BY_PEP440_TAG = {
'a' : 'alpha',
'b' : 'beta',
'' : 'final',
'rc' : 'rc',
'dev' : 'dev',
'post': 'post',
}
PEP440_TAG_BY_TAG = {
'a' : 'a',
'b' : 'b',
'dev' : 'dev',
'alpha' : 'a',
'beta' : 'b',
'preview': 'rc',
'pre' : 'rc',
'rc' : 'rc',
'c' : 'rc',
'final' : '',
'post' : 'post',
'r' : 'post',
'rev' : 'post',
}
assert set(TAG_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_TAG.values())
assert set(TAG_BY_PEP440_TAG.values()) < set(PEP440_TAG_BY_TAG.keys())
PART_ZERO_VALUES = {
'MAJOR': "0",
'MINOR': "0",
'PATCH': "0",
'TAG' : "final",
'PYTAG': "",
'NUM' : "0",
'INC0' : "0",
}
V2_FIELD_INITIAL_VALUES = {
'major': "0",
'minor': "0",
'patch': "0",
'num' : "0",
'inc0' : "0",
'inc1' : "1",
}
def is_zero_val(part: str, part_value: str) -> bool:
return part in PART_ZERO_VALUES and part_value == PART_ZERO_VALUES[part]
class PatternError(Exception):
pass
def date_from_doy(year: int, doy: int) -> dt.date:
"""Parse date from year and day of year (1 indexed).
>>> cases = [
... (2016, 1), (2016, 31), (2016, 31 + 1), (2016, 31 + 29), (2016, 31 + 30),
... (2017, 1), (2017, 31), (2017, 31 + 1), (2017, 31 + 28), (2017, 31 + 29),
... ]
>>> dates = [date_from_doy(year, month) for year, month in cases]
>>> assert [(d.month, d.day) for d in dates] == [
... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1),
... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1),
... ]
"""
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
def quarter_from_month(month: int) -> int:
"""Calculate quarter (1 indexed) from month (1 indexed).
>>> [quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def to_pep440(version: str) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
>>> to_pep440("v201811.0007-beta")
'201811.7b0'
"""
return str(pkg_resources.parse_version(version))

View file

@ -1,8 +0,0 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""PyCalVer: CalVer for Python Packages."""
__version__ = "v202007.0036"

View file

@ -1,16 +0,0 @@
#!/usr/bin/env python
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""
__main__ module for PyCalVer.
Enables use as module: $ python -m pycalver --version
"""
if __name__ == '__main__':
from . import cli
cli.cli()

View file

@ -1,380 +0,0 @@
#!/usr/bin/env python
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""
CLI module for PyCalVer.
Provided subcommands: show, test, init, bump
"""
import sys
import typing as typ
import logging
import subprocess as sp
import click
from . import vcs
from . import config
from . import rewrite
from . import version
_VERBOSE = 0
try:
import pretty_traceback
pretty_traceback.install()
except ImportError:
pass # no need to fail because of missing dev dependency
click.disable_unicode_literals_warning = True
VALID_RELEASE_VALUES = ("alpha", "beta", "dev", "rc", "post", "final")
logger = logging.getLogger("pycalver.cli")
def _configure_logging(verbose: int = 0) -> None:
if verbose >= 2:
log_format = "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-17s - %(message)s"
log_level = logging.DEBUG
elif verbose == 1:
log_format = "%(levelname)-7s - %(message)s"
log_level = logging.INFO
else:
log_format = "%(levelname)-7s - %(message)s"
log_level = logging.INFO
logging.basicConfig(level=log_level, format=log_format, datefmt="%Y-%m-%dT%H:%M:%S")
logger.debug("Logging configured.")
def _validate_release_tag(release: str) -> None:
if release in VALID_RELEASE_VALUES:
return
logger.error(f"Invalid argument --release={release}")
logger.error(f"Valid arguments are: {', '.join(VALID_RELEASE_VALUES)}")
sys.exit(1)
@click.group()
@click.version_option(version="v202007.0036")
@click.help_option()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
def cli(verbose: int = 0) -> None:
"""Automatically update PyCalVer version strings on python projects."""
global _VERBOSE
_VERBOSE = verbose
@cli.command()
@click.argument("old_version")
@click.argument("pattern", default="{pycalver}")
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"--release", default=None, metavar="<name>", help="Override release name of current_version"
)
@click.option("--major", is_flag=True, default=False, help="Increment major component.")
@click.option("--minor", is_flag=True, default=False, help="Increment minor component.")
@click.option("--patch", is_flag=True, default=False, help="Increment patch component.")
def test(
old_version: str,
pattern : str = "{pycalver}",
verbose : int = 0,
release : str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
) -> None:
"""Increment a version number for demo purposes."""
_configure_logging(verbose=max(_VERBOSE, verbose))
if release:
_validate_release_tag(release)
new_version = version.incr(
old_version, pattern=pattern, release=release, major=major, minor=minor, patch=patch
)
if new_version is None:
logger.error(f"Invalid version '{old_version}' and/or pattern '{pattern}'.")
sys.exit(1)
pep440_version = version.to_pep440(new_version)
click.echo(f"New Version: {new_version}")
click.echo(f"PEP440 : {pep440_version}")
def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
try:
vcs_api = vcs.get_vcs_api()
logger.debug(f"vcs found: {vcs_api.name}")
if fetch:
logger.info("fetching tags from remote (to turn off use: -n / --no-fetch)")
vcs_api.fetch()
version_tags = [
tag for tag in vcs_api.ls_tags() if version.is_valid(tag, cfg.version_pattern)
]
if version_tags:
version_tags.sort(reverse=True)
logger.debug(f"found {len(version_tags)} tags: {version_tags[:2]}")
latest_version_tag = version_tags[0]
latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag > cfg.current_version:
logger.info(f"Working dir version : {cfg.current_version}")
logger.info(f"Latest version from {vcs_api.name:>3} tag: {latest_version_tag}")
cfg = cfg._replace(
current_version=latest_version_tag, pep440_version=latest_version_pep440
)
else:
logger.debug("no vcs tags found")
except OSError:
logger.debug("No vcs found")
return cfg
@cli.command()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"-f/-n", "--fetch/--no-fetch", is_flag=True, default=True, help="Sync tags from remote origin."
)
def show(verbose: int = 0, fetch: bool = True) -> None:
"""Show current version."""
_configure_logging(verbose=max(_VERBOSE, verbose))
ctx: config.ProjectContext = config.init_project_ctx(project_path=".")
cfg: config.MaybeConfig = config.parse(ctx)
if cfg is None:
logger.error("Could not parse configuration. Perhaps try 'pycalver init'.")
sys.exit(1)
cfg = _update_cfg_from_vcs(cfg, fetch=fetch)
click.echo(f"Current Version: {cfg.current_version}")
click.echo(f"PEP440 : {cfg.pep440_version}")
@cli.command()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"--dry", default=False, is_flag=True, help="Display diff of changes, don't rewrite files."
)
def init(verbose: int = 0, dry: bool = False) -> None:
"""Initialize [pycalver] configuration."""
_configure_logging(verbose=max(_VERBOSE, verbose))
ctx: config.ProjectContext = config.init_project_ctx(project_path=".")
cfg: config.MaybeConfig = config.parse(ctx)
if cfg:
logger.error(f"Configuration already initialized in {ctx.config_filepath}")
sys.exit(1)
if dry:
click.echo(f"Exiting because of '--dry'. Would have written to {ctx.config_filepath}:")
cfg_text: str = config.default_config(ctx)
click.echo("\n " + "\n ".join(cfg_text.splitlines()))
sys.exit(0)
config.write_content(ctx)
def _assert_not_dirty(vcs_api: vcs.VCSAPI, filepaths: typ.Set[str], allow_dirty: bool) -> None:
dirty_files = vcs_api.status(required_files=filepaths)
if dirty_files:
logger.warning(f"{vcs_api.name} working directory is not clean. Uncomitted file(s):")
for dirty_file in dirty_files:
logger.warning(" " + dirty_file)
if not allow_dirty and dirty_files:
sys.exit(1)
dirty_pattern_files = set(dirty_files) & filepaths
if dirty_pattern_files:
logger.error("Not commiting when pattern files are dirty:")
for dirty_file in dirty_pattern_files:
logger.warning(" " + dirty_file)
sys.exit(1)
def _commit(
cfg: config.Config, new_version: str, vcs_api: vcs.VCSAPI, filepaths: typ.Set[str]
) -> None:
for filepath in filepaths:
vcs_api.add(filepath)
vcs_api.commit(f"bump version to {new_version}")
if cfg.commit and cfg.tag:
vcs_api.tag(new_version)
if cfg.commit and cfg.tag and cfg.push:
vcs_api.push(new_version)
def _bump(cfg: config.Config, new_version: str, allow_dirty: bool = False) -> None:
vcs_api: typ.Optional[vcs.VCSAPI] = None
if cfg.commit:
try:
vcs_api = vcs.get_vcs_api()
except OSError:
logger.warning("Version Control System not found, aborting commit.")
filepaths = set(cfg.file_patterns.keys())
if vcs_api:
_assert_not_dirty(vcs_api, filepaths, allow_dirty)
try:
new_vinfo = version.parse_version_info(new_version, cfg.version_pattern)
rewrite.rewrite(cfg.file_patterns, new_vinfo)
except Exception as ex:
logger.error(str(ex))
sys.exit(1)
if vcs_api:
_commit(cfg, new_version, vcs_api, filepaths)
def _try_bump(cfg: config.Config, new_version: str, allow_dirty: bool = False) -> None:
try:
_bump(cfg, new_version, allow_dirty)
except sp.CalledProcessError as ex:
logger.error(f"Error running subcommand: {ex.cmd}")
if ex.stdout:
sys.stdout.write(ex.stdout.decode('utf-8'))
if ex.stderr:
sys.stderr.write(ex.stderr.decode('utf-8'))
sys.exit(1)
def _print_diff(cfg: config.Config, new_version: str) -> None:
new_vinfo = version.parse_version_info(new_version, cfg.version_pattern)
diff: str = rewrite.diff(new_vinfo, cfg.file_patterns)
if sys.stdout.isatty():
for line in diff.splitlines():
if line.startswith("+++") or line.startswith("---"):
click.echo(line)
elif line.startswith("+"):
click.echo("\u001b[32m" + line + "\u001b[0m")
elif line.startswith("-"):
click.echo("\u001b[31m" + line + "\u001b[0m")
elif line.startswith("@"):
click.echo("\u001b[36m" + line + "\u001b[0m")
else:
click.echo(line)
else:
click.echo(diff)
def _try_print_diff(cfg: config.Config, new_version: str) -> None:
try:
_print_diff(cfg, new_version)
except Exception as ex:
logger.error(str(ex))
sys.exit(1)
@cli.command()
@click.option("-v", "--verbose", count=True, help="Control log level. -vv for debug level.")
@click.option(
"-f/-n", "--fetch/--no-fetch", is_flag=True, default=True, help="Sync tags from remote origin."
)
@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=(
f"Override release name of current_version. Valid options are: "
f"{', '.join(VALID_RELEASE_VALUES)}."
),
)
@click.option(
"--allow-dirty",
default=False,
is_flag=True,
help=(
"Commit even when working directory is has uncomitted changes. "
"(WARNING: The commit will still be aborted if there are uncomitted "
"to files with version strings."
),
)
@click.option("--major", is_flag=True, default=False, help="Increment major component.")
@click.option("--minor", is_flag=True, default=False, help="Increment minor component.")
@click.option("--patch", is_flag=True, default=False, help="Increment patch component.")
def bump(
release : typ.Optional[str] = None,
verbose : int = 0,
dry : bool = False,
allow_dirty: bool = False,
fetch : bool = True,
major : bool = False,
minor : bool = False,
patch : bool = False,
) -> None:
"""Increment the current version string and update project files."""
verbose = max(_VERBOSE, verbose)
_configure_logging(verbose)
if release:
_validate_release_tag(release)
ctx: config.ProjectContext = config.init_project_ctx(project_path=".")
cfg: config.MaybeConfig = config.parse(ctx)
if cfg is None:
logger.error("Could not parse configuration. Perhaps try 'pycalver init'.")
sys.exit(1)
cfg = _update_cfg_from_vcs(cfg, fetch=fetch)
old_version = cfg.current_version
new_version = version.incr(
old_version,
pattern=cfg.version_pattern,
release=release,
major=major,
minor=minor,
patch=patch,
)
if new_version is None:
is_semver = "{semver}" in cfg.version_pattern
has_semver_inc = major or minor or patch
if is_semver and not has_semver_inc:
logger.warning("bump --major/--minor/--patch required when using semver.")
else:
logger.error(f"Invalid version '{old_version}' and/or pattern '{cfg.version_pattern}'.")
sys.exit(1)
logger.info(f"Old Version: {old_version}")
logger.info(f"New Version: {new_version}")
if dry or verbose >= 2:
_try_print_diff(cfg, new_version)
if dry:
return
_try_bump(cfg, new_version, allow_dirty)
if __name__ == '__main__':
cli()

View file

@ -1,482 +0,0 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Parse setup.cfg or pycalver.cfg files."""
import os
import typing as typ
import logging
import datetime as dt
import configparser
import six
import toml
import pathlib2 as pl
from . import version
logger = logging.getLogger("pycalver.config")
Patterns = typ.List[str]
PatternsByGlob = typ.Dict[str, Patterns]
SUPPORTED_CONFIGS = ["setup.cfg", "pyproject.toml", "pycalver.toml"]
class ProjectContext(typ.NamedTuple):
"""Container class for project info."""
path : pl.Path
config_filepath: pl.Path
config_format : str
vcs_type : typ.Optional[str]
def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> ProjectContext:
"""Initialize ProjectContext from a path."""
if isinstance(project_path, pl.Path):
path = project_path
elif project_path is None:
path = pl.Path(".")
else:
# assume it's a str/unicode
path = pl.Path(project_path)
if (path / "pycalver.toml").exists():
config_filepath = path / "pycalver.toml"
config_format = 'toml'
elif (path / "pyproject.toml").exists():
config_filepath = path / "pyproject.toml"
config_format = 'toml'
elif (path / "setup.cfg").exists():
config_filepath = path / "setup.cfg"
config_format = 'cfg'
else:
# fallback to creating a new pycalver.toml
config_filepath = path / "pycalver.toml"
config_format = 'toml'
vcs_type: typ.Optional[str]
if (path / ".git").exists():
vcs_type = 'git'
elif (path / ".hg").exists():
vcs_type = 'hg'
else:
vcs_type = None
return ProjectContext(path, config_filepath, config_format, vcs_type)
RawConfig = typ.Dict[str, typ.Any]
class Config(typ.NamedTuple):
"""Container for parameters parsed from a config file."""
current_version: str
version_pattern: str
pep440_version : str
commit: bool
tag : bool
push : bool
file_patterns: PatternsByGlob
def _debug_str(cfg: Config) -> str:
cfg_str_parts = [
"Config Parsed: Config(",
f"current_version='{cfg.current_version}'",
"version_pattern='{pycalver}'",
f"pep440_version='{cfg.pep440_version}'",
f"commit={cfg.commit}",
f"tag={cfg.tag}",
f"push={cfg.push}",
"file_patterns={",
]
for filepath, patterns in cfg.file_patterns.items():
for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern}'")
cfg_str_parts += ["\n})"]
return ", ".join(cfg_str_parts)
MaybeConfig = typ.Optional[Config]
MaybeRawConfig = typ.Optional[RawConfig]
FilePatterns = typ.Dict[str, typ.List[str]]
def _parse_cfg_file_patterns(cfg_parser: configparser.RawConfigParser) -> FilePatterns:
file_patterns: FilePatterns = {}
file_pattern_items: typ.List[typ.Tuple[str, str]]
if cfg_parser.has_section("pycalver:file_patterns"):
file_pattern_items = cfg_parser.items("pycalver:file_patterns")
else:
file_pattern_items = []
for filepath, patterns_str in file_pattern_items:
patterns: typ.List[str] = []
for line in patterns_str.splitlines():
pattern = line.strip()
if pattern:
patterns.append(pattern)
file_patterns[filepath] = patterns
return file_patterns
class _ConfigParser(configparser.RawConfigParser):
# pylint:disable=too-many-ancestors ; from our perspective, it's just one
"""Custom parser, simply to override optionxform behaviour."""
def optionxform(self, optionstr: str) -> str:
"""Non-xforming (ie. uppercase preserving) override.
This is important because our option names are actually
filenames, so case sensitivity is relevant. The default
behaviour is to do optionstr.lower()
"""
return optionstr
OptionVal = typ.Union[str, bool, None]
BOOL_OPTIONS: typ.Mapping[str, OptionVal] = {'commit': False, 'tag': None, 'push': None}
def _parse_cfg(cfg_buffer: typ.IO[str]) -> RawConfig:
cfg_parser = _ConfigParser()
if hasattr(cfg_parser, 'read_file'):
cfg_parser.read_file(cfg_buffer)
else:
cfg_parser.readfp(cfg_buffer) # python2 compat
if not cfg_parser.has_section("pycalver"):
raise ValueError("Missing [pycalver] section.")
raw_cfg: RawConfig = dict(cfg_parser.items("pycalver"))
for option, default_val in BOOL_OPTIONS.items():
val: OptionVal = raw_cfg.get(option, default_val)
if isinstance(val, six.text_type):
val = val.lower() in ("yes", "true", "1", "on")
raw_cfg[option] = val
raw_cfg['file_patterns'] = _parse_cfg_file_patterns(cfg_parser)
return raw_cfg
def _parse_toml(cfg_buffer: typ.IO[str]) -> RawConfig:
raw_full_cfg: typ.Any = toml.load(cfg_buffer)
raw_cfg : RawConfig = raw_full_cfg.get('pycalver', {})
for option, default_val in BOOL_OPTIONS.items():
raw_cfg[option] = raw_cfg.get(option, default_val)
return raw_cfg
def _normalize_file_patterns(raw_cfg: RawConfig) -> FilePatterns:
"""Create consistent representation of file_patterns.
The result the same, regardless of the config format.
"""
version_str : str = raw_cfg['current_version']
version_pattern: str = raw_cfg['version_pattern']
pep440_version : str = version.to_pep440(version_str)
file_patterns: FilePatterns
if 'file_patterns' in raw_cfg:
file_patterns = raw_cfg['file_patterns']
else:
file_patterns = {}
for filepath, patterns in list(file_patterns.items()):
if not os.path.exists(filepath):
logger.warning(f"Invalid config, no such file: {filepath}")
normalized_patterns: typ.List[str] = []
for pattern in patterns:
normalized_pattern = pattern.replace("{version}", version_pattern)
if version_pattern == "{pycalver}":
normalized_pattern = normalized_pattern.replace(
"{pep440_version}", "{pep440_pycalver}"
)
elif version_pattern == "{semver}":
normalized_pattern = normalized_pattern.replace("{pep440_version}", "{semver}")
elif "{pep440_version}" in pattern:
logger.warning(f"Invalid config, cannot match '{pattern}' for '{filepath}'.")
logger.warning(f"No mapping of '{version_pattern}' to '{pep440_version}'")
normalized_patterns.append(normalized_pattern)
file_patterns[filepath] = normalized_patterns
return file_patterns
def _parse_config(raw_cfg: RawConfig) -> Config:
"""Parse configuration which was loaded from an .ini/.cfg or .toml file."""
if 'current_version' not in raw_cfg:
raise ValueError("Missing 'pycalver.current_version'")
version_str: str = raw_cfg['current_version']
version_str = raw_cfg['current_version'] = version_str.strip("'\" ")
version_pattern: str = raw_cfg.get('version_pattern', "{pycalver}")
version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ")
# NOTE (mb 2019-01-05): Provoke ValueError if version_pattern
# and current_version are not compatible.
version.parse_version_info(version_str, version_pattern)
pep440_version = version.to_pep440(version_str)
commit = raw_cfg['commit']
tag = raw_cfg['tag']
push = raw_cfg['push']
if tag is None:
tag = raw_cfg['tag'] = False
if push is None:
push = raw_cfg['push'] = False
if tag and not commit:
raise ValueError("pycalver.commit = true required if pycalver.tag = true")
if push and not commit:
raise ValueError("pycalver.commit = true required if pycalver.push = true")
file_patterns = _normalize_file_patterns(raw_cfg)
cfg = Config(
current_version=version_str,
version_pattern=version_pattern,
pep440_version=pep440_version,
commit=commit,
tag=tag,
push=push,
file_patterns=file_patterns,
)
logger.debug(_debug_str(cfg))
return cfg
def _parse_current_version_default_pattern(cfg: Config, raw_cfg_text: str) -> str:
is_pycalver_section = False
for line in raw_cfg_text.splitlines():
if is_pycalver_section and line.startswith("current_version"):
return line.replace(cfg.current_version, cfg.version_pattern)
if line.strip() == "[pycalver]":
is_pycalver_section = True
elif line and line[0] == "[" and line[-1] == "]":
is_pycalver_section = False
raise ValueError("Could not parse pycalver.current_version")
def parse(ctx: ProjectContext) -> MaybeConfig:
"""Parse config file if available."""
if not ctx.config_filepath.exists():
logger.warning(f"File not found: {ctx.config_filepath}")
return None
fobj: typ.IO[str]
cfg_path: str
if ctx.config_filepath.is_absolute():
cfg_path = str(ctx.config_filepath.relative_to(ctx.path.absolute()))
else:
cfg_path = str(ctx.config_filepath)
raw_cfg: RawConfig
try:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
if ctx.config_format == 'toml':
raw_cfg = _parse_toml(fobj)
elif ctx.config_format == 'cfg':
raw_cfg = _parse_cfg(fobj)
else:
err_msg = "Invalid config_format='{ctx.config_format}'"
raise RuntimeError(err_msg)
cfg: Config = _parse_config(raw_cfg)
if cfg_path not in cfg.file_patterns:
fobj.seek(0)
raw_cfg_text = fobj.read()
cfg.file_patterns[cfg_path] = [
_parse_current_version_default_pattern(cfg, raw_cfg_text)
]
return cfg
except ValueError as ex:
logger.warning(f"Couldn't parse {cfg_path}: {str(ex)}")
return None
DEFAULT_CONFIGPARSER_BASE_TMPL = """
[pycalver]
current_version = "{initial_version}"
version_pattern = "{{pycalver}}"
commit = True
tag = True
push = True
[pycalver:file_patterns]
""".lstrip()
DEFAULT_CONFIGPARSER_SETUP_CFG_STR = """
setup.cfg =
current_version = "{version}"
""".lstrip()
DEFAULT_CONFIGPARSER_SETUP_PY_STR = """
setup.py =
"{version}"
"{pep440_version}"
""".lstrip()
DEFAULT_CONFIGPARSER_README_RST_STR = """
README.rst =
{version}
{pep440_version}
""".lstrip()
DEFAULT_CONFIGPARSER_README_MD_STR = """
README.md =
{version}
{pep440_version}
""".lstrip()
DEFAULT_TOML_BASE_TMPL = """
[pycalver]
current_version = "{initial_version}"
version_pattern = "{{pycalver}}"
commit = true
tag = true
push = true
[pycalver.file_patterns]
""".lstrip()
DEFAULT_TOML_PYCALVER_STR = """
"pycalver.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_PYPROJECT_STR = """
"pyproject.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_SETUP_PY_STR = """
"setup.py" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
DEFAULT_TOML_README_RST_STR = """
"README.rst" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
DEFAULT_TOML_README_MD_STR = """
"README.md" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
def _initial_version() -> str:
return dt.datetime.now().strftime("v%Y%m.0001-alpha")
def _initial_version_pep440() -> str:
return dt.datetime.now().strftime("%Y%m.1a0")
def default_config(ctx: ProjectContext) -> str:
"""Generate initial default config."""
fmt = ctx.config_format
if fmt == 'cfg':
base_tmpl = DEFAULT_CONFIGPARSER_BASE_TMPL
default_pattern_strs_by_filename = {
"setup.cfg" : DEFAULT_CONFIGPARSER_SETUP_CFG_STR,
"setup.py" : DEFAULT_CONFIGPARSER_SETUP_PY_STR,
"README.rst": DEFAULT_CONFIGPARSER_README_RST_STR,
"README.md" : DEFAULT_CONFIGPARSER_README_MD_STR,
}
elif fmt == 'toml':
base_tmpl = DEFAULT_TOML_BASE_TMPL
default_pattern_strs_by_filename = {
"pyproject.toml": DEFAULT_TOML_PYPROJECT_STR,
"pycalver.toml" : DEFAULT_TOML_PYCALVER_STR,
"setup.py" : DEFAULT_TOML_SETUP_PY_STR,
"README.rst" : DEFAULT_TOML_README_RST_STR,
"README.md" : DEFAULT_TOML_README_MD_STR,
}
else:
raise ValueError(f"Invalid config_format='{fmt}', must be either 'toml' or 'cfg'.")
cfg_str = base_tmpl.format(initial_version=_initial_version())
for filename, default_str in default_pattern_strs_by_filename.items():
if (ctx.path / filename).exists():
cfg_str += default_str
has_config_file = any((ctx.path / fn).exists() for fn in SUPPORTED_CONFIGS)
if not has_config_file:
if ctx.config_format == 'cfg':
cfg_str += DEFAULT_CONFIGPARSER_SETUP_CFG_STR
if ctx.config_format == 'toml':
cfg_str += DEFAULT_TOML_PYCALVER_STR
cfg_str += "\n"
return cfg_str
def write_content(ctx: ProjectContext) -> None:
"""Update project config file with initial default config."""
fobj: typ.IO[str]
cfg_content = default_config(ctx)
if ctx.config_filepath.exists():
cfg_content = "\n" + cfg_content
with ctx.config_filepath.open(mode="at", encoding="utf-8") as fobj:
fobj.write(cfg_content)
print(f"Updated {ctx.config_filepath}")

View file

@ -1,169 +0,0 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""A scheme for lexically ordered numerical ids.
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:
"""Generate next lexical id.
Increments by one and adds padding if required.
>>> next_id("0098")
'0099'
>>> next_id("0099")
'0100'
>>> next_id("0999")
'11000'
>>> next_id("11000")
'11001'
"""
num_digits = len(prev_id)
if prev_id.count("9") == num_digits:
raise OverflowError("max lexical version reached: " + prev_id)
_prev_id_val = int(prev_id, 10)
_maybe_next_id_val = int(_prev_id_val) + 1
_maybe_next_id_str = f"{_maybe_next_id_val:0{num_digits}}"
_is_padding_ok = prev_id[0] == _maybe_next_id_str[0]
_next_id_str: str
if _is_padding_ok:
_next_id_str = _maybe_next_id_str
else:
_next_id_str = str(_maybe_next_id_val * 11)
return _next_id_str
def ord_val(lex_id: str) -> int:
"""Parse the ordinal value of a lexical id.
The ordinal value is the position in the sequence,
from repeated calls to next_id.
>>> ord_val("0098")
98
>>> ord_val("0099")
99
>>> ord_val("0100")
100
>>> ord_val("11000")
1000
>>> ord_val("11001")
1001
"""
if len(lex_id) == 1:
return int(lex_id, 10)
else:
return int(lex_id[1:], 10)
def _main() -> None:
_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)
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()

View file

@ -1,53 +0,0 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Parse PyCalVer strings from files."""
import typing as typ
from .patterns import compile_pattern
class PatternMatch(typ.NamedTuple):
"""Container to mark a version string in a file."""
lineno : int # zero based
line : str
pattern: str
span : typ.Tuple[int, int]
match : str
PatternMatches = typ.Iterable[PatternMatch]
def _iter_for_pattern(lines: typ.List[str], pattern: str) -> PatternMatches:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
pattern_re = compile_pattern(pattern)
for lineno, line in enumerate(lines):
match = pattern_re.search(line)
if match:
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> PatternMatches:
"""Iterate over all matches of any pattern on any line.
>>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> patterns = ["{pycalver}", "{pep440_pycalver}"]
>>> matches = list(iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch(
... lineno = 0,
... line = "__version__ = 'v201712.0002-alpha'",
... pattern= "{pycalver}",
... span = (15, 33),
... match = "v201712.0002-alpha",
... )
"""
for pattern in patterns:
for match in _iter_for_pattern(lines, pattern):
yield match

View file

@ -1,237 +0,0 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Rewrite files, updating occurences of version strings."""
import io
import glob
import typing as typ
import difflib
import logging
import pathlib2 as pl
from . import parse
from . import config
from . import version
from . import patterns
logger = logging.getLogger("pycalver.rewrite")
def detect_line_sep(content: str) -> str:
r"""Parse line separator from content.
>>> detect_line_sep('\r\n')
'\r\n'
>>> detect_line_sep('\r')
'\r'
>>> detect_line_sep('\n')
'\n'
>>> detect_line_sep('')
'\n'
"""
if "\r\n" in content:
return "\r\n"
elif "\r" in content:
return "\r"
else:
return "\n"
class NoPatternMatch(Exception):
"""Pattern not found in content.
logger.error is used to show error info about the patterns so
that users can debug what is wrong with them. The class
itself doesn't capture that info. This approach is used so
that all patter issues can be shown, rather than bubbling
all the way up the stack on the very first pattern with no
matches.
"""
def rewrite_lines(
pattern_strs: typ.List[str], new_vinfo: version.VersionInfo, old_lines: typ.List[str]
) -> typ.List[str]:
"""Replace occurances of pattern_strs in old_lines with new_vinfo.
>>> new_vinfo = version.parse_version_info("v201811.0123-beta")
>>> pattern_strs = ['__version__ = "{pycalver}"']
>>> rewrite_lines(pattern_strs, new_vinfo, ['__version__ = "v201809.0002-beta"'])
['__version__ = "v201811.0123-beta"']
>>> pattern_strs = ['__version__ = "{pep440_version}"']
>>> rewrite_lines(pattern_strs, new_vinfo, ['__version__ = "201809.2b0"'])
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
for match in parse.iter_matches(old_lines, pattern_strs):
found_patterns.add(match.pattern)
replacement = version.format_version(new_vinfo, match.pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(pattern_strs) - found_patterns
if non_matched_patterns:
for non_matched_pattern in non_matched_patterns:
logger.error(f"No match for pattern '{non_matched_pattern}'")
compiled_pattern_str = patterns.compile_pattern_str(non_matched_pattern)
logger.error(f"Pattern compiles to regex '{compiled_pattern_str}'")
raise NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
class RewrittenFileData(typ.NamedTuple):
"""Container for line-wise content of rewritten files."""
path : str
line_sep : str
old_lines: typ.List[str]
new_lines: typ.List[str]
def rfd_from_content(
pattern_strs: typ.List[str], new_vinfo: version.VersionInfo, content: str
) -> RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> new_vinfo = version.parse_version_info("v201809.0123")
>>> pattern_strs = ['__version__ = "{pycalver}"']
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(pattern_strs, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>>
>>> new_vinfo = version.parse_version_info("v1.2.3", "v{semver}")
>>> pattern_strs = ['__version__ = "v{semver}"']
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(pattern_strs, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(pattern_strs, new_vinfo, old_lines)
return RewrittenFileData("<path>", line_sep, old_lines, new_lines)
def _iter_file_paths(
file_patterns: config.PatternsByGlob,
) -> typ.Iterable[typ.Tuple[pl.Path, config.Patterns]]:
for globstr, pattern_strs in file_patterns.items():
file_paths = glob.glob(globstr)
if not any(file_paths):
errmsg = f"No files found for path/glob '{globstr}'"
raise IOError(errmsg)
for file_path_str in file_paths:
file_path = pl.Path(file_path_str)
yield (file_path, pattern_strs)
def iter_rewritten(
file_patterns: config.PatternsByGlob, new_vinfo: version.VersionInfo
) -> typ.Iterable[RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> new_vinfo = version.parse_version_info("v201809.0123")
>>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> assert rfd.new_lines == [
... '# This file is part of the pycalver project',
... '# https://gitlab.com/mbarkhau/pycalver',
... '#',
... '# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License',
... '# SPDX-License-Identifier: MIT',
... '"""PyCalVer: CalVer for Python Packages."""',
... '',
... '__version__ = "v201809.0123"',
... '',
... ]
>>>
'''
fobj: typ.IO[str]
for file_path, pattern_strs in _iter_file_paths(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
r"""Generate unified diff.
>>> rfd = RewrittenFileData(
... path = "<path>",
... line_sep = "\n",
... old_lines = ["foo"],
... new_lines = ["bar"],
... )
>>> diff_lines(rfd)
['--- <path>', '+++ <path>', '@@ -1 +1 @@', '-foo', '+bar']
"""
lines = difflib.unified_diff(
a=rfd.old_lines, b=rfd.new_lines, lineterm="", fromfile=rfd.path, tofile=rfd.path
)
return list(lines)
def diff(new_vinfo: version.VersionInfo, file_patterns: config.PatternsByGlob) -> str:
r"""Generate diffs of rewritten files.
>>> new_vinfo = version.parse_version_info("v201809.0123")
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> diff_str = diff(new_vinfo, file_patterns)
>>> lines = diff_str.split("\n")
>>> lines[:2]
['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py']
>>> assert lines[6].startswith('-__version__ = "v2')
>>> assert not lines[6].startswith('-__version__ = "v201809.0123"')
>>> lines[7]
'+__version__ = "v201809.0123"'
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, pattern_strs in sorted(_iter_file_paths(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
try:
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
except NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = diff_lines(rfd)
if len(lines) == 0:
errmsg = f"No patterns matched for '{file_path}'"
raise NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite(file_patterns: config.PatternsByGlob, new_vinfo: version.VersionInfo) -> None:
"""Rewrite project files, updating each with the new version."""
fobj: typ.IO[str]
for file_data in iter_rewritten(file_patterns, new_vinfo):
new_content = file_data.line_sep.join(file_data.new_lines)
with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj:
fobj.write(new_content)

View file

@ -1,3 +1,3 @@
# PyCalVer README Fixture
# Python CalVer README Fixture
Current Version: v201612.0123-alpha
Current Version: v2016.0123-alpha

View file

@ -1,11 +1,12 @@
[pycalver]
current_version = "v201710.0123-alpha"
[bumpver]
current_version = "v2017.0123-alpha"
version_pattern = "vYYYY.BUILD[-TAG]"
commit = true
tag = true
push = true
[pycalver.file_patterns]
"pycalver.toml" = [
[bumpver.file_patterns]
"bumpver.toml" = [
'current_version = "{version}"',
]

View file

@ -1,11 +1,11 @@
[pycalver]
[bumpver]
current_version = v201307.0456-beta
version_pattern = {pycalver}
commit = True
tag = True
push = True
[pycalver:file_patterns]
[bumpver:file_patterns]
setup.cfg =
current_version = {version}
setup.py =

View file

@ -1,3 +1,8 @@
import setuptools
setuptools.setup(name="mylib", license="MIT", version="201307.456b0", keywords="awesome library")
setuptools.setup(
name="mylib",
license="MIT",
version="201307.456b0",
keywords="awesome library",
)

View file

@ -1,4 +1,4 @@
[pycalver]
[bumpver]
current_version = "v2017q1.54321"
version_pattern = "v{year}q{quarter}.{build_no}"
commit = true

View file

@ -0,0 +1,6 @@
[calver]
current_version = "v2017q1.54321"
version_pattern = "vYYYYqQ.BUILD"
commit = true
tag = true
push = true

View file

@ -1,18 +1,38 @@
# pylint:disable=redefined-outer-name ; pytest fixtures
# pylint:disable=protected-access ; allowed for test code
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
import io
import os
import re
import time
import shlex
import shutil
import datetime as dt
import subprocess as sp
import pytest
import pathlib2 as pl
from click.testing import CliRunner
import pycalver.cli as cli
import pycalver.config as config
import pycalver.patterns as patterns
from bumpver import cli
from bumpver import config
from bumpver import v2patterns
# pylint:disable=redefined-outer-name ; pytest fixtures
# pylint:disable=protected-access ; allowed for test code
# pylint:disable=unused-argument ; allowed for test code
README_TEXT_FIXTURE = """
Hello World v2017.1002-alpha !
[aka. 2017.1002a0 !]
Hello World v201707.1002-alpha !
[aka. 201707.1002a0 !]
"""
SETUP_CFG_FIXTURE = """
[metadata]
@ -22,7 +42,7 @@ license_file = LICENSE
universal = 1
"""
PYCALVER_TOML_FIXTURE = """
CALVER_TOML_FIXTURE = """
"""
PYPROJECT_TOML_FIXTURE = """
@ -31,11 +51,11 @@ requires = ["setuptools", "wheel"]
"""
ENV = {
'GIT_AUTHOR_NAME' : "pycalver_tester",
'GIT_COMMITTER_NAME' : "pycalver_tester",
'GIT_AUTHOR_EMAIL' : "pycalver_tester@nowhere.com",
'GIT_COMMITTER_EMAIL': "pycalver_tester@nowhere.com",
'HGUSER' : "pycalver_tester",
'GIT_AUTHOR_NAME' : "bumpver_tester",
'GIT_COMMITTER_NAME' : "bumpver_tester",
'GIT_AUTHOR_EMAIL' : "bumpver_tester@nowhere.com",
'GIT_COMMITTER_EMAIL': "bumpver_tester@nowhere.com",
'HGUSER' : "bumpver_tester",
'PATH' : os.environ['PATH'],
}
@ -44,6 +64,16 @@ def shell(*cmd):
return sp.check_output(cmd, env=ENV)
ECHO_CAPLOG = os.getenv('ECHO_CAPLOG') == "1"
def _debug_records(caplog):
if ECHO_CAPLOG:
print()
for record in caplog.records:
print(record)
@pytest.fixture
def runner(tmpdir):
runner = CliRunner(env=ENV)
@ -51,7 +81,7 @@ def runner(tmpdir):
_debug = 0
if _debug:
tmpdir = pl.Path("..") / "tmp_test_pycalver_project"
tmpdir = pl.Path("..") / "tmp_test_bumpver_project"
if tmpdir.exists():
time.sleep(0.2)
shutil.rmtree(str(tmpdir))
@ -70,8 +100,8 @@ def runner(tmpdir):
def test_help(runner):
result = runner.invoke(cli.cli, ['--help', "-vv"])
assert result.exit_code == 0
assert "PyCalVer" in result.output
assert "bump " in result.output
assert "CalVer" in result.output
assert "update " in result.output
assert "test " in result.output
assert "init " in result.output
assert "show " in result.output
@ -80,30 +110,52 @@ def test_help(runner):
def test_version(runner):
result = runner.invoke(cli.cli, ['--version', "-vv"])
assert result.exit_code == 0
assert " version v20" in result.output
match = patterns.PYCALVER_RE.search(result.output)
assert " version 20" in result.output
pattern = v2patterns.compile_pattern("YYYY.BUILD[-TAG]")
match = pattern.regexp.search(result.output)
assert match
def test_incr_default(runner):
old_version = "v201701.0999-alpha"
initial_version = config._initial_version()
old_version = "v201709.1004-alpha"
result = runner.invoke(cli.cli, ['test', "-vv", old_version])
cmd = ['test', "-vv", "--pin-date", "--tag", "beta", old_version, "{pycalver}"]
result = runner.invoke(cli.cli, cmd)
assert result.exit_code == 0
new_version = initial_version.replace(".0001-alpha", ".11000-alpha")
assert f"Version: {new_version}\n" in result.output
assert "Version: v201709.1005-beta\n" in result.output
old_version = "v2017.1004-alpha"
cmd = ['test', "-vv", "--pin-date", "--tag", "beta", old_version, "v{year}{build}{release}"]
result = runner.invoke(cli.cli, cmd)
assert result.exit_code == 0
assert "Version: v2017.1005-beta\n" in result.output
cmd = ['test', "-vv", "--pin-date", "--tag", "beta", old_version, "vYYYY.BUILD[-TAG]"]
result = runner.invoke(cli.cli, cmd)
assert result.exit_code == 0
assert "Version: v2017.1005-beta\n" in result.output
def test_incr_pin_date(runner):
old_version = "v2017.1999-alpha"
pattern = "vYYYY.BUILD[-TAG]"
result = runner.invoke(cli.cli, ['test', "-vv", "--pin-date", old_version, pattern])
assert result.exit_code == 0
assert "Version: v2017.22000-alpha\n" in result.output
def test_incr_semver(runner):
semver_pattern = "{MAJOR}.{MINOR}.{PATCH}"
semver_patterns = [
"{semver}",
"{MAJOR}.{MINOR}.{PATCH}",
"MAJOR.MINOR.PATCH",
]
for semver_pattern in semver_patterns:
old_version = "0.1.0"
new_version = "0.1.1"
result = runner.invoke(cli.cli, ['test', "-vv", "--patch", old_version, "{semver}"])
assert result.exit_code == 0
assert f"Version: {new_version}\n" in result.output
result = runner.invoke(cli.cli, ['test', "-vv", "--patch", old_version, semver_pattern])
assert result.exit_code == 0
assert f"Version: {new_version}\n" in result.output
@ -124,50 +176,71 @@ def test_incr_semver(runner):
def test_incr_semver_invalid(runner, caplog):
result = runner.invoke(cli.cli, ['test', "-vv", "--patch", "0.1.1"])
pattern = "vYYYY.BUILD[-TAG]"
result = runner.invoke(cli.cli, ['test', "-vv", "0.1.1", pattern, "--patch"])
assert result.exit_code == 1
assert len(caplog.records) > 0
log_record = caplog.records[0]
assert "Invalid version string" in log_record.message
assert "for pattern '{pycalver}'" in log_record.message
assert "--patch is not applicable to pattern" in log_record.message
assert "to pattern 'vYYYY.BUILD[-TAG]'" in log_record.message
def test_incr_to_beta(runner):
old_version = "v201701.0999-alpha"
initial_version = config._initial_version()
pattern = "vYYYY.BUILD[-TAG]"
old_version = "v2017.1999-alpha"
new_version = dt.datetime.utcnow().strftime("v%Y.22000-beta")
result = runner.invoke(cli.cli, ['test', old_version, "-vv", "--release", "beta"])
result = runner.invoke(cli.cli, ['test', "-vv", old_version, pattern, "--tag", "beta"])
assert result.exit_code == 0
new_version = initial_version.replace(".0001-alpha", ".11000-beta")
assert f"Version: {new_version}\n" in result.output
def test_incr_to_final(runner):
old_version = "v201701.0999-alpha"
initial_version = config._initial_version()
def test_incr_to_final(runner, caplog):
pattern = "vYYYY.BUILD[-TAG]"
old_version = "v2017.1999-alpha"
new_version = dt.datetime.utcnow().strftime("v%Y.22000")
result = runner.invoke(cli.cli, ['test', old_version, "-vv", "--release", "final"])
result = runner.invoke(cli.cli, ['test', "-vv", old_version, pattern, "--tag", "final"])
_debug_records(caplog)
assert result.exit_code == 0
assert f"Version: {new_version}\n" in result.output
SEMVER = "MAJOR.MINOR.PATCH[PYTAGNUM]"
def test_incr_tag(runner):
old_version = "0.1.0"
new_version = "0.1.1b0"
result = runner.invoke(
cli.cli, ['test', "-vv", old_version, SEMVER, "--patch", "--tag", "beta"]
)
assert result.exit_code == 0
assert f"Version: {new_version}\n" in result.output
def test_incr_tag_num(runner):
old_version = "0.1.0b0"
new_version = "0.1.0b1"
result = runner.invoke(cli.cli, ['test', "-vv", old_version, SEMVER, "--tag-num"])
assert result.exit_code == 0
new_version = initial_version.replace(".0001-alpha", ".11000")
assert f"Version: {new_version}\n" in result.output
def test_incr_invalid(runner):
old_version = "v201701.0999-alpha"
pattern = "vYYYY.BUILD[-TAG]"
old_version = "v2017.1999-alpha"
result = runner.invoke(cli.cli, ['test', old_version, "-vv", "--release", "alfa"])
result = runner.invoke(cli.cli, ['test', "-vv", old_version, pattern, "--tag", "alfa"])
assert result.exit_code == 1
def _add_project_files(*files):
if "README.md" in files:
with pl.Path("README.md").open(mode="wt", encoding="utf-8") as fobj:
fobj.write(
"""
Hello World v201701.0002-alpha !
aka. 201701.2a0 !
"""
)
fobj.write(README_TEXT_FIXTURE)
if "setup.cfg" in files:
with pl.Path("setup.cfg").open(mode="wt", encoding="utf-8") as fobj:
@ -175,21 +248,40 @@ def _add_project_files(*files):
if "pycalver.toml" in files:
with pl.Path("pycalver.toml").open(mode="wt", encoding="utf-8") as fobj:
fobj.write(PYCALVER_TOML_FIXTURE)
fobj.write(CALVER_TOML_FIXTURE)
if "pyproject.toml" in files:
with pl.Path("pyproject.toml").open(mode="wt", encoding="utf-8") as fobj:
fobj.write(PYPROJECT_TOML_FIXTURE)
if "bumpver.toml" in files:
with pl.Path("bumpver.toml").open(mode="wt", encoding="utf-8") as fobj:
fobj.write(CALVER_TOML_FIXTURE)
def _update_config_val(filename, **kwargs):
with io.open(filename, mode="r", encoding="utf-8") as fobj:
old_cfg_text = fobj.read()
new_cfg_text = old_cfg_text
for key, val in kwargs.items():
replacement = "{} = {}".format(key, val)
if replacement not in new_cfg_text:
pattern = r"^{} = .*$".format(key)
new_cfg_text = re.sub(pattern, replacement, new_cfg_text, flags=re.MULTILINE)
assert old_cfg_text != new_cfg_text
with io.open(filename, mode="w", encoding="utf-8") as fobj:
fobj.write(new_cfg_text)
def test_nocfg(runner, caplog):
_add_project_files("README.md")
result = runner.invoke(cli.cli, ['show', "-vv"])
assert result.exit_code == 1
assert any(
bool("Could not parse configuration. Perhaps try 'pycalver init'." in r.message)
for r in caplog.records
)
expected_msg = "Could not parse configuration. Perhaps try 'bumpver init'."
_debug_records(caplog)
assert any(expected_msg in r.message for r in caplog.records)
def test_novcs_nocfg_init(runner, caplog):
@ -197,26 +289,14 @@ def test_novcs_nocfg_init(runner, caplog):
# dry mode test
result = runner.invoke(cli.cli, ['init', "-vv", "--dry"])
assert result.exit_code == 0
assert not os.path.exists("pycalver.toml")
# check logging
assert len(caplog.records) == 1
log = caplog.records[0]
assert log.levelname == 'WARNING'
assert "File not found" in log.message
assert not os.path.exists("bumpver.toml")
# non dry mode
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
# check logging
assert len(caplog.records) == 2
log = caplog.records[1]
assert log.levelname == 'WARNING'
assert "File not found" in log.message
assert os.path.exists("pycalver.toml")
with pl.Path("pycalver.toml").open(mode="r", encoding="utf-8") as fobj:
assert os.path.exists("bumpver.toml")
with pl.Path("bumpver.toml").open(mode="r", encoding="utf-8") as fobj:
cfg_content = fobj.read()
base_str = config.DEFAULT_TOML_BASE_TMPL.format(initial_version=config._initial_version())
@ -224,6 +304,7 @@ def test_novcs_nocfg_init(runner, caplog):
assert config.DEFAULT_TOML_README_MD_STR in cfg_content
result = runner.invoke(cli.cli, ['show', "-vv"])
_debug_records(caplog)
assert result.exit_code == 0
assert f"Current Version: {config._initial_version()}\n" in result.output
assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output
@ -232,8 +313,8 @@ def test_novcs_nocfg_init(runner, caplog):
assert result.exit_code == 1
# check logging
assert len(caplog.records) == 3
log = caplog.records[2]
assert len(caplog.records) == 1
log = caplog.records[0]
assert log.levelname == 'ERROR'
assert "Configuration already initialized" in log.message
@ -258,9 +339,10 @@ def test_novcs_setupcfg_init(runner):
assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output
def test_novcs_pyproject_init(runner):
def test_novcs_pyproject_init(runner, caplog):
_add_project_files("README.md", "pyproject.toml")
result = runner.invoke(cli.cli, ['init', "-vv"])
_debug_records(caplog)
assert result.exit_code == 0
with pl.Path("pyproject.toml").open(mode="r", encoding="utf-8") as fobj:
@ -288,33 +370,57 @@ def _vcs_init(vcs, files=("README.md",)):
shell(f"{vcs}", "commit", "-m", "initial commit")
def test_git_init(runner):
_today = dt.datetime.utcnow().date()
DEFAULT_VERSION_PATTERNS = [
('"vYYYY0M.BUILD[-TAG]"' , _today.strftime("v%Y%m.1001-alpha"), _today.strftime("%Y%m.1001a0")),
('"vYYYY.BUILD[-TAG]"' , _today.strftime("v%Y.1001-alpha"), _today.strftime("%Y.1001a0")),
('"{pycalver}"' , _today.strftime("v%Y%m.1001-alpha"), _today.strftime("%Y%m.1001a0")),
('"v{year}{build}{release}"', _today.strftime("v%Y.1001-alpha"), _today.strftime("%Y.1001a0")),
]
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_git_init(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
_vcs_init("git")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
result = runner.invoke(cli.cli, ['show'])
assert result.exit_code == 0
assert f"Current Version: {config._initial_version()}\n" in result.output
assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output
assert f"Current Version: {cur_version}\n" in result.output
def test_hg_init(runner):
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_hg_init(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
_vcs_init("hg")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
result = runner.invoke(cli.cli, ['show'])
assert result.exit_code == 0
assert f"Current Version: {config._initial_version()}\n" in result.output
assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output
assert f"Current Version: {cur_version}\n" in result.output
def test_git_tag_eval(runner):
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_v1_git_tag_eval(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
_vcs_init("git")
@ -322,19 +428,25 @@ def test_git_tag_eval(runner):
# we set in the vcs, which should take precedence.
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
initial_version = config._initial_version()
tag_version = initial_version.replace(".0001-alpha", ".0123-beta")
tag_version_pep440 = tag_version[1:7] + ".123b0"
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
tag_version = cur_version.replace(".1001-alpha", ".1123-beta")
assert tag_version != cur_version
shell("git", "tag", "--annotate", tag_version, "--message", f"bump version to {tag_version}")
result = runner.invoke(cli.cli, ['show', "-vv"])
assert result.exit_code == 0
assert f"Current Version: {tag_version}\n" in result.output
assert f"PEP440 : {tag_version_pep440}\n" in result.output
def test_hg_tag_eval(runner):
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_hg_tag_eval(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
_vcs_init("hg")
@ -342,9 +454,15 @@ def test_hg_tag_eval(runner):
# we set in the vcs, which should take precedence.
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
initial_version = config._initial_version()
tag_version = initial_version.replace(".0001-alpha", ".0123-beta")
tag_version_pep440 = tag_version[1:7] + ".123b0"
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
tag_version = cur_version.replace(".1001-alpha", ".1123-beta")
tag_version_pep440 = tag_version[1:].split(".")[0] + ".1123b0"
shell("hg", "tag", tag_version, "--message", f"bump version to {tag_version}")
@ -354,87 +472,113 @@ def test_hg_tag_eval(runner):
assert f"PEP440 : {tag_version_pep440}\n" in result.output
def test_novcs_bump(runner):
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_novcs_bump(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
result = runner.invoke(cli.cli, ['bump', "-vv"])
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
with pl.Path("README.md").open(mode="r") as fobj:
content = fobj.read()
result = runner.invoke(cli.cli, ['update', "-vv"])
assert result.exit_code == 0
calver = config._initial_version()[:7]
calver = cur_version.split(".")[0]
with pl.Path("README.md").open() as fobj:
content = fobj.read()
assert calver + ".0002-alpha !\n" in content
assert calver[1:] + ".2a0 !\n" in content
assert calver + ".1002-alpha !\n" in content
assert calver[1:] + ".1002a0 !]\n" in content
result = runner.invoke(cli.cli, ['bump', "-vv", "--release", "beta"])
result = runner.invoke(cli.cli, ['update', "-vv", "--tag", "beta"])
assert result.exit_code == 0
with pl.Path("README.md").open() as fobj:
content = fobj.read()
assert calver + ".0003-beta !\n" in content
assert calver[1:] + ".3b0 !\n" in content
assert calver + ".1003-beta !\n" in content
assert calver[1:] + ".1003b0 !]\n" in content
def test_git_bump(runner):
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_git_bump(runner, caplog, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
_vcs_init("git")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
shell("git", "add", "pycalver.toml")
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
shell("git", "add", "bumpver.toml")
shell("git", "commit", "-m", "initial commit")
result = runner.invoke(cli.cli, ['bump', "-vv"])
result = runner.invoke(cli.cli, ['update', "-vv"])
_debug_records(caplog)
assert result.exit_code == 0
calver = config._initial_version()[:7]
calver = cur_version.split(".")[0]
with pl.Path("README.md").open() as fobj:
content = fobj.read()
assert calver + ".0002-alpha !\n" in content
assert calver + ".1002-alpha !\n" in content
def test_hg_bump(runner):
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_hg_bump(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md")
_vcs_init("hg")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
shell("hg", "add", "pycalver.toml")
_update_config_val(
"bumpver.toml",
version_pattern=version_pattern,
current_version='"' + cur_version + '"',
)
shell("hg", "add", "bumpver.toml")
shell("hg", "commit", "-m", "initial commit")
result = runner.invoke(cli.cli, ['bump', "-vv"])
result = runner.invoke(cli.cli, ['update', "-vv"])
assert result.exit_code == 0
calver = config._initial_version()[:7]
calver = cur_version.split(".")[0]
with pl.Path("README.md").open() as fobj:
content = fobj.read()
assert calver + ".0002-alpha !\n" in content
assert calver + ".1002-alpha !\n" in content
def test_empty_git_bump(runner, caplog):
shell("git", "init")
with pl.Path("setup.cfg").open(mode="w") as fobj:
fobj.write("")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
with pl.Path("setup.cfg").open(mode="r") as fobj:
default_cfg_data = fobj.read()
assert "[pycalver]\n" in default_cfg_data
assert "[bumpver]\n" in default_cfg_data
assert "\ncurrent_version = " in default_cfg_data
assert "\n[pycalver:file_patterns]\n" in default_cfg_data
assert "\n[bumpver:file_patterns]\n" in default_cfg_data
assert "\nsetup.cfg =\n" in default_cfg_data
result = runner.invoke(cli.cli, ['bump'])
result = runner.invoke(cli.cli, ['update'])
assert any(("working directory is not clean" in r.message) for r in caplog.records)
assert any(("setup.cfg" in r.message) for r in caplog.records)
@ -444,18 +588,19 @@ def test_empty_hg_bump(runner, caplog):
shell("hg", "init")
with pl.Path("setup.cfg").open(mode="w") as fobj:
fobj.write("")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
with pl.Path("setup.cfg").open(mode="r") as fobj:
default_cfg_text = fobj.read()
assert "[pycalver]\n" in default_cfg_text
assert "[bumpver]\n" in default_cfg_text
assert "\ncurrent_version = " in default_cfg_text
assert "\n[pycalver:file_patterns]\n" in default_cfg_text
assert "\n[bumpver:file_patterns]\n" in default_cfg_text
assert "\nsetup.cfg =\n" in default_cfg_text
result = runner.invoke(cli.cli, ['bump'])
result = runner.invoke(cli.cli, ['update'])
assert any(("working directory is not clean" in r.message) for r in caplog.records)
assert any(("setup.cfg" in r.message) for r in caplog.records)
@ -478,36 +623,48 @@ setup.cfg =
"""
def test_bump_semver_warning(runner, caplog):
DEFAULT_SEMVER_PATTERNS = [
'"{semver}"',
'"MAJOR.MINOR.PATCH"',
]
@pytest.mark.parametrize("version_pattern", DEFAULT_SEMVER_PATTERNS)
def test_v1_bump_semver_warning(runner, caplog, version_pattern):
_add_project_files("README.md")
with pl.Path("setup.cfg").open(mode="w") as fobj:
fobj.write(SETUP_CFG_SEMVER_FIXTURE)
_update_config_val("setup.cfg", version_pattern=version_pattern)
_vcs_init("hg", files=["README.md", "setup.cfg"])
result = runner.invoke(cli.cli, ['bump', "-vv", "-n", "--dry"])
result = runner.invoke(cli.cli, ['update', "-vv", "-n", "--dry"])
assert result.exit_code == 1
assert any("version did not change" in r.message for r in caplog.records)
assert any("--major/--minor/--patch required" in r.message for r in caplog.records)
assert any("[--major/--minor/--patch] required" in r.message for r in caplog.records)
result = runner.invoke(cli.cli, ['bump', "-vv", "-n", "--dry", "--patch"])
result = runner.invoke(cli.cli, ['update', "-vv", "-n", "--dry", "--patch"])
assert result.exit_code == 0
def test_bump_semver_diff(runner, caplog):
@pytest.mark.parametrize("version_pattern", DEFAULT_SEMVER_PATTERNS)
def test_v1_bump_semver_diff(runner, caplog, version_pattern):
_add_project_files("README.md")
with pl.Path("setup.cfg").open(mode="w") as fobj:
fobj.write(SETUP_CFG_SEMVER_FIXTURE)
_update_config_val("setup.cfg", version_pattern=version_pattern)
_vcs_init("hg", files=["README.md", "setup.cfg"])
cases = [("--major", "1.0.0"), ("--minor", "0.2.0"), ("--patch", "0.1.1")]
for flag, expected in cases:
result = runner.invoke(cli.cli, ['bump', "-vv", "-n", "--dry", flag])
result = runner.invoke(cli.cli, ['update', "-vv", "-n", "--dry", flag])
assert result.exit_code == 0
assert len(caplog.records) == 0
@ -516,3 +673,274 @@ def test_bump_semver_diff(runner, caplog):
assert "+++ setup.cfg" in out_lines
assert "-current_version = \"0.1.0\"" in out_lines
assert f"+current_version = \"{expected}\"" in out_lines
@pytest.mark.parametrize("version_pattern, cur_version, cur_pep440", DEFAULT_VERSION_PATTERNS)
def test_get_diff(runner, version_pattern, cur_version, cur_pep440):
_add_project_files("README.md", "setup.cfg")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
if len(cur_pep440) == 11:
old_version = "v2017.1002-alpha"
old_pep440 = "2017.1002a0"
elif len(cur_pep440) == 13:
old_version = "v201707.1002-alpha"
old_pep440 = "201707.1002a0"
else:
assert False, len(cur_pep440)
_update_config_val(
"setup.cfg",
version_pattern=version_pattern,
current_version='"' + old_version + '"',
)
_, cfg = config.init()
diff_str = cli.get_diff(cfg, cur_version)
diff_lines = set(diff_str.splitlines())
assert f"- Hello World {old_version} !" in diff_lines
assert f"+ Hello World {cur_version} !" in diff_lines
assert f"- [aka. {old_pep440} !]" in diff_lines
assert f"+ [aka. {cur_pep440} !]" in diff_lines
assert f'-current_version = "{old_version}"' in diff_lines
assert f'+current_version = "{cur_version}"' in diff_lines
WEEKNUM_TEST_CASES = [
# 2020-12-26 Sat
("2020-12-26", "YYYY.0W", "2020.51"),
("2020-12-26", "YYYY.0U", "2020.51"),
("2020-12-26", "GGGG.0V", "2020.52"),
# 2020-12-27 Sun
("2020-12-27", "YYYY.0W", "2020.51"),
("2020-12-27", "YYYY.0U", "2020.52"),
("2020-12-27", "GGGG.0V", "2020.52"),
# 2020-12-28 Mon
("2020-12-28", "YYYY.0W", "2020.52"),
("2020-12-28", "YYYY.0U", "2020.52"),
("2020-12-28", "GGGG.0V", "2020.53"),
# 2020-12-29 Tue
("2020-12-29", "YYYY.0W", "2020.52"),
("2020-12-29", "YYYY.0U", "2020.52"),
("2020-12-29", "GGGG.0V", "2020.53"),
# 2020-12-30 Wed
("2020-12-30", "YYYY.0W", "2020.52"),
("2020-12-30", "YYYY.0U", "2020.52"),
("2020-12-30", "GGGG.0V", "2020.53"),
# 2020-12-31 Thu
("2020-12-31", "YYYY.0W", "2020.52"),
("2020-12-31", "YYYY.0U", "2020.52"),
("2020-12-31", "GGGG.0V", "2020.53"),
# 2021-01-01 Fri
("2021-01-01", "YYYY.0W", "2021.00"),
("2021-01-01", "YYYY.0U", "2021.00"),
("2021-01-01", "GGGG.0V", "2020.53"),
# 2021-01-02 Sat
("2021-01-02", "YYYY.0W", "2021.00"),
("2021-01-02", "YYYY.0U", "2021.00"),
("2021-01-02", "GGGG.0V", "2020.53"),
# 2021-01-03 Sun
("2021-01-03", "YYYY.0W", "2021.00"),
("2021-01-03", "YYYY.0U", "2021.01"),
("2021-01-03", "GGGG.0V", "2020.53"),
# 2021-01-04 Mon
("2021-01-04", "YYYY.0W", "2021.01"),
("2021-01-04", "YYYY.0U", "2021.01"),
("2021-01-04", "GGGG.0V", "2021.01"),
]
@pytest.mark.parametrize("date, pattern, expected", WEEKNUM_TEST_CASES)
def test_weeknum(date, pattern, expected, runner):
cmd = shlex.split(f"test -vv --date {date} 2020.40 {pattern}")
result = runner.invoke(cli.cli, cmd)
assert result.exit_code == 0
assert "New Version: " + expected in result.output
def test_hg_commit_message(runner, caplog):
_add_project_files("README.md", "setup.cfg")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
commit_message = """
"bump from {old_version} ({old_version_pep440}) to {new_version} ({new_version_pep440})"
"""
_update_config_val(
"setup.cfg",
current_version='"v2019.1001-alpha"',
version_pattern="vYYYY.BUILD[-TAG]",
commit_message=commit_message.strip(),
)
_vcs_init("hg", ["README.md", "setup.cfg"])
assert len(caplog.records) > 0
result = runner.invoke(cli.cli, ['update', "-vv", "--pin-date", "--tag", "beta"])
assert result.exit_code == 0
tags = shell("hg", "tags").decode("utf-8")
assert "v2019.1002-beta" in tags
commits = shell(*shlex.split("hg log -l 2")).decode("utf-8").split("\n\n")
expected = "bump from v2019.1001-alpha (2019.1001a0) to v2019.1002-beta (2019.1002b0)"
summary = commits[1].split("summary:")[-1]
assert expected in summary
def test_git_commit_message(runner, caplog):
_add_project_files("README.md", "setup.cfg")
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
commit_message = """
"bump: {old_version} ({old_version_pep440}) -> {new_version} ({new_version_pep440})"
"""
_update_config_val(
"setup.cfg",
current_version='"v2019.1001-alpha"',
version_pattern="vYYYY.BUILD[-TAG]",
commit_message=commit_message.strip(),
)
_vcs_init("git", ["README.md", "setup.cfg"])
assert len(caplog.records) > 0
result = runner.invoke(cli.cli, ['update', "-vv", "--pin-date", "--tag", "beta"])
assert result.exit_code == 0
tags = shell("git", "tag", "--list").decode("utf-8")
assert "v2019.1002-beta" in tags
commits = shell(*shlex.split("git log -l 2")).decode("utf-8").split("\n\n")
expected = "bump: v2019.1001-alpha (2019.1001a0) -> v2019.1002-beta (2019.1002b0)"
assert expected in commits[1]
def test_grep(runner):
_add_project_files("README.md")
search_re = r"^\s+2:\s+Hello World v2017\.1002-alpha !"
cmd1 = r'grep "vYYYY.BUILD[-TAG]" README.md'
result1 = runner.invoke(cli.cli, shlex.split(cmd1))
assert result1.exit_code == 0
assert re.search(search_re, result1.output, flags=re.MULTILINE)
cmd2 = r'grep --version-pattern "vYYYY.BUILD[-TAG]" "{version}" README.md'
result2 = runner.invoke(cli.cli, shlex.split(cmd2))
assert result2.exit_code == 0
assert re.search(search_re, result2.output, flags=re.MULTILINE)
assert result1.output == result2.output
search_re = r"^\s+3:\s+\[aka\. 2017\.1002a0 \!\]"
cmd3 = r'grep "\[aka. YYYY.BLD[PYTAGNUM] \!\]" README.md'
result3 = runner.invoke(cli.cli, shlex.split(cmd3))
assert result3.exit_code == 0
assert re.search(search_re, result3.output, flags=re.MULTILINE)
cmd4 = r'grep --version-pattern "vYYYY.BUILD[-TAG]" "\[aka. {pep440_version} \!\]" README.md'
result4 = runner.invoke(cli.cli, shlex.split(cmd4))
assert result4.exit_code == 0
assert re.search(search_re, result4.output, flags=re.MULTILINE)
assert result3.output == result4.output
SETUP_CFG_MULTIMATCH_FILE_PATTERNS_FIXTURE = r"""
[pycalver]
current_version = "v201701.1002-alpha"
version_pattern = "{pycalver}"
[pycalver:file_patterns]
setup.cfg =
current_version = "{version}"
README.md =
Hello World {version} !
README.* =
[aka. {pep440_version} !]
"""
def test_multimatch_file_patterns(runner):
_add_project_files("README.md")
with pl.Path("setup.cfg").open(mode="w", encoding="utf-8") as fobj:
fobj.write(SETUP_CFG_MULTIMATCH_FILE_PATTERNS_FIXTURE)
result = runner.invoke(cli.cli, ['update', '--tag', 'beta', '--date', "2020-11-22"])
assert result.exit_code == 0
with pl.Path("README.md").open(mode="r", encoding="utf-8") as fobj:
content = fobj.read()
assert "Hello World v202011.1003-beta !" in content
assert "[aka. 202011.1003b0 !]" in content
def _kwargs(year, month, minor=False):
return {'date': dt.date(year, month, 1), 'minor': minor}
ROLLOVER_TEST_CASES = [
# v1 cases
["{year}.{month}.{MINOR}", "2020.10.3", "2020.10.4", _kwargs(2020, 10, True)],
["{year}.{month}.{MINOR}", "2020.10.3", None, _kwargs(2020, 10, False)],
["{year}.{month}.{MINOR}", "2020.10.3", "2020.11.4", _kwargs(2020, 11, True)],
["{year}.{month}.{MINOR}", "2020.10.3", "2020.11.3", _kwargs(2020, 11, False)],
# v2 cases
["YYYY.MM.MINOR" , "2020.10.3", "2020.10.4", _kwargs(2020, 10, True)],
["YYYY.MM.MINOR" , "2020.10.3", None, _kwargs(2020, 10, False)],
["YYYY.MM.MINOR" , "2020.10.3", "2020.11.0", _kwargs(2020, 11, True)],
["YYYY.MM.MINOR" , "2020.10.3", "2020.11.0", _kwargs(2020, 11, False)],
["YYYY.MM[.MINOR]", "2020.10.3", "2020.10.4", _kwargs(2020, 10, True)],
["YYYY.MM[.MINOR]", "2020.10.3", "2020.11", _kwargs(2020, 11, False)],
["YYYY.MM.MINOR" , "2020.10.3", "2021.10.0", _kwargs(2021, 10, False)],
# incr0/incr1 part
["YYYY.MM.INC0", "2020.10.3", "2020.10.4", _kwargs(2020, 10)],
["YYYY.MM.INC0", "2020.10.3", "2020.11.0", _kwargs(2020, 11)],
["YYYY.MM.INC0", "2020.10.3", "2021.10.0", _kwargs(2021, 10)],
["YYYY.MM.INC1", "2020.10.3", "2020.10.4", _kwargs(2020, 10)],
["YYYY.MM.INC1", "2020.10.3", "2020.11.1", _kwargs(2020, 11)],
["YYYY.MM.INC1", "2020.10.3", "2021.10.1", _kwargs(2021, 10)],
]
@pytest.mark.parametrize("version_pattern, old_version, expected, kwargs", ROLLOVER_TEST_CASES)
def test_rollover(version_pattern, old_version, expected, kwargs):
new_version = cli.incr_dispatch(old_version, raw_pattern=version_pattern, **kwargs)
if new_version is None:
assert expected is None
else:
assert new_version == expected
def test_get_latest_vcs_version_tag(runner):
result = runner.invoke(cli.cli, ['init', "-vv"])
assert result.exit_code == 0
_update_config_val("bumpver.toml", push="false")
_update_config_val("bumpver.toml", current_version='"0.1.8"')
_update_config_val("bumpver.toml", version_pattern='"MAJOR.MINOR.PATCH"')
_vcs_init("git", files=["bumpver.toml"])
result = runner.invoke(cli.cli, ['update', "--patch"])
assert result.exit_code == 0
_, cfg = config.init()
latest_version = cli.get_latest_vcs_version_tag(cfg, fetch=False)
assert latest_version == "0.1.9"
result = runner.invoke(cli.cli, ['update', "--patch"])
assert result.exit_code == 0
_, cfg = config.init()
latest_version = cli.get_latest_vcs_version_tag(cfg, fetch=False)
assert latest_version == "0.1.10"

View file

@ -1,15 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
import io
from test import util
from bumpver import config
# pylint:disable=redefined-outer-name ; pytest fixtures
# pylint:disable=protected-access ; allowed for test code
import io
from pycalver import config
from . import util
PYCALVER_TOML_FIXTURE_1 = """
[pycalver]
current_version = "v201808.0123-alpha"
current_version = "v2020.1003-alpha"
version_pattern = "vYYYY.BUILD[-TAG]"
commit = true
tag = true
push = true
@ -29,6 +36,9 @@ PYCALVER_TOML_FIXTURE_2 = """
[pycalver]
current_version = "1.2.3"
version_pattern = "{semver}"
commit = false
tag = false
push = false
[pycalver.file_patterns]
"README.md" = [
@ -40,15 +50,34 @@ version_pattern = "{semver}"
]
"""
CALVER_TOML_FIXTURE_3 = """
[bumpver]
current_version = "v201808.0123-alpha"
version_pattern = "vYYYY0M.BUILD[-TAG]"
commit = true
tag = true
push = true
[bumpver.file_patterns]
"README.md" = [
"{version}",
"{pep440_version}",
]
"bumpver.toml" = [
'current_version = "{version}"',
]
"""
SETUP_CFG_FIXTURE = """
[pycalver]
[bumpver]
current_version = "v201808.0456-beta"
version_pattern = "vYYYY0M.BUILD[-TAG]"
commit = True
tag = True
push = True
[pycalver:file_patterns]
[bumpver:file_patterns]
setup.py =
{version}
{pep440_version}
@ -57,6 +86,26 @@ setup.cfg =
"""
NEW_PATTERN_CFG_FIXTURE = """
[bumpver]
current_version = "v201808.1456-beta"
version_pattern = "vYYYY0M.BUILD[-TAG]"
commit_message = "bump version to {new_version}"
commit = True
tag = True
push = True
[bumpver:file_patterns]
setup.py =
{version}
{pep440_version}
setup.cfg =
current_version = "{version}"
src/project/*.py =
Copyright (c) 2018-YYYY
"""
def mk_buf(text):
buf = io.StringIO()
buf.write(text)
@ -64,21 +113,31 @@ def mk_buf(text):
return buf
def _parse_raw_patterns_by_filepath(cfg):
return {
filepath: [pattern.raw_pattern for pattern in patterns]
for filepath, patterns in cfg.file_patterns.items()
}
def test_parse_toml_1():
buf = mk_buf(PYCALVER_TOML_FIXTURE_1)
raw_cfg = config._parse_toml(buf)
cfg = config._parse_config(raw_cfg)
assert cfg.current_version == "v201808.0123-alpha"
assert cfg.version_pattern == "{pycalver}"
assert cfg.current_version == "v2020.1003-alpha"
assert cfg.version_pattern == "vYYYY.BUILD[-TAG]"
assert cfg.commit is True
assert cfg.tag is True
assert cfg.push is True
assert "pycalver.toml" in cfg.file_patterns
assert cfg.file_patterns["README.md" ] == ["{pycalver}", "{pep440_pycalver}"]
assert cfg.file_patterns["pycalver.toml"] == ['current_version = "{pycalver}"']
files = set(cfg.file_patterns)
assert "pycalver.toml" in files
raw_patterns_by_path = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_path["README.md" ] == ["vYYYY.BUILD[-TAG]", "YYYY.BLD[PYTAGNUM]"]
assert raw_patterns_by_path["pycalver.toml"] == ['current_version = "vYYYY.BUILD[-TAG]"']
def test_parse_toml_2():
@ -94,11 +153,33 @@ def test_parse_toml_2():
assert cfg.push is False
assert "pycalver.toml" in cfg.file_patterns
assert cfg.file_patterns["README.md" ] == ["{semver}", "{semver}"]
assert cfg.file_patterns["pycalver.toml"] == ['current_version = "{semver}"']
raw_patterns_by_path = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_path["README.md" ] == ["{semver}", "{semver}"]
assert raw_patterns_by_path["pycalver.toml"] == ['current_version = "{semver}"']
def test_parse_cfg():
def test_parse_toml_3():
buf = mk_buf(CALVER_TOML_FIXTURE_3)
raw_cfg = config._parse_toml(buf)
cfg = config._parse_config(raw_cfg)
assert cfg.current_version == "v201808.0123-alpha"
assert cfg.version_pattern == "vYYYY0M.BUILD[-TAG]"
assert cfg.commit is True
assert cfg.tag is True
assert cfg.push is True
files = set(cfg.file_patterns)
assert "bumpver.toml" in files
raw_patterns_by_path = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_path["README.md" ] == ["vYYYY0M.BUILD[-TAG]", "YYYY0M.BLD[PYTAGNUM]"]
assert raw_patterns_by_path["bumpver.toml"] == ['current_version = "vYYYY0M.BUILD[-TAG]"']
def test_parse_v1_cfg():
buf = mk_buf(SETUP_CFG_FIXTURE)
raw_cfg = config._parse_cfg(buf)
@ -109,9 +190,33 @@ def test_parse_cfg():
assert cfg.tag is True
assert cfg.push is True
assert "setup.cfg" in cfg.file_patterns
assert cfg.file_patterns["setup.py" ] == ["{pycalver}", "{pep440_pycalver}"]
assert cfg.file_patterns["setup.cfg"] == ['current_version = "{pycalver}"']
files = set(cfg.file_patterns)
assert "setup.cfg" in files
raw_patterns_by_path = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_path["setup.py" ] == ["vYYYY0M.BUILD[-TAG]", "YYYY0M.BLD[PYTAGNUM]"]
assert raw_patterns_by_path["setup.cfg"] == ['current_version = "vYYYY0M.BUILD[-TAG]"']
def test_parse_v2_cfg():
buf = mk_buf(NEW_PATTERN_CFG_FIXTURE)
raw_cfg = config._parse_cfg(buf)
cfg = config._parse_config(raw_cfg)
assert cfg.current_version == "v201808.1456-beta"
assert cfg.commit_message == "bump version to {new_version}"
assert cfg.commit is True
assert cfg.tag is True
assert cfg.push is True
files = set(cfg.file_patterns)
assert "setup.py" in files
assert "setup.cfg" in files
raw_patterns_by_path = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_path["setup.py"] == ["vYYYY0M.BUILD[-TAG]", "YYYY0M.BLD[PYTAGNUM]"]
assert raw_patterns_by_path["setup.cfg"] == ['current_version = "vYYYY0M.BUILD[-TAG]"']
assert raw_patterns_by_path["src/project/*.py"] == ["Copyright (c) 2018-YYYY"]
def test_parse_default_toml():
@ -142,31 +247,34 @@ def test_parse_default_cfg():
def test_parse_project_toml():
project_path = util.FIXTURES_DIR / "project_a"
config_path = util.FIXTURES_DIR / "project_a" / "pycalver.toml"
config_path = util.FIXTURES_DIR / "project_a" / "bumpver.toml"
config_rel_path = "bumpver.toml"
with config_path.open() as fobj:
config_data = fobj.read()
assert "v201710.0123-alpha" in config_data
assert "v2017.0123-alpha" in config_data
ctx = config.init_project_ctx(project_path)
assert ctx == config.ProjectContext(project_path, config_path, "toml", None)
assert ctx == config.ProjectContext(project_path, config_path, config_rel_path, "toml", None)
cfg = config.parse(ctx)
assert cfg
assert cfg.current_version == "v201710.0123-alpha"
assert cfg.current_version == "v2017.0123-alpha"
assert cfg.commit is True
assert cfg.tag is True
assert cfg.push is True
assert set(cfg.file_patterns.keys()) == {"pycalver.toml", "README.md"}
files = set(cfg.file_patterns.keys())
assert files == {"bumpver.toml", "README.md"}
def test_parse_project_cfg():
project_path = util.FIXTURES_DIR / "project_b"
config_path = util.FIXTURES_DIR / "project_b" / "setup.cfg"
config_rel_path = "setup.cfg"
with config_path.open() as fobj:
config_data = fobj.read()
@ -174,7 +282,7 @@ def test_parse_project_cfg():
assert "v201307.0456-beta" in config_data
ctx = config.init_project_ctx(project_path)
assert ctx == config.ProjectContext(project_path, config_path, 'cfg', None)
assert ctx == config.ProjectContext(project_path, config_path, config_rel_path, 'cfg', None)
cfg = config.parse(ctx)
@ -194,44 +302,51 @@ def test_parse_project_cfg():
def test_parse_toml_file(tmpdir):
project_path = tmpdir.mkdir("minimal")
setup_cfg = project_path.join("pycalver.toml")
setup_cfg.write(PYCALVER_TOML_FIXTURE_1)
cfg_file = project_path.join("pycalver.toml")
cfg_file.write(PYCALVER_TOML_FIXTURE_1)
cfg_file_rel_path = "pycalver.toml"
ctx = config.init_project_ctx(project_path)
assert ctx == config.ProjectContext(project_path, setup_cfg, 'toml', None)
assert ctx == config.ProjectContext(project_path, cfg_file, cfg_file_rel_path, 'toml', None)
cfg = config.parse(ctx)
assert cfg
assert cfg.current_version == "v201808.0123-alpha"
assert cfg.current_version == "v2020.1003-alpha"
assert cfg.version_pattern == "vYYYY.BUILD[-TAG]"
assert cfg.tag is True
assert cfg.commit is True
assert cfg.push is True
assert cfg.file_patterns == {
"README.md" : ["{pycalver}", "{pep440_pycalver}"],
"pycalver.toml": ['current_version = "{pycalver}"'],
raw_patterns_by_filepath = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_filepath == {
"README.md" : ["vYYYY.BUILD[-TAG]", "YYYY.BLD[PYTAGNUM]"],
"pycalver.toml": ['current_version = "vYYYY.BUILD[-TAG]"'],
}
def test_parse_default_pattern():
project_path = util.FIXTURES_DIR / "project_c"
config_path = util.FIXTURES_DIR / "project_c" / "pyproject.toml"
config_rel_path = "pyproject.toml"
ctx = config.init_project_ctx(project_path)
assert ctx == config.ProjectContext(project_path, config_path, "toml", None)
assert ctx == config.ProjectContext(project_path, config_path, config_rel_path, "toml", None)
cfg = config.parse(ctx)
assert cfg
assert cfg.current_version == "v2017q1.54321"
# assert cfg.version_pattern == "vYYYYqQ.BUILD"
assert cfg.version_pattern == "v{year}q{quarter}.{build_no}"
assert cfg.commit is True
assert cfg.tag is True
assert cfg.push is True
assert cfg.file_patterns == {
raw_patterns_by_filepath = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_filepath == {
"pyproject.toml": [r'current_version = "v{year}q{quarter}.{build_no}"']
}
@ -240,21 +355,24 @@ def test_parse_cfg_file(tmpdir):
project_path = tmpdir.mkdir("minimal")
setup_cfg = project_path.join("setup.cfg")
setup_cfg.write(SETUP_CFG_FIXTURE)
setup_cfg_rel_path = "setup.cfg"
ctx = config.init_project_ctx(project_path)
assert ctx == config.ProjectContext(project_path, setup_cfg, 'cfg', None)
assert ctx == config.ProjectContext(project_path, setup_cfg, setup_cfg_rel_path, 'cfg', None)
cfg = config.parse(ctx)
assert cfg
assert cfg.current_version == "v201808.0456-beta"
assert cfg.version_pattern == "vYYYY0M.BUILD[-TAG]"
assert cfg.tag is True
assert cfg.commit is True
assert cfg.push is True
assert cfg.file_patterns == {
"setup.py" : ["{pycalver}", "{pep440_pycalver}"],
"setup.cfg": ['current_version = "{pycalver}"'],
raw_patterns_by_filepath = _parse_raw_patterns_by_filepath(cfg)
assert raw_patterns_by_filepath == {
"setup.py" : ["vYYYY0M.BUILD[-TAG]", "YYYY0M.BLD[PYTAGNUM]"],
"setup.cfg": ['current_version = "vYYYY0M.BUILD[-TAG]"'],
}
@ -288,8 +406,8 @@ def test_parse_missing_version(tmpdir):
setup_path.write(
"\n".join(
(
"[pycalver]",
# f"current_version = v201808.0001-dev",
"[bumpver]",
# f"current_version = v201808.1001-dev",
"commit = False",
)
)
@ -304,7 +422,7 @@ def test_parse_missing_version(tmpdir):
def test_parse_invalid_version(tmpdir):
setup_path = tmpdir.mkdir("fail").join("setup.cfg")
setup_path.write("\n".join(("[pycalver]", "current_version = 0.1.0", "commit = False")))
setup_path.write("\n".join(("[bumpver]", "current_version = 0.1.0", "commit = False")))
ctx = config.init_project_ctx(setup_path)
assert ctx

View file

@ -1,67 +0,0 @@
# pylint:disable=protected-access ; allowed for test code
import random
from pycalver import lex_id
def test_next_id_basic():
assert lex_id.next_id("01") == "02"
assert lex_id.next_id("09") == "110"
def test_next_id_overflow():
try:
prev_id = "9999"
next_id = lex_id.next_id(prev_id)
assert False, (prev_id, "->", next_id)
except OverflowError:
pass
def test_next_id_random():
for _ in range(1000):
prev_id = str(random.randint(1, 100 * 1000))
try:
next_id = lex_id.next_id(prev_id)
assert prev_id < next_id
except OverflowError:
assert len(prev_id) == prev_id.count("9")
def test_ord_val():
assert lex_id.ord_val("1" ) == 1
assert lex_id.ord_val("01" ) == 1
assert lex_id.ord_val("02" ) == 2
assert lex_id.ord_val("09" ) == 9
assert lex_id.ord_val("110") == 10
def test_main(capsys):
lex_id._main()
captured = capsys.readouterr()
assert len(captured.err) == 0
lines = iter(captured.out.splitlines())
header = next(lines)
assert "lexical" in header
assert "numerical" in header
ids = []
ord_vals = []
for line in lines:
if "..." in line:
continue
_id, _ord_val = line.split()
assert _id.endswith(_ord_val)
assert int(_ord_val) == int(_ord_val, 10)
ids.append(_id.strip())
ord_vals.append(int(_ord_val.strip()))
assert len(ids) > 0
assert sorted(ids) == ids
assert sorted(ord_vals) == ord_vals

View file

@ -1,4 +1,11 @@
from pycalver import parse
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
from bumpver import parse
from bumpver import v1patterns
SETUP_PY_FIXTURE = """
# setup.py
@ -13,15 +20,15 @@ setuptools.setup(
def test_default_parse_patterns():
lines = SETUP_PY_FIXTURE.splitlines()
patterns = ["{pycalver}", "{pep440_pycalver}"]
matches = list(parse.iter_matches(lines, patterns))
re_patterns = [v1patterns.compile_pattern(p) for p in patterns]
matches = list(parse.iter_matches(lines, re_patterns))
assert len(matches) == 2
assert matches[0].lineno == 3
assert matches[1].lineno == 6
assert matches[0].pattern == patterns[0]
assert matches[1].pattern == patterns[1]
assert matches[0].pattern == re_patterns[0]
assert matches[1].pattern == re_patterns[1]
assert matches[0].match == "v201712.0002-alpha"
assert matches[1].match == "201712.2a0"
@ -31,15 +38,15 @@ def test_explicit_parse_patterns():
lines = SETUP_PY_FIXTURE.splitlines()
patterns = ["__version__ = '{pycalver}'", "version='{pep440_pycalver}'"]
matches = list(parse.iter_matches(lines, patterns))
re_patterns = [v1patterns.compile_pattern(p) for p in patterns]
matches = list(parse.iter_matches(lines, re_patterns))
assert len(matches) == 2
assert matches[0].lineno == 3
assert matches[1].lineno == 6
assert matches[0].pattern == patterns[0]
assert matches[1].pattern == patterns[1]
assert matches[0].pattern == re_patterns[0]
assert matches[1].pattern == re_patterns[1]
assert matches[0].match == "__version__ = 'v201712.0002-alpha'"
assert matches[1].match == "version='201712.2a0'"
@ -58,15 +65,16 @@ def test_badge_parse_patterns():
lines = README_RST_FIXTURE.splitlines()
patterns = ["badge/CalVer-{calver}{build}-{release}-blue.svg", ":alt: CalVer {pycalver}"]
re_patterns = [v1patterns.compile_pattern(p) for p in patterns]
matches = list(parse.iter_matches(lines, re_patterns))
matches = list(parse.iter_matches(lines, patterns))
assert len(matches) == 2
assert matches[0].lineno == 3
assert matches[1].lineno == 5
assert matches[0].pattern == patterns[0]
assert matches[1].pattern == patterns[1]
assert matches[0].pattern == re_patterns[0]
assert matches[1].pattern == re_patterns[1]
assert matches[0].match == "badge/CalVer-v201809.0002--beta-blue.svg"
assert matches[1].match == ":alt: CalVer v201809.0002-beta"

View file

@ -1,17 +1,177 @@
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
import re
import pytest
from pycalver import patterns
from bumpver import v1patterns
from bumpver import v2patterns
V2_PART_PATTERN_CASES = [
(['YYYY', 'GGGG'], "2020" , "2020"),
(['YYYY', 'GGGG'], "" , None),
(['YYYY', 'GGGG'], "A020" , None),
(['YYYY', 'GGGG'], "020" , None),
(['YYYY', 'GGGG'], "12020", None),
(['YY' , 'GG' ], "20" , "20"),
(['YY' , 'GG' ], "3" , "3"),
(['YY' , 'GG' ], "03" , None),
(['YY' , 'GG' ], "2X" , None),
(['YY' , 'GG' ], "" , None),
(['0Y' , '0G' ], "20" , "20"),
(['0Y' , '0G' ], "03" , "03"),
(['0Y' , '0G' ], "3" , None),
(['0Y' , '0G' ], "2X" , None),
(['0Y' , '0G' ], "" , None),
# quarter
(['Q'], "0", None),
(['Q'], "1", "1"),
(['Q'], "2", "2"),
(['Q'], "3", "3"),
(['Q'], "4", "4"),
(['Q'], "5", None),
(['Q'], "X", None),
# months
(['MM'], "0" , None),
(['MM'], "01", None),
(['MM'], "1" , "1"),
(['MM'], "10", "10"),
(['MM'], "12", "12"),
(['MM'], "13", None),
(['0M'], "00", None),
(['0M'], "1" , None),
(['0M'], "01", "01"),
(['MM'], "10", "10"),
(['MM'], "12", "12"),
(['MM'], "13", None),
# day of month
(['DD'], "0" , None),
(['DD'], "01", None),
(['DD'], "1" , "1"),
(['DD'], "10", "10"),
(['DD'], "31", "31"),
(['DD'], "32", None),
(['0D'], "00", None),
(['0D'], "1" , None),
(['0D'], "01", "01"),
(['0D'], "10", "10"),
(['0D'], "31", "31"),
(['0D'], "32", None),
(['DD'], "0" , None),
(['DD'], "01", None),
(['DD'], "1" , "1"),
(['DD'], "10", "10"),
(['DD'], "31", "31"),
(['DD'], "32", None),
(['0D'], "00", None),
(['0D'], "1" , None),
(['0D'], "01", "01"),
(['0D'], "10", "10"),
(['0D'], "31", "31"),
(['0D'], "32", None),
# day of year
(['JJJ'], "0" , None),
(['JJJ'], "01" , None),
(['JJJ'], "1" , "1"),
(['JJJ'], "10" , "10"),
(['JJJ'], "31" , "31"),
(['JJJ'], "32" , "32"),
(['JJJ'], "100", "100"),
(['JJJ'], "365", "365"),
(['JJJ'], "366", "366"),
(['JJJ'], "367", None),
(['00J'], "000", None),
(['00J'], "01" , None),
(['00J'], "1" , None),
(['00J'], "001", "001"),
(['00J'], "010", "010"),
(['00J'], "031", "031"),
(['00J'], "032", "032"),
(['00J'], "100", "100"),
(['00J'], "365", "365"),
(['00J'], "366", "366"),
(['00J'], "367", None),
# week numbers
(['WW', 'UU'], "00", None),
(['WW', 'UU'], "01", None),
(['WW', 'UU'], "0" , "0"),
(['WW', 'UU'], "1" , "1"),
(['WW', 'UU'], "10", "10"),
(['WW', 'UU'], "52", "52"),
(['WW', 'UU'], "53", None),
(['0W', '0U'], "00", "00"),
(['0W', '0U'], "01", "01"),
(['0W', '0U'], "0" , None),
(['0W', '0U'], "1" , None),
(['0W', '0U'], "10", "10"),
(['0W', '0U'], "52", "52"),
(['0W', '0U'], "53", None),
(['VV'], "00", None),
(['VV'], "01", None),
(['VV'], "0" , None),
(['VV'], "1" , "1"),
(['VV'], "10", "10"),
(['VV'], "52", "52"),
(['VV'], "53", "53"),
(['VV'], "54", None),
(['0V'], "00", None),
(['0V'], "01", "01"),
(['0V'], "0" , None),
(['0V'], "1" , None),
(['0V'], "10", "10"),
(['0V'], "52", "52"),
(['0V'], "53", "53"),
(['0V'], "54", None),
(['MAJOR', 'MINOR', 'PATCH'], "0", "0"),
(['TAG' ], "alpha" , "alpha"),
(['TAG' ], "alfa" , None),
(['TAG' ], "beta" , "beta"),
(['TAG' ], "rc" , "rc"),
(['TAG' ], "post" , "post"),
(['TAG' ], "final" , "final"),
(['TAG' ], "latest", None),
(['PYTAG'], "a" , "a"),
(['PYTAG'], "b" , "b"),
(['PYTAG'], "rc" , "rc"),
(['PYTAG'], "post" , "post"),
(['PYTAG'], "post" , "post"),
(['PYTAG'], "x" , None),
(['NUM' ], "a" , None),
(['NUM' ], "0" , "0"),
(['NUM' ], "1" , "1"),
(['NUM' ], "10" , "10"),
]
def _part_re_by_name(name):
return re.compile(patterns.PART_PATTERNS[name])
def _compile_part_re(pattern_str):
grouped_pattern_str = r"^(?:" + pattern_str + r")$"
# print("\n", grouped_pattern_str)
return re.compile(grouped_pattern_str, flags=re.MULTILINE)
@pytest.mark.parametrize("part_name", patterns.PART_PATTERNS.keys())
def test_part_compilation(part_name):
assert _part_re_by_name(part_name)
@pytest.mark.parametrize("parts, testcase, expected", V2_PART_PATTERN_CASES)
def test_v2_part_patterns(parts, testcase, expected):
for part in parts:
part_re = _compile_part_re(v2patterns.PART_PATTERNS[part])
match = part_re.match(testcase)
if match is None:
assert expected is None
else:
assert match.group(0) == expected
@pytest.mark.parametrize("part_name", v2patterns.PART_PATTERNS.keys())
def test_v1_part_compilation(part_name):
assert _compile_part_re(v2patterns.PART_PATTERNS[part_name])
@pytest.mark.parametrize("part_name", v1patterns.PART_PATTERNS.keys())
def test_v2_part_compilation(part_name):
assert _compile_part_re(v1patterns.PART_PATTERNS[part_name])
PATTERN_PART_CASES = [
@ -20,8 +180,8 @@ PATTERN_PART_CASES = [
("pep440_pycalver", "201712.0033-alpha" , None),
("pycalver" , "v201712.0034" , "v201712.0034"),
("pycalver" , "v201712.0035-alpha" , "v201712.0035-alpha"),
("pycalver" , "v201712.0036-alpha0", "v201712.0036-alpha"),
("pycalver" , "v201712.0037-pre" , "v201712.0037"),
("pycalver" , "v201712.0036-alpha0", None),
("pycalver" , "v201712.0037-pre" , None), # pre not available for v1 patterns
("pycalver" , "201712.38a0" , None),
("pycalver" , "201712.0039" , None),
("semver" , "1.23.456" , "1.23.456"),
@ -38,34 +198,34 @@ PATTERN_PART_CASES = [
("release" , "-dev" , "-dev"),
("release" , "-rc" , "-rc"),
("release" , "-post" , "-post"),
("release" , "-pre" , ""),
("release" , "alpha" , ""),
("release" , "-pre" , None), # pre not available for v1 patterns
("release" , "alpha" , None), # missing dash "-" prefix
]
@pytest.mark.parametrize("part_name, line, expected", PATTERN_PART_CASES)
def test_re_pattern_parts(part_name, line, expected):
part_re = _part_re_by_name(part_name)
result = part_re.search(line)
def test_v1_re_pattern_parts(part_name, line, expected):
part_re = _compile_part_re(v1patterns.PART_PATTERNS[part_name])
result = part_re.match(line)
if result is None:
assert expected is None, (part_name, line)
assert expected is None, (part_name, line, result)
else:
result_val = result.group(0)
assert result_val == expected, (part_name, line)
PATTERN_CASES = [
PATTERN_V1_CASES = [
(r"v{year}.{month}.{MINOR}" , "v2017.11.1" , "v2017.11.1"),
(r"v{year}.{month}.{MINOR}" , "v2017.07.12", "v2017.07.12"),
(r"v{year}.{month_short}.{MINOR}", "v2017.11.1" , "v2017.11.1"),
(r"v{year}.{month_short}.{MINOR}", "v2017.7.12" , "v2017.7.12"),
(r"v{year}.{month_short}.{PATCH}", "v2017.11.1" , "v2017.11.1"),
(r"v{year}.{month_short}.{PATCH}", "v2017.7.12" , "v2017.7.12"),
]
@pytest.mark.parametrize("pattern_str, line, expected", PATTERN_CASES)
def test_patterns(pattern_str, line, expected):
pattern_re = patterns.compile_pattern(pattern_str)
result = pattern_re.search(line)
@pytest.mark.parametrize("pattern_str, line, expected", PATTERN_V1_CASES)
def test_v1_patterns(pattern_str, line, expected):
pattern = v1patterns.compile_pattern(pattern_str)
result = pattern.regexp.search(line)
if result is None:
assert expected is None, (pattern_str, line)
else:
@ -73,6 +233,24 @@ def test_patterns(pattern_str, line, expected):
assert result_val == expected, (pattern_str, line)
PATTERN_V2_CASES = [
("vYYYY.0M.MINOR" , "v2017.11.1" , "v2017.11.1"),
("vYYYY.0M.MINOR" , "v2017.07.12", "v2017.07.12"),
("YYYY.MM[.PATCH]", "2017.11.1" , "2017.11.1"),
("YYYY.MM[.PATCH]", "2017.7.12" , "2017.7.12"),
("YYYY.MM[.PATCH]", "2017.7" , "2017.7"),
("YYYY0M.BUILD" , "201707.1000", "201707.1000"),
]
@pytest.mark.parametrize("pattern_str, line, expected", PATTERN_V2_CASES)
def test_v2_patterns(pattern_str, line, expected):
pattern = v2patterns.compile_pattern(pattern_str)
result = pattern.regexp.search(line)
result_val = None if result is None else result.group(0)
assert result_val == expected, (pattern_str, line, pattern.regexp.pattern)
CLI_MAIN_FIXTURE = """
@click.group()
@click.version_option(version="v201812.0123-beta")
@ -81,9 +259,9 @@ CLI_MAIN_FIXTURE = """
def test_pattern_escapes():
pattern = 'click.version_option(version="{pycalver}")'
pattern_re = patterns.compile_pattern(pattern)
match = pattern_re.search(CLI_MAIN_FIXTURE)
pattern_str = 'click.version_option(version="{pycalver}")'
pattern = v1patterns.compile_pattern(pattern_str)
match = pattern.regexp.search(CLI_MAIN_FIXTURE)
expected = 'click.version_option(version="v201812.0123-beta")'
assert match.group(0) == expected
@ -94,8 +272,18 @@ package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"}
def test_curly_escapes():
pattern = 'package_metadata = {"name": "mypackage", "version": "{pycalver}"}'
pattern_re = patterns.compile_pattern(pattern)
match = pattern_re.search(CURLY_BRACE_FIXTURE)
pattern_str = 'package_metadata = {"name": "mypackage", "version": "{pycalver}"}'
pattern = v1patterns.compile_pattern(pattern_str)
match = pattern.regexp.search(CURLY_BRACE_FIXTURE)
expected = 'package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"}'
assert match.group(0) == expected
def test_part_field_mapping_v2():
a_names = set(v2patterns.PATTERN_PART_FIELDS.keys())
b_names = set(v2patterns.PART_PATTERNS.keys())
a_extra_names = a_names - b_names
assert not any(a_extra_names), sorted(a_extra_names)
b_extra_names = b_names - a_names
assert not any(b_extra_names), sorted(b_extra_names)

View file

@ -1,12 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
import re
import copy
from test import util
from bumpver import config
from bumpver import rewrite
from bumpver import v1rewrite
from bumpver import v1version
from bumpver import v2rewrite
from bumpver import v2version
from bumpver import v1patterns
from bumpver import v2patterns
# pylint:disable=protected-access ; allowed for test code
import copy
from pycalver import config
from pycalver import rewrite
from pycalver import version
# Fix for Python<3.7
# https://stackoverflow.com/a/56935186/62997
copy._deepcopy_dispatch[type(re.compile(''))] = lambda r, _: r
from . import util
REWRITE_FIXTURE = """
# SPDX-License-Identifier: MIT
@ -14,25 +31,60 @@ __version__ = "v201809.0002-beta"
"""
def test_rewrite_lines():
def test_v1_rewrite_lines_basic():
pattern = v1patterns.compile_pattern("{pycalver}", '__version__ = "{pycalver}"')
new_vinfo = v1version.parse_version_info("v201911.0003")
old_lines = REWRITE_FIXTURE.splitlines()
patterns = ['__version__ = "{pycalver}"']
new_vinfo = version.parse_version_info("v201911.0003")
new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines)
new_lines = v1rewrite.rewrite_lines([pattern], new_vinfo, old_lines)
assert len(new_lines) == len(old_lines)
assert "v201911.0003" not in "\n".join(old_lines)
assert "v201911.0003" in "\n".join(new_lines)
def test_rewrite_final():
def test_v1_rewrite_lines():
version_pattern = "{pycalver}"
new_vinfo = v1version.parse_version_info("v201811.0123-beta", version_pattern)
patterns = [v1patterns.compile_pattern(version_pattern, '__version__ = "{pycalver}"')]
lines = v1rewrite.rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-beta"'])
assert lines == ['__version__ = "v201811.0123-beta"']
patterns = [v1patterns.compile_pattern(version_pattern, '__version__ = "{pep440_version}"')]
lines = v1rewrite.rewrite_lines(patterns, new_vinfo, ['__version__ = "201809.2b0"'])
assert lines == ['__version__ = "201811.123b0"']
def test_v2_rewrite_lines():
version_pattern = "vYYYY0M.BUILD[-TAG]"
new_vinfo = v2version.parse_version_info("v201811.0123-beta", version_pattern)
patterns = [v2patterns.compile_pattern(version_pattern, '__version__ = "{version}"')]
lines = v2rewrite.rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-alpha" '])
assert lines == ['__version__ = "v201811.0123-beta" ']
lines = v2rewrite.rewrite_lines(
patterns, new_vinfo, ['__version__ = "v201809.0002-alpha" # comment']
)
assert lines == ['__version__ = "v201811.0123-beta" # comment']
patterns = [v2patterns.compile_pattern(version_pattern, '__version__ = "YYYY0M.BLD[PYTAGNUM]"')]
old_lines = ['__version__ = "201809.2a0"']
lines = v2rewrite.rewrite_lines(patterns, new_vinfo, old_lines)
assert lines == ['__version__ = "201811.123b0"']
def test_v1_rewrite_final():
# Patterns written with {release_tag} placeholder preserve
# the release tag even if the new version is -final
pattern = v1patterns.compile_pattern(
"v{year}{month}.{build_no}-{release_tag}",
'__version__ = "v{year}{month}.{build_no}-{release_tag}"',
)
new_vinfo = v1version.parse_version_info("v201911.0003")
old_lines = REWRITE_FIXTURE.splitlines()
patterns = ['__version__ = "v{year}{month}.{build_no}-{release_tag}"']
new_vinfo = version.parse_version_info("v201911.0003")
new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines)
new_lines = v1rewrite.rewrite_lines([pattern], new_vinfo, old_lines)
assert len(new_lines) == len(old_lines)
assert "v201911.0003" not in "\n".join(old_lines)
@ -43,14 +95,14 @@ def test_rewrite_final():
def test_iter_file_paths():
with util.Project(project="a") as project:
ctx = config.init_project_ctx(project.dir)
assert ctx
cfg = config.parse(ctx)
assert cfg
file_paths = {
str(file_path) for file_path, patterns in rewrite._iter_file_paths(cfg.file_patterns)
}
_paths_and_patterns = rewrite.iter_path_patterns_items(cfg.file_patterns)
file_paths = {str(file_path) for file_path, patterns in _paths_and_patterns}
assert file_paths == {"pycalver.toml", "README.md"}
assert file_paths == {"bumpver.toml", "README.md"}
def test_iter_file_globs():
@ -59,9 +111,8 @@ def test_iter_file_globs():
cfg = config.parse(ctx)
assert cfg
file_paths = {
str(file_path) for file_path, patterns in rewrite._iter_file_paths(cfg.file_patterns)
}
_paths_and_patterns = rewrite.iter_path_patterns_items(cfg.file_patterns)
file_paths = {str(file_path) for file_path, patterns in _paths_and_patterns}
assert file_paths == {
"setup.cfg",
@ -80,24 +131,30 @@ def test_error_bad_path():
(project.dir / "setup.py").unlink()
try:
list(rewrite._iter_file_paths(cfg.file_patterns))
list(rewrite.iter_path_patterns_items(cfg.file_patterns))
assert False, "expected IOError"
except IOError as ex:
assert "setup.py" in str(ex)
def test_error_bad_pattern():
def test_v1_error_bad_pattern():
with util.Project(project="b") as project:
ctx = config.init_project_ctx(project.dir)
cfg = config.parse(ctx)
assert cfg
patterns = copy.deepcopy(cfg.file_patterns)
patterns["setup.py"] = patterns["setup.py"][0] + "invalid"
original_pattern = patterns["setup.py"][0]
invalid_pattern = v1patterns.compile_pattern(
original_pattern.version_pattern,
original_pattern.raw_pattern + ".invalid",
)
patterns["setup.py"] = [invalid_pattern]
try:
new_vinfo = version.parse_version_info("v201809.1234")
list(rewrite.diff(new_vinfo, patterns))
old_vinfo = v1version.parse_version_info("v201808.0233")
new_vinfo = v1version.parse_version_info("v201809.1234")
list(v1rewrite.diff(old_vinfo, new_vinfo, patterns))
assert False, "expected rewrite.NoPatternMatch"
except rewrite.NoPatternMatch as ex:
assert "setup.py" in str(ex)
@ -109,23 +166,173 @@ __version__ = "2018.0002-beta"
"""
def test_optional_release():
old_lines = OPTIONAL_RELEASE_FIXTURE.splitlines()
pattern = "{year}.{build_no}{release}"
patterns = ['__version__ = "{year}.{build_no}{release}"']
def test_v1_optional_release():
version_pattern = "{year}.{build_no}{release}"
new_vinfo = v1version.parse_version_info("2019.0003", version_pattern)
new_vinfo = version.parse_version_info("2019.0003", pattern)
new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines)
raw_pattern = '__version__ = "{year}.{build_no}{release}"'
pattern = v1patterns.compile_pattern(version_pattern, raw_pattern)
old_lines = OPTIONAL_RELEASE_FIXTURE.splitlines()
new_lines = v1rewrite.rewrite_lines([pattern], new_vinfo, old_lines)
assert len(new_lines) == len(old_lines)
assert "2019.0003" not in "\n".join(old_lines)
new_text = "\n".join(new_lines)
assert "2019.0003" in new_text
assert "2019.0003" in "\n".join(new_lines)
assert '__version__ = "2019.0003"' in "\n".join(new_lines)
new_vinfo = version.parse_version_info("2019.0004-beta", pattern)
new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines)
new_vinfo = v1version.parse_version_info("2019.0004-beta", version_pattern)
new_lines = v1rewrite.rewrite_lines([pattern], new_vinfo, old_lines)
# make sure optional release tag is added back on
assert len(new_lines) == len(old_lines)
assert "2019.0004-beta" not in "\n".join(old_lines)
assert "2019.0004-beta" in "\n".join(new_lines)
assert '__version__ = "2019.0004-beta"' in "\n".join(new_lines)
def test_v2_optional_release():
version_pattern = "YYYY.BUILD[-TAG]"
new_vinfo = v2version.parse_version_info("2019.0003", version_pattern)
raw_pattern = '__version__ = "YYYY.BUILD[-TAG]"'
pattern = v2patterns.compile_pattern(version_pattern, raw_pattern)
old_lines = OPTIONAL_RELEASE_FIXTURE.splitlines()
new_lines = v2rewrite.rewrite_lines([pattern], new_vinfo, old_lines)
assert len(new_lines) == len(old_lines)
assert "2019.0003" not in "\n".join(old_lines)
assert "2019.0003" in "\n".join(new_lines)
assert '__version__ = "2019.0003"' in "\n".join(new_lines)
new_vinfo = v2version.parse_version_info("2019.0004-beta", version_pattern)
new_lines = v2rewrite.rewrite_lines([pattern], new_vinfo, old_lines)
# make sure optional release tag is added back on
assert len(new_lines) == len(old_lines)
assert "2019.0004-beta" not in "\n".join(old_lines)
assert "2019.0004-beta" in "\n".join(new_lines)
assert '__version__ = "2019.0004-beta"' in "\n".join(new_lines)
def test_v1_iter_rewritten():
version_pattern = "{year}{build}{release}"
new_vinfo = v1version.parse_version_info("2018.0123", version_pattern)
init_pattern = v1patterns.compile_pattern(
version_pattern, '__version__ = "{year}{build}{release}"'
)
file_patterns = {"src/bumpver/__init__.py": [init_pattern]}
rewritten_datas = v1rewrite.iter_rewritten(file_patterns, new_vinfo)
rfd = list(rewritten_datas)[0]
expected = [
"# This file is part of the pycalver project",
"# https://github.com/mbarkhau/pycalver",
"#",
"# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License",
"# SPDX-License-Identifier: MIT",
'"""BumpVer: A CLI program for versioning."""',
'',
'__version__ = "2018.0123"',
'',
]
assert rfd.new_lines == expected
def test_v2_iter_rewritten():
version_pattern = "YYYY.BUILD[-TAG]"
new_vinfo = v2version.parse_version_info("2018.0123", version_pattern)
file_patterns = {
"src/bumpver/__init__.py": [
v2patterns.compile_pattern(version_pattern, '__version__ = "YYYY.BUILD[-TAG]"'),
]
}
rewritten_datas = v2rewrite.iter_rewritten(file_patterns, new_vinfo)
rfd = list(rewritten_datas)[0]
expected = [
"# This file is part of the pycalver project",
"# https://github.com/mbarkhau/pycalver",
"#",
"# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License",
"# SPDX-License-Identifier: MIT",
'"""BumpVer: A CLI program for versioning."""',
'',
'__version__ = "2018.0123"',
'',
]
assert rfd.new_lines == expected
def test_v1_diff():
version_pattern = "{year}{build}{release}"
raw_pattern = '__version__ = "{year}{build}{release}"'
pattern = v1patterns.compile_pattern(version_pattern, raw_pattern)
file_patterns = {"src/bumpver/__init__.py": [pattern]}
old_vinfo = v1version.parse_version_info("v201809.0123")
new_vinfo = v1version.parse_version_info("v201911.1124")
assert new_vinfo > old_vinfo
old_vinfo = v1version.parse_version_info("2018.0123", version_pattern)
new_vinfo = v1version.parse_version_info("2019.1124", version_pattern)
diff_str = v1rewrite.diff(old_vinfo, new_vinfo, file_patterns)
lines = diff_str.split("\n")
assert lines[:2] == [
"--- src/bumpver/__init__.py",
"+++ src/bumpver/__init__.py",
]
assert lines[6].startswith('-__version__ = "20')
assert lines[7].startswith('+__version__ = "20')
assert not lines[6].startswith('-__version__ = "2018.0123"')
assert lines[7] == '+__version__ = "2019.1124"'
raw_pattern = "Copyright (c) 2018-{year}"
pattern = v1patterns.compile_pattern(version_pattern, raw_pattern)
file_patterns = {'LICENSE': [pattern]}
diff_str = v1rewrite.diff(old_vinfo, new_vinfo, file_patterns)
lines = diff_str.split("\n")
assert lines[3].startswith("-MIT License Copyright (c) 2018-20")
assert lines[4].startswith("+MIT License Copyright (c) 2018-2019")
def test_v2_diff():
version_pattern = "YYYY.BUILD[-TAG]"
raw_pattern = '__version__ = "YYYY.BUILD[-TAG]"'
pattern = v2patterns.compile_pattern(version_pattern, raw_pattern)
file_patterns = {"src/bumpver/__init__.py": [pattern]}
old_vinfo = v2version.parse_version_info("2018.0123", version_pattern)
new_vinfo = v2version.parse_version_info("2019.1124", version_pattern)
diff_str = v2rewrite.diff(old_vinfo, new_vinfo, file_patterns)
lines = diff_str.split("\n")
assert lines[:2] == [
"--- src/bumpver/__init__.py",
"+++ src/bumpver/__init__.py",
]
assert lines[6].startswith('-__version__ = "20')
assert lines[7].startswith('+__version__ = "20')
assert not lines[6].startswith('-__version__ = "2018.0123"')
assert lines[7] == '+__version__ = "2019.1124"'
raw_pattern = "Copyright (c) 2018-YYYY"
pattern = v2patterns.compile_pattern(version_pattern, raw_pattern)
file_patterns = {'LICENSE': [pattern]}
diff_str = v2rewrite.diff(old_vinfo, new_vinfo, file_patterns)
lines = diff_str.split("\n")
assert lines[3].startswith("-MIT License Copyright (c) 2018-20")
assert lines[4].startswith("+MIT License Copyright (c) 2018-2019")

View file

@ -1,32 +1,41 @@
# pylint:disable=protected-access ; allowed for test code
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals
import random
import datetime as dt
import pytest
from pycalver import version
from pycalver import patterns
from bumpver import version
from bumpver import v1version
from bumpver import v2version
from bumpver import v1patterns
from bumpver import v2patterns
# pylint:disable=protected-access ; allowed for test code
def test_bump_beta():
cur_version = "v201712.0001-beta"
assert cur_version < version.incr(cur_version)
assert version.incr(cur_version).endswith("-beta")
assert version.incr(cur_version, release="alpha").endswith("-alpha")
assert version.incr(cur_version, release="final").endswith("0002")
assert cur_version < v1version.incr(cur_version)
assert v1version.incr(cur_version).endswith("-beta")
assert v1version.incr(cur_version, tag="alpha").endswith("-alpha")
assert v1version.incr(cur_version, tag="final").endswith("0002")
def test_bump_final():
cur_version = "v201712.0001"
assert cur_version < version.incr(cur_version)
assert version.incr(cur_version).endswith(".0002")
assert version.incr(cur_version, release="alpha").endswith("-alpha")
assert cur_version < v1version.incr(cur_version)
assert v1version.incr(cur_version).endswith(".0002")
assert v1version.incr(cur_version, tag="alpha").endswith("-alpha")
assert version.incr(cur_version, release="final").endswith(".0002")
assert v1version.incr(cur_version, tag="final").endswith(".0002")
pre_version = cur_version + "-beta"
assert version.incr(pre_version, release="final").endswith(".0002")
assert v1version.incr(pre_version, tag="final").endswith(".0002")
def test_bump_future():
@ -34,7 +43,7 @@ def test_bump_future():
future_date = dt.datetime.today() + dt.timedelta(days=300)
future_calver = future_date.strftime("v%Y%m")
cur_version = future_calver + ".0001"
new_version = version.incr(cur_version)
new_version = v1version.incr(cur_version)
assert cur_version < new_version
@ -46,8 +55,8 @@ def test_bump_random(monkeypatch):
for _ in range(1000):
cur_date += dt.timedelta(days=int((1 + random.random()) ** 10))
new_version = version.incr(
cur_version, release=random.choice([None, "alpha", "beta", "rc", "final", "post"])
new_version = v1version.incr(
cur_version, tag=random.choice([None, "alpha", "beta", "rc", "final", "post"])
)
assert cur_version < new_version
cur_version = new_version
@ -55,7 +64,7 @@ def test_bump_random(monkeypatch):
def test_parse_version_info():
version_str = "v201712.0001-alpha"
version_info = version.parse_version_info(version_str)
version_info = v1version.parse_version_info(version_str)
# assert version_info.pep440_version == "201712.1a0"
# assert version_info.version == "v201712.0001-alpha"
@ -65,7 +74,7 @@ def test_parse_version_info():
assert version_info.tag == "alpha"
version_str = "v201712.0001"
version_info = version.parse_version_info(version_str)
version_info = v1version.parse_version_info(version_str)
# assert version_info.pep440_version == "201712.1"
# assert version_info.version == "v201712.0001"
@ -77,7 +86,7 @@ def test_parse_version_info():
def test_readme_pycalver1():
version_str = "v201712.0001-alpha"
version_info = patterns.PYCALVER_RE.match(version_str).groupdict()
version_info = v1patterns.PYCALVER_RE.match(version_str).groupdict()
assert version_info == {
'pycalver' : "v201712.0001-alpha",
@ -93,7 +102,7 @@ def test_readme_pycalver1():
def test_readme_pycalver2():
version_str = "v201712.0033"
version_info = patterns.PYCALVER_RE.match(version_str).groupdict()
version_info = v1patterns.PYCALVER_RE.match(version_str).groupdict()
assert version_info == {
'pycalver' : "v201712.0033",
@ -109,7 +118,7 @@ def test_readme_pycalver2():
def test_parse_error_empty():
try:
version.parse_version_info("")
v1version.parse_version_info("")
assert False
except version.PatternError as err:
assert "Invalid version string" in str(err)
@ -117,7 +126,7 @@ def test_parse_error_empty():
def test_parse_error_noprefix():
try:
version.parse_version_info("201809.0002")
v1version.parse_version_info("201809.0002")
assert False
except version.PatternError as err:
assert "Invalid version string" in str(err)
@ -125,60 +134,141 @@ def test_parse_error_noprefix():
def test_parse_error_nopadding():
try:
version.parse_version_info("v201809.2b0")
v1version.parse_version_info("v201809.2b0")
assert False
except version.PatternError as err:
assert "Invalid version string" in str(err)
def test_part_field_mapping():
a_names = set(version.PATTERN_PART_FIELDS.keys())
b_names = set(patterns.PART_PATTERNS.keys())
c_names = set(patterns.COMPOSITE_PART_PATTERNS.keys())
def test_part_field_mapping_v1():
a_names = set(v1patterns.PATTERN_PART_FIELDS.keys())
b_names = set(v1patterns.PART_PATTERNS.keys())
c_names = set(v1patterns.COMPOSITE_PART_PATTERNS.keys())
extra_names = a_names - b_names
assert not any(extra_names)
missing_names = b_names - a_names
assert missing_names == c_names
a_extra_names = a_names - b_names
assert not any(a_extra_names), sorted(a_extra_names)
b_extra_names = b_names - (a_names | c_names)
assert not any(b_extra_names), sorted(b_extra_names)
a_fields = set(version.PATTERN_PART_FIELDS.values())
b_fields = set(version.VersionInfo._fields)
a_fields = set(v1patterns.PATTERN_PART_FIELDS.values())
b_fields = set(version.V1VersionInfo._fields)
assert a_fields == b_fields
a_extra_fields = a_fields - b_fields
b_extra_fields = b_fields - a_fields
assert not any(a_extra_fields), sorted(a_extra_fields)
assert not any(b_extra_fields), sorted(b_extra_fields)
def vnfo(**field_values):
return version._parse_field_values(field_values)
def v1vnfo(**field_values):
return v1version._parse_field_values(field_values)
PARSE_VERSION_TEST_CASES = [
["{year}.{month}.{dom}" , "2017.06.07", vnfo(year="2017", month="06", dom="07")],
["{year}.{month}.{dom_short}" , "2017.06.7" , vnfo(year="2017", month="06", dom="7" )],
["{year}.{month}.{dom_short}" , "2017.06.7" , vnfo(year="2017", month="06", dom="7" )],
["{year}.{month_short}.{dom_short}", "2017.6.7" , vnfo(year="2017", month="6" , dom="7" )],
def v2vnfo(**field_values):
return v2version.parse_field_values_to_vinfo(field_values)
PARSE_V1_VERSION_TEST_CASES = [
["{year}.{month}.{dom}" , "2017.06.07", v1vnfo(year="2017", month="06", dom="07")],
["{year}.{month}.{dom_short}" , "2017.06.7" , v1vnfo(year="2017", month="06", dom="7" )],
["{year}.{month}.{dom_short}" , "2017.06.7" , v1vnfo(year="2017", month="06", dom="7" )],
["{year}.{month_short}.{dom_short}", "2017.6.7" , v1vnfo(year="2017", month="6" , dom="7" )],
["{year}.{month}.{dom}" , "2017.6.07" , None],
["{year}.{month}.{dom}" , "2017.06.7" , None],
["{year}.{month_short}.{dom}" , "2017.06.7" , None],
["{year}.{month}.{dom_short}" , "2017.6.07" , None],
["{year}.{month_short}.{MINOR}" , "2017.6.7" , vnfo(year="2017", month="6" , minor="7" )],
["{year}.{month}.{MINOR}" , "2017.06.7" , vnfo(year="2017", month="06", minor="7" )],
["{year}.{month}.{MINOR}" , "2017.06.07", vnfo(year="2017", month="06", minor="07")],
["{year}.{month_short}.{MINOR}" , "2017.6.7" , v1vnfo(year="2017", month="6" , minor="7" )],
["{year}.{month}.{MINOR}" , "2017.06.7" , v1vnfo(year="2017", month="06", minor="7" )],
["{year}.{month}.{MINOR}" , "2017.06.07", v1vnfo(year="2017", month="06", minor="07")],
["{year}.{month}.{MINOR}" , "2017.6.7" , None],
["YYYY.0M.0D" , "2017.06.07", v2vnfo(year_y="2017", month="06", dom="07")],
["YYYY.MM.DD" , "2017.6.7" , v2vnfo(year_y="2017", month="6" , dom="7" )],
["YYYY.MM.MD" , "2017.06.07", None],
["YYYY.0M.0D" , "2017.6.7" , None],
]
@pytest.mark.parametrize("pattern_str, line, expected_vinfo", PARSE_VERSION_TEST_CASES)
def test_parse_versions(pattern_str, line, expected_vinfo):
pattern_re = patterns.compile_pattern(pattern_str)
version_match = pattern_re.search(line)
@pytest.mark.parametrize("pattern_str, line, expected_vinfo", PARSE_V1_VERSION_TEST_CASES)
def test_v1_parse_versions(pattern_str, line, expected_vinfo):
if "{" in pattern_str:
pattern = v1patterns.compile_pattern(pattern_str)
version_match = pattern.regexp.search(line)
else:
pattern = v2patterns.compile_pattern(pattern_str)
version_match = pattern.regexp.search(line)
if expected_vinfo is None:
assert version_match is None
return
else:
assert version_match is not None
version_str = version_match.group(0)
version_info = version.parse_version_info(version_str, pattern_str)
if "{" in pattern_str:
version_info = v1version.parse_version_info(version_str, pattern_str)
assert version_info == expected_vinfo
else:
version_info = v2version.parse_version_info(version_str, pattern_str)
assert version_info == expected_vinfo
def test_v2_parse_versions():
_vnfo = v2version.parse_version_info("v201712.0033", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
fvals = {'year_y': 2017, 'month': 12, 'bid': "0033"}
assert _vnfo == v2version.parse_field_values_to_vinfo(fvals)
def test_v2_format_version():
version_pattern = "vYYYY0M.BUILD[-TAG[NUM]]"
in_version = "v200701.0033-beta"
vinfo = v2version.parse_version_info(in_version, raw_pattern=version_pattern)
out_version = v2version.format_version(vinfo, raw_pattern=version_pattern)
assert in_version == out_version
result = v2version.format_version(vinfo, raw_pattern="v0Y.BUILD[-TAG]")
assert result == "v07.0033-beta"
result = v2version.format_version(vinfo, raw_pattern="vYY.BLD[-TAG]")
assert result == "v7.33-beta"
result = v2version.format_version(vinfo, raw_pattern="vYY.BLD-TAG")
assert result == "v7.33-beta"
result = v2version.format_version(vinfo, raw_pattern='__version__ = "YYYY.BUILD[-TAG]"')
assert result == '__version__ = "2007.0033-beta"'
result = v2version.format_version(vinfo, raw_pattern='__version__ = "YYYY.BLD"')
assert result == '__version__ = "2007.33"'
WEEK_PATTERN_TEXT_CASES = [
("YYYYWW.PATCH", True),
("YYYYUU.PATCH", True),
("GGGGVV.PATCH", True),
("YYWW.PATCH" , True),
("YYUU.PATCH" , True),
("GGVV.PATCH" , True),
("0YWW.PATCH" , True),
("0YUU.PATCH" , True),
("0GVV.PATCH" , True),
("0Y0W.PATCH" , True),
("0Y0U.PATCH" , True),
("0G0V.PATCH" , True),
("GGGGWW.PATCH", False),
("GGGGUU.PATCH", False),
("YYYYVV.PATCH", False),
("GGWW.PATCH" , False),
("GGUU.PATCH" , False),
("YYVV.PATCH" , False),
("0GWW.PATCH" , False),
("0GUU.PATCH" , False),
("0YVV.PATCH" , False),
("0G0W.PATCH" , False),
("0G0U.PATCH" , False),
("0Y0V.PATCH" , False),
]
@pytest.mark.parametrize("pattern, expected", WEEK_PATTERN_TEXT_CASES)
def test_is_valid_week_pattern(pattern, expected):
assert v2version.is_valid_week_pattern(pattern) == expected

View file

@ -29,6 +29,7 @@ FIXTURE_PATH_PARTS = [
["setup.cfg"],
["setup.py"],
["pycalver.toml"],
["bumpver.toml"],
["src", "module_v1", "__init__.py"],
["src", "module_v2", "__init__.py"],
]
@ -40,7 +41,7 @@ class Project:
self.tmpdir = tmpdir
self.prev_cwd = os.getcwd()
self.dir = tmpdir / "pycalver_project"
self.dir = tmpdir / "bumpver_project"
self.dir.mkdir()
if project is None:
@ -76,7 +77,7 @@ class Project:
for path_parts in FIXTURE_PATH_PARTS:
maybe_file_path = self.dir.joinpath(*path_parts)
if maybe_file_path.exists():
self.shell(f"{cmd} add {str(maybe_file_path)}")
self.shell(cmd + " add " + str(maybe_file_path))
added_file_paths.append(maybe_file_path)
assert len(added_file_paths) >= 2