diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cdfcfa..bd9281b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0977023..e213607 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ff167b2..91295c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08dee2e..7abde12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Dockerfile b/Dockerfile index b0119f4..2cedc12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/" diff --git a/LICENSE b/LICENSE index 77be9d3..a7b7ab4 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/makefile b/Makefile similarity index 79% rename from makefile rename to Makefile index 25099b5..1d477a6 100644 --- a/makefile +++ b/Makefile @@ -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 diff --git a/makefile.bootstrapit.make b/Makefile.bootstrapit.make similarity index 96% rename from makefile.bootstrapit.make rename to Makefile.bootstrapit.make index fada680..842cfb5 100644 --- a/makefile.bootstrapit.make +++ b/Makefile.bootstrapit.make @@ -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 diff --git a/README.md b/README.md index 9b22567..aa729cb 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,38 @@ -# [PyCalVer: Automatic Calendar Versioning][repo_ref] +
+

+ logo +

+
-PyCalVer is a cli tool to search and replace version strings in the files of -your project. -By default PyCalVer uses a format that looks like this: -`v201812.0123-beta`, but it can be configured to generate version strings -in many formats, including SemVer and other CalVer variants. +# [BumpVer: Automatic Versioning][url_repo] + +With the CLI command `bumpver`, you can search for and update version strings in your project files. It has a flexible pattern syntax to support many version schemes ([SemVer][url_semver_org], [CalVer][url_calver_org] or otherwise). BumpVer features: + +- Configurable version patterns +- Optional Git or Mercurial integration +- Works with plaintext, so you can use it with any project. + +[url_repo]: https://gitlab.com/mbarkhau/bumpver +[url_semver_org]: https://semver.org/ +[url_calver_org]: https://calver.org/ Project/Repo: -[![MIT License][license_img]][license_ref] -[![Supported Python Versions][pyversions_img]][pyversions_ref] -[![PyCalVer v202007.0036][version_img]][version_ref] -[![PyPI Releases][pypi_img]][pypi_ref] -[![PyPI Downloads][downloads_img]][downloads_ref] +[![MIT License][img_license]][url_license] +[![Supported Python Versions][img_pyversions]][url_pyversions] +[![CalVer 2020.1041-beta][img_version]][url_version] +[![PyPI Releases][img_pypi]][url_pypi] +[![PyPI Downloads][img_downloads]][url_downloads] Code Quality/CI: -[![GitHub Build Status][github_build_img]][github_build_ref] -[![GitLab Build Status][gitlab_build_img]][gitlab_build_ref] -[![Type Checked with mypy][mypy_img]][mypy_ref] -[![Code Coverage][codecov_img]][codecov_ref] -[![Code Style: sjfmt][style_img]][style_ref] +[![GitHub Build Status][img_github_build]][url_github_build] +[![GitLab Build Status][img_gitlab_build]][url_gitlab_build] +[![Type Checked with mypy][img_mypy]][url_mypy] +[![Code Coverage][img_codecov]][url_codecov] +[![Code Style: sjfmt][img_style]][url_style] | Name | role | since | until | @@ -30,978 +40,925 @@ Code Quality/CI: | Manuel Barkhau (mbarkhau@gmail.com) | author/maintainer | 2018-09 | - | +[img_github_build]: https://github.com/mbarkhau/bumpver/workflows/CI/badge.svg +[url_github_build]: https://github.com/mbarkhau/bumpver/actions?query=workflow%3ACI + +[img_gitlab_build]: https://gitlab.com/mbarkhau/bumpver/badges/master/pipeline.svg +[url_gitlab_build]: https://gitlab.com/mbarkhau/bumpver/pipelines + +[img_codecov]: https://gitlab.com/mbarkhau/bumpver/badges/master/coverage.svg +[url_codecov]: https://mbarkhau.gitlab.io/bumpver/cov + +[img_license]: https://img.shields.io/badge/License-MIT-blue.svg +[url_license]: https://gitlab.com/mbarkhau/bumpver/blob/master/LICENSE + +[img_mypy]: https://img.shields.io/badge/mypy-checked-green.svg +[url_mypy]: https://mbarkhau.gitlab.io/bumpver/mypycov + +[img_style]: https://img.shields.io/badge/code%20style-%20sjfmt-f71.svg +[url_style]: https://gitlab.com/mbarkhau/straitjacket/ + +[img_downloads]: https://pepy.tech/badge/bumpver/month +[url_downloads]: https://pepy.tech/project/bumpver + +[img_version]: https://img.shields.io/static/v1.svg?label=CalVer&message=2020.1041-beta&color=blue +[url_version]: https://pypi.org/project/bumpver/ + +[img_pypi]: https://img.shields.io/badge/PyPI-wheels-green.svg +[url_pypi]: https://pypi.org/project/bumpver/#files + +[img_pyversions]: https://img.shields.io/pypi/pyversions/bumpver.svg +[url_pyversions]: https://pypi.python.org/pypi/bumpver + + -[](TOC) +- [Overview](#overview) + - [Search and Replace](#search-and-replace) + - [Related Projects/Alternatives](#related-projectsalternatives) +- [Example Usage](#example-usage) + - [Testing a version pattern](#testing-a-version-pattern) + - [SemVer: `MAJOR`/`MINOR`/`PATCH`](#semver-majorminorpatch) + - [Auto Increment Parts: `BUILD`/`INC0`/`INC1`](#auto-increment-parts-buildinc0inc1) + - [Persistent Parts: `BUILD`/`TAG`/`PYTAG`](#persistent-parts-buildtagpytag) + - [Searching for Patterns with `grep`](#searching-for-patterns-with-grep) +- [Reference](#reference) + - [Command Line](#command-line) + - [Part Overview](#part-overview) + - [Normalization Caveats](#normalization-caveats) + - [Pattern Examples](#pattern-examples) + - [Week Numbering](#week-numbering) +- [Configuration](#configuration) + - [Configuration Setup](#configuration-setup) + - [Debugging Configuration](#debugging-configuration) +- [Bump It Up](#bump-it-up) + - [Version State](#version-state) + - [The Current Version](#the-current-version) + - [Dry Mode](#dry-mode) + - [VCS Parameters (git/mercurial)](#vcs-parameters-gitmercurial) +- [Depricated Pattern Syntax](#depricated-pattern-syntax) -- [Usage](#usage) - - [Configuration](#configuration) - - [Pattern Search and Replacement](#pattern-search-and-replacement) - - [Examples](#examples) - - [Version State](#version-state) - - [The Current Version](#the-current-version) - - [Bump It Up](#bump-it-up) -- [The PyCalVer Format](#the-pycalver-format) - - [Parsing](#parsing) - - [Incrementing Behaviour](#incrementing-behaviour) - - [Lexical Ids](#lexical-ids) -- [Semantics of PyCalVer](#semantics-of-pycalver) - - [Intentional Breaking Changes](#intentional-breaking-changes) - - [Costs and Benefits](#costs-and-benefits) - - [Unintentional Breaking Changes](#unintentional-breaking-changes) - - [Pinning is not a Panacea](#pinning-is-not-a-panacea) - - [Zeno's 1.0 and The Eternal Beta](#zeno-s-1-0-and-the-eternal-beta) - -[](TOC) + -## Usage +## Overview -### Configuration +### Search and Replace -The fastest way to setup a project is to use `pycalver init`. +With `bumpver`, you configure a single `version_pattern` which is then used to +1. Search for version strings in your project files +2. Replace these with an updated/bumped version number. + +Your configuration might look something like this: + +``` +[bumpver] +current_version = "1.5.2" +version_pattern = "MAJOR.MINOR.PATCH" + +[bumpver:file_patterns] +setup.py + version="{version}", +src/mymodule/__init__.py + __version__ = "{version}" +``` + +Using this configuration, the output of `bumpver update --dry` might look something like this: + +```diff +$ bumpver update --patch --dry +INFO - Old Version: 1.5.2 +INFO - New Version: 1.5.3 +--- setup.py ++++ setup.py +@@ -63,7 +63,7 @@ + name="mymodule", +- version="1.5.2", ++ version="1.5.3", + description=description, + +--- src/mymodule/__init__.py ++++ src/mymodule/__init__.py +@@ -3,3 +3,3 @@ + +-__version__ = "1.5.2" ++__version__ = "1.5.3" +``` + + +### Name Change PyCalVer -> BumpVer + +This project was originally developed under the name PyCalVer, with the intent to support various CalVer schemes. The package and CLI command have since been renamed from PyCalVer/`pycalver` to BumpVer/`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. + + +### Related Projects/Alternatives + +If you are looking for an alternative, BumpVer was heavily influenced by [bumpversion / bump2version][url_bump2version]. You may also wish to take a look at their list of related projects: [bump2version/RELATED.md][url_bump2version_related] + +[url_bump2version] https://github.com/c4urself/bump2version/ + +[url_bump2version_related] https://github.com/c4urself/bump2version/blob/master/RELATED.md + + +## Example Usage + +You can override the date used by `bumpver` with the `--date=` option. Adding this every time would be distracting, so the examples assume the following date: ```shell -$ pip install pycalver -... -Installing collected packages: click pathlib2 typing toml six pycalver -Successfully installed pycalver-202007.36 +$ date --iso +2020-10-15 +``` -$ pycalver --version -pycalver, version v202007.0036 + +### Testing a `version_pattern` + +To test a `version_pattern` and how to increment it, you can use `bumpver test`: + +```shell +$ bumpver test 'v2020.37' 'vYYYY.WW' +New Version: v2020.41 +``` + +A `version_pattern` consists of three kinds of characters: + +- Literal text, such as `v`, `.`, and `-`, typically used as delimiters. +- A [valid part](#parts-overview) such as `YYYY`/`WW` in the previous example. +- Square brackets `[]` to mark an optional segment. + +The following example uses all three: `vYYYY.WW[-TAG]` + +``` + vYYYY.WW[-TAG] +literal text ^ ^ ^ +``` + +```shell +$ bumpver test 'v2020.37-beta' 'vYYYY.WW[-TAG]' +New Version: v2020.41-beta +PEP440 : 2020.41b0 +``` + +Here we see the week number changed from 37 to 41. The test command also shows the normalized version pattern according to [PEP440][pep_440_ref]. This removes the `"v"` prefix and shortens the release tag from `-beta` to `b0`. + +[pep_440_ref]: https://www.python.org/dev/peps/pep-0440/ + +To remove the release tag, use the option `--tag=final`. + +```shell +$ bumpver test 'v2020.37-beta' 'vYYYY.WW[-TAG]' --tag=final +New Version: v2020.41 +PEP440 : 2020.41 +``` + +### Using `MAJOR`/`MINOR`/`PATCH` (SemVer Parts) + +A CalVer `version_pattern` may not require any flags to determine which part should be incremented, so long as the date has changed. +With SemVer you must always specify one of `--major/--minor/--patch`. + +```shell +$ bumpver test '1.2.3' 'MAJOR.MINOR.PATCH[PYTAGNUM]' --major +New Version: 2.0.0 + +$ bumpver test '1.2.3' 'MAJOR.MINOR.PATCH[PYTAGNUM]' --minor +New Version: 1.3.0 + +$ bumpver test '1.2.3' 'MAJOR.MINOR.PATCH[PYTAGNUM]' --patch +New Version: 1.2.4 + +$ bumpver test '1.2.3' 'MAJOR.MINOR.PATCH[PYTAGNUM]' --patch --tag=beta +New Version: 1.2.4b0 + +$ bumpver test '1.2.4b0' 'MAJOR.MINOR.PATCH[PYTAGNUM]' --tag-num +New Version: 1.2.4b1 +``` + +These non date based parts also make sense for a CalVer `version_pattern`, so that you can create multiple releases in the same month. It is common to include e.g. a `PATCH` part. + +```shell +$ bumpver test '2020.10.0' 'YYYY.MM.PATCH' --patch +New Version: 2020.10.1 +``` + +Without this flag, we would get an error if the date is still in October. + +```shell +$ date --iso +2020-10-15 + +$ bumpver test '2020.10.0' 'YYYY.MM.PATCH' +ERROR - Invalid arguments or pattern, version did not change. +ERROR - Version did not change: '2020.10.0'. Invalid version and/or pattern 'YYYY.MM.PATCH'. +INFO - Perhaps try: bumpver test --patch +``` + +Once the date is in November, the `PATCH` part will roll over back to zero. This happens whenever parts to the left change (in this case the year and month), just as it does if `MAJOR` or `MINOR` were incremented in SemVer. + +```shell +$ bumpver test '2020.10.1' 'YYYY.MM.PATCH' --date 2020-11-01 +New Version: 2020.11.0 +``` + +The rollover to zero will happen even if you use the `--patch` argument, so that your first release in a month will always have a `PATCH` set to 0 instead of 1. You can make the `PATCH` part optional with `[.PATCH]` and always supply the `--patch` flag in your build script. This will cause the part to be omitted when 0 and added when > 0. + +```shell +$ bumpver test '2020.9.1' 'YYYY.MM[.PATCH]' --patch +New Version: 2020.10 + +$ bumpver test '2020.10' 'YYYY.MM[.PATCH]' --patch +New Version: 2020.10.1 + +$ bumpver test '2020.10.1' 'YYYY.MM[.PATCH]' --patch +New Version: 2020.10.2 +``` + + +With CalVer, the version is based on a calendar date, so you only have to specify such flags if you've already published a release for the current date. Without such a flag, BumpVer will show the error, that the "version did not change". + +```shell +$ bumpver test 'v2020.41-beta0' 'vYYYY.WW[-TAGNUM]' +ERROR - Invalid arguments or pattern, version did not change. +ERROR - Invalid version 'v2020.41-beta0' and/or pattern 'vYYYY.WW[-TAGNUM]'. +``` + +In this case you have to change one of the parts that are not based on a calendar date. + +```shell +$ bumpver test 'v2020.41-beta0' 'vYYYY.WW[-TAGNUM]' --tag-num +New Version: v2020.41-beta1 +PEP440 : 2020.41b1 + +$ bumpver test 'v2020.41-beta0' 'vYYYY.WW[-TAGNUM]' --tag=final +New Version: v2020.41 +PEP440 : 2020.41 +``` + +If a pattern is not applicable to a version string, then you will get an error message. + +```shell +$ bumpver test '2020.37' 'YYYY.MM' # expected to fail because 37 is not valid for part MM +ERROR - Incomplete match '2020.3' for version string '2020.37' with pattern 'YYYY.MM'/'(?P[1-9][0-9]{3})\.(?P1[0-2]|[1-9])' +ERROR - Invalid version '2020.37' and/or pattern 'YYYY.MM'. +``` + +This illustrates that each pattern is internally translated to a regular expression which must match the version string. The `--verbose` flag will show a verbose form of the regular expression, which may help to debug the discrepancy between the pattern and the version. + +```shell +$ bumpver test 'v2020.37' 'YYYY.WW' --verbose # missing "v" prefix +INFO - Using pattern YYYY.WW +INFO - regex = re.compile(r""" + (?P[1-9][0-9]{3}) + \. + (?P5[0-2]|[1-4][0-9]|[0-9]) +""", flags=re.VERBOSE) +ERROR - Invalid version string 'v2020.37' for pattern ... +``` + +To fix the above, you can either remove the "v" prefix from the version or add it to the pattern. + +```shell +$ bumpver test 'v2020.37' 'vYYYY.WW' # added "v" prefix +New Version: v2020.41 +PEP440 : 2020.41 +``` + + +### Auto Increment Parts: `BUILD`/`INC0`/`INC1` + +These parts are incremented automatically, and do not use/require a CLI flag: `BUILD`/`INC0`/`INC1`. + +```shell +$ bumpver test '2020.10.1' 'YYYY.MM.INC0' +New Version: 2020.10.2 + +$ bumpver test '2020.10.2' 'YYYY.MM.INC0' --date 2020-11-01 +New Version: 2020.11.0 +``` + +You can make the part optional using the `[PART]` syntax and it will be added/removed as needed. + +```shell +$ bumpver test '2020.10' 'YYYY.MM[.INC0]' +New Version: 2020.10.1 + +$ bumpver test '2020.10.1' 'YYYY.MM[.INC0]' --date 2020-11-01 +New Version: 2020.11 +``` + + +### Persistent Parts: `BUILD`/`TAG`/`PYTAG` + +The `BUILD` and `TAG` parts will not rollover/reset. Instead they are carried forward from one version to the next. + +```shell +$ bumpver test 'v2020.1051-beta' 'vYYYY.BUILD[-TAG]' +New Version: v2020.1052-beta +PEP440 : 2020.1052b0 + +$ bumpver test 'v2020.1051-beta' 'vYYYY.BUILD[-TAG]' --date 2021-01-01 +New Version: v2021.1052-beta +PEP440 : 2021.1052b0 + +$ bumpver test 'v2020.1051-beta' 'vYYYY.BUILD[-TAG]' --tag=rc +New Version: v2020.1052-rc +PEP440 : 2020.1052rc0 +``` + +To remove a release tag, mark it as final with `--tag=final`. + +```shell +$ bumpver test 'v2020.1051-beta' 'vYYYY.BUILD[-TAG]' --tag=final +New Version: v2020.1052 +PEP440 : 2020.1052 +``` + + +### Searching for Patterns with `grep` + +You can use `bumpver grep` to test and debug entries for your configuration. + +```shell +$ bumpver grep \ + '__version__ = "YYYY.MM[-TAGNUM]"' \ + src/module/__init__.py + + 3: + 4: __version__ = "2020.9-beta1" + 5: +``` + +When searching your project files for version strings, there are some limitations to keep in mind: + + 1. A version string cannot span multiple lines. + 2. Brackets `[]` can be escaped with backslash: `\[\]`. + 3. There is no way to escape a valid part (so you cannot match the literal text `YYYY`). + +Note that everything in the pattern is treated as literal text, except for a valid part (in all caps). + +``` + __version__ = "YYYY.MM[-TAGNUM]" +literal text ^^^^^^^^^^^^^^^ ^ ^ ^ +``` + +When you write your configuration, you can avoid repeating your version pattern in every search pattern, by using these placeholders + +- `{version}` +- `{pep440_version}` + +Applied to the above example, you can instead write this: + +```shell +$ bumpver grep \ + --version-pattern "YYYY.MM[-TAGNUM]" \ + '__version__ = "{version}"' \ + src/module/__init__.py + + 3: + 4: __version__ = "2020.9-beta1" + 5: +``` + +The corresponding configuration would look like this. + +```ini +[bumpver] +current_version = "2020.9-beta1" +version_pattern = "YYYY.MM[-TAGNUM]" +... + +[bumpver:file_patterns] +src/module/__init__.py + __version__ = "{version}" +... +``` + +If you use a version pattern that is not in the PEP440 normalized form (such as the one above), you can nonetheless match version strings in your project files which *are* in the [PEP440 normalized form][url_pep_440]. To do this, you can use the placeholder `{pep440_version}` instead of the `{version}` placeholder. + +```shell +$ bumpver grep --version-pattern "YYYY.MM[-TAGNUM]" 'version="{pep440_version}"' setup.py +setup.py + 65: url="https://github.com/org/project", + 66: version="2020.9b1", + 67: description=description, +``` + +The placeholder `{version}` matches `2020.9-beta1`, while the placeholder `{pep440_version}` matches `2020.9b1` (excluding the "v" prefix, the "-" separator and with a short form release tag "b1" instead of "beta1"). These two placeholders make it possible to mostly use your preferred format for version strings, but use a PEP440 compliant/normalized version string where appropriate. + +[url_pep_440]: https://www.python.org/dev/peps/pep-0440/ + +As a ~~neat trick~~ further illustration of how the search and replace works, you might wish to keep the year of your copyright headers up to date. + +```shell +$ bumpver grep 'Copyright (c) 2018-YYYY' src/mymodule/*.py | head +src/mymodule/__init__.py + 3: + 4: # Copyright (c) 2018-2020 Vandelay Industries - All rights reserved. + 5: + +src/mymodule/config.py + 3: + 4: # Copyright (c) 2018-2020 Vandelay Industries - All rights reserved. + 5: +``` + +The corresponding configuration for this pattern would look like this. + +```ini +[bumpver:file_patterns] +... +src/mymodule/*.py + Copyright (c) 2018-YYYY Vandelay Industries - All rights reserved. +``` + +Note that there must be a match for every entry in `file_patterns`. If there is no match, `bumpver` will show an error. This ensures that a pattern is not skipped when your project changes. In this case the side effect is to make sure that every file has a copyright header. + +```shell +$ bumpver update --dry +ERROR - No match for pattern 'Copyright (c) 2018-YYYY Vandelay Industries - All rights reserved.' +ERROR - +# https://regex101.com/?flavor=python&flags=gmx®ex=Copyright%5B%20%5D%5C%28c%5C%29%0A%5B%20%5D2018%5C-%0A%28%3FP%3Cyear_y%3E%5B1-9%5D%5B0-9%5D%7B3%7D%29%0A%5B%20%5DVandelay%5B%20%5DIndustries%5B%20%5D%5C-%5B%20%5DAll%5B%20%5Drights%5B%20%5Dreserved%5C. +regex = re.compile(r""" + Copyright[ ]\(c\) + [ ]2018\- + (?P[1-9][0-9]{3}) + [ ]Vandelay[ ]Industries[ ]\-[ ]All[ ]rights[ ]reserved\. +""", flags=re.VERBOSE) +ERROR - No patterns matched for file 'src/mymodule/utils.py' +``` + + +## Reference + +### Command Line + + + +``` +$ bumpver --help +Usage: bumpver [OPTIONS] COMMAND [ARGS]... + + Automatically update CalVer version strings in plaintext files. + +Options: + --version Show the version and exit. + --help Show this message and exit. + -v, --verbose Control log level. -vv for debug level. + +Commands: + update Increment the current version string and update project files. + grep Search file(s) for a version pattern. + init Initialize [bumpver] configuration. + show Show current version of your project. + test Increment a version number for demo purposes. +``` + + + + + +``` +$ bumpver update --help +Usage: bumpver update [OPTIONS] + + Increment the current version string and update project files. + +Options: + -v, --verbose Control log level. -vv for debug level. + -f, --fetch / -n, --no-fetch Sync tags from remote origin. + -d, --dry Display diff of changes, don't rewrite files. + --allow-dirty 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. + + --major Increment major component. + -m, --minor Increment minor component. + -p, --patch Increment patch component. + -t, --tag Override release tag of current_version. Valid + options are: alpha, beta, rc, post, final. + + --tag-num Increment release tag number (rc1, rc2, + rc3..). + + --pin-date Leave date components unchanged. + --date Set explicit date in format YYYY-0M-0D (e.g. + 2020-10-16). + + --help Show this message and exit. +``` + + + + +### Part Overview + +> Where possible, these patterns match the conventions from [CalVer.org][url_calver_org_scheme]. + +[url_calver_org_scheme]: https://calver.org/#scheme + +| part | range / example(s) | info | +|---------|---------------------------|--------------------------------------------| +| `MAJOR` | 0..9, 10..99, 100.. | `bumpver update --major` | +| `MINOR` | 0..9, 10..99, 100.. | `bumpver update --minor` | +| `PATCH` | 0..9, 10..99, 100.. | `bumpver update --patch` | +| `TAG` | alpha, beta, rc, post | `--tag=` | +| `PYTAG` | a, b, rc, post | `--tag=` | +| `NUM` | 0, 1, 2... | `-r/--tag-num` | +| `YYYY` | 2019, 2020... | Full year, based on `strftime('%Y')` | +| `YY` | 18, 19..99, 0, 1 | Short year, based on `int(strftime('%y'))` | +| `MM` | 9, 10, 11, 12 | Month, based on `int(strftime('%m'))` | +| `DD` | 1, 2, 3..31 | Day, based on `int(strftime('%d'))` | +| `BUILD` | 1001, 1002 .. 1999, 22000 | build number (maintains lexical order) | +| `INC0` | 0, 1, 2... | 0-based auto incrementing number | +| `INC1` | 1, 2... | 1-based auto incrementing number | + + +The following are also available, but you should review the [Normalization Caveats](#normalization-caveats) before you decide to use them. + + +| part | range / example(s) | comment | +| ------ | ------------------- | -------------------------------------------- | +| `Q` | 1, 2, 3, 4 | Quarter | +| `0Y` | 18, 19..99, 00, 01 | Short Year `strftime('%y')`(zero-padded) | +| `0M` | 09, 10, 11, 12 | Month `strftime('%m')` (zero-padded) | +| `0D` | 01, 02, 03..31 | Day `strftime('%d')` (zero-padded) | +| `JJJ` | 1,2,3..366 | Day of year `int(strftime('%j'))` | +| `00J` | 001, 002..366 | Day of year `strftime('%j')` (zero-padded) | +| `WW` | 0, 1, 2..52 | Week number¹ `int(strftime('%W'))` | +| `0W` | 00, 01, 02..52 | Week number¹ `strftime('%W')` (zero-padded) | +| `UU` | 0, 1, 2..52 | Week number² `int(strftime('%U'))` | +| `0U` | 00, 01, 02..52 | Week number² `strftime('%U')` (zero-padded) | +| `VV` | 1, 2..53 | Week number¹³ `int(strftime('%V'))` | +| `0V` | 01, 02..53 | Week number¹³ `strftime('%V')` (zero-padded) | +| `GGGG` | 2019, 2020... | `strftime("%G")` ISO 8601 week-based year | +| `GG` | 19, 20...99, 0, 1 | Short ISO 8601 week-based year | +| `0G` | 19, 20...99, 00, 01 | Zero-padded ISO 8601 week-based year | + +- ¹ Monday is the first day of the week. +- ² Sunday is the first day of the week. +- ³ ISO 8601 week. Week 1 contains Jan 4th. + + +### Normalization Caveats + +Package managers and installation tools will parse your version numbers. When doing so, your version number may go through a normalization process and may not be exactly as you specified. In the case of Python, the packaging tools (such as pip, twine, [setuptools][setuptools_ref]) follow [PEP440 normalization rules][pep_440_normalzation_ref]. + +According to these rules (among other things): + +- Any non-numerical prefix (such as `v`) is removed +- Leading zeros in delimited parts are truncated `XX.08` -> `XX.8` +- Tags are converted to a short form (`-alpha` -> `a0`) + +For example: + +- Pattern: `vYY.0M.0D[-TAG]` +- Version: `v20.08.02-beta` +- PEP440 : `20.8.2b0` + +I am not aware of any technical reason to use a normalized representation everywhere in your project. However, if you choose a pattern which is always in a normalized, it will help to avoid confusion. For example, it may not be obvious at a glance, that `v20.08.02-beta` is the same as `20.8.2b0` . + +A further consideration for the choice of your `version_pattern` is that it may be processed by tools that *do not* interpret it as a version number, but treat it just like any other string. It may also be confusing to your users if they a list of version numbers, sorted lexicographically by some tool (e.g. from `git tags`) and versions are not listed in order of their release: + +``` +$ git tag +18.6b4 +18.9b0 +19.10b0 +19.3b0 +20.8b0 +20.8b1 +``` + +If you wish to avoid this, you should use a pattern which maintains lexicographical ordering. + +[setuptools_ref]: https://setuptools.readthedocs.io/en/latest/setuptools.html#specifying-your-project-s-version + +[pep_440_normalzation_ref]: https://www.python.org/dev/peps/pep-0440/#id31 + + +### Pattern Examples + + + +| pattern | examples | PEP440 | lexico. | +|---------------------------------|-------------------------------------|--------|---------| +| `MAJOR.MINOR.PATCH[PYTAGNUM]` | `0.13.10 0.16.10rc1` | yes | no | +| `MAJOR.MINOR[.PATCH[PYTAGNUM]]` | `1.11 0.3.0b5` | yes | no | +| `YYYY.BUILD[PYTAGNUM]` | `2020.1031 2020.1148a0` | yes | yes | +| `YYYY.BUILD[-TAG]` | `2021.1393-beta 2022.1279` | no | yes | +| `YYYY.INC0[PYTAGNUM]` | `2020.10 2021.12b2` | yes | no | +| `YYYY0M.PATCH[-TAG]` | `202005.12 202210.15-beta` | no | no¹ | +| `YYYY0M.BUILD[-TAG]` | `202106.1071 202106.1075-beta` | no | yes | +| `YYYY.0M` | `2020.02 2022.09` | no | yes | +| `YYYY.MM` | `2020.8 2020.10` | yes | no | +| `YYYY.WW` | `2020.8 2021.14` | yes | no | +| `YYYY.MM.PATCH[PYTAGNUM]` | `2020.3.12b0 2021.6.19b0` | yes | no | +| `YYYY.0M.PATCH[PYTAGNUM]` | `2020.10.15b0 2022.07.7b0` | no | no¹ | +| `YYYY.MM.INC0` | `2021.6.2 2022.8.9` | yes | no | +| `YYYY.MM.DD` | `2020.5.18 2021.8.2` | yes | no | +| `YYYY.0M.0D` | `2020.08.24 2022.05.03` | no | yes | +| `YY.0M.PATCH` | `21.04.2 21.11.12` | no | no² | + + + +- ¹ If `PATCH > 9` +- ² For the year 2100, the part `YY` will produce 0 + + +### Week Numbering + +Week numbering is a bit special, as it depends on your definition of "week": + +- First day of the week is either Monday or Sunday. +- Range either from 0-52 or 1-53. +- At the beginning/end of the year, you either have partial weeks or a week that spans multiple years. + +If you use `VV`/`0V`, be aware that you cannot also use `YYYY`. +Instead use `GGGG`. This is to avoid an edge case where your version +number would run backwards if it was created around New Year. + + + + +```sql + YYYY WW UU GGGG VV +2020-12-26 (Sat): 2020 51 51 2020 52 +2020-12-27 (Sun): 2020 51 52 2020 52 +2020-12-28 (Mon): 2020 52 52 2020 53 +2020-12-29 (Tue): 2020 52 52 2020 53 +2020-12-30 (Wed): 2020 52 52 2020 53 +2020-12-31 (Thu): 2020 52 52 2020 53 +2021-01-01 (Fri): 2021 00 00 2020 53 +2021-01-02 (Sat): 2021 00 00 2020 53 +2021-01-03 (Sun): 2021 00 01 2020 53 +2021-01-04 (Mon): 2021 01 01 2021 01 +``` + + + + +## Configuration + +### Configuration Setup + +The create an initial configuration for project with `bumpver init`. + +```shell +$ pip install bumpver +... +Installing collected packages: click toml lexid bumpver +Successfully installed bumpver-2020.1042 $ cd myproject -~/myproject$ pycalver init --dry -WARNING - File not found: pycalver.toml -Exiting because of '--dry'. Would have written to pycalver.toml: +~/myproject/ - [pycalver] - current_version = "v201902.0001-alpha" - version_pattern = "{pycalver}" +$ bumpver init --dry +Exiting because of '-d/--dry'. Would have written to bumpver.toml: + + [bumpver] + current_version = "2020.1001a0" + version_pattern = "YYYY.BUILD[PYTAGNUM]" + commit_message = "bump version to {new_version}" commit = true tag = true push = true - [pycalver.file_patterns] + [bumpver.file_patterns] "README.md" = [ "{version}", "{pep440_version}", ] - "pycalver.toml" = [ + "bumpver.toml" = [ 'current_version = "{version}"', ] ``` -If you already have a `setup.cfg` file, the `init` sub-command will write to that -instead. +If you already have configuration file in your project (such as `setup.cfg` or `pyproject.toml`), then `bumpver init` will update that file instead. ``` -~/myproject$ ls -README.md setup.cfg setup.py - -~/myproject$ pycalver init -WARNING - Couldn't parse setup.cfg: Missing [pycalver] section. +~/myproject +$ bumpver init Updated setup.cfg ``` -This will add the something like the following to your `setup.cfg` -(depending on what files already exist in your project): +Your `setup.cfg` may now look something like this: ```ini -# setup.cfg -[pycalver] -current_version = "v201902.0001-alpha" -version_pattern = "{pycalver}" +[bumpver] +current_version = "2019.1001-alpha" +version_pattern = "YYYY.BUILD[-TAG]" +commit_message = "bump version to {new_version}" commit = True tag = True push = True -[pycalver:file_patterns] +[bumpver:file_patterns] setup.cfg = - current_version = {version} + current_version = "{version}" setup.py = - "{version}", - "{pep440_version}", + version="{pep440_version}", README.md = {version} {pep440_version} ``` -This probably won't cover every version number used in your project and you -will have to manually add entries to `pycalver:file_patterns`. Something -like the following may illustrate additional changes you might need to -make. -```ini -[pycalver:file_patterns] -setup.cfg = - current_version = {pycalver} -setup.py = - version="{pep440_pycalver}" -src/mymodule_v*/__init__.py = - __version__ = "{pycalver}" -README.md = - [PyCalVer {calver}{build}{release}] - img.shields.io/static/v1.svg?label=PyCalVer&message={pycalver}&color=blue -``` +### Debugging Configuration -To see if a pattern is found, you can use `pycalver bump --dry`, which will -leave your project files untouched and only show you a diff of the changes -it would have made. +For the entries in `[bumpver:file_patterns]` you can expect two failure modes: + +- False negative: A pattern *will not* match a version number in the associated file *which it should* match. +- False positive: A pattern *will* match something it *should not match* (less likely). + +Most obviously you will see such cases when you first attempt to use `bumpver update`: ```shell -$ pycalver bump --dry --no-fetch -INFO - Old Version: v201901.0001-beta -INFO - New Version: v201902.0002-beta +$ bumpver update --dry --no-fetch +INFO - Old Version: 2020.1001-alpha +INFO - New Version: 2020.1002-alpha +ERROR - No match for pattern 'version="YYYY.BUILD[PYTAGNUM]",' +ERROR - +# https://regex101.com/?flavor=python&flags=gmx®ex=version%3D%5C%22%0A%28%3FP%3Cyear_y%3E%5B1-9%5D%5B0-9%5D%7B3%7D%29%0A%5C.%0A%28%3FP%3Cbid%3E%5B1-9%5D%5B0-9%5D%2A%29%0A%28%3F%3A%0A%20%20%20%20%28%3FP%3Cpytag%3Epost%7Crc%7Ca%7Cb%29%0A%20%20%20%20%28%3FP%3Cnum%3E%5B0-9%5D%2B%29%0A%29%3F%0A%5C%22%2C +regex = re.compile(r""" + version=\" + (?P[1-9][0-9]{3}) + \. + (?P[1-9][0-9]*) + (?: + (?Ppost|rc|a|b) + (?P[0-9]+) + )? + \", +""", flags=re.VERBOSE) +ERROR - No patterns matched for file 'setup.py' +``` + +The internally used regular expression is also shown, which you can use to debug the issue, for example on [regex101.com](https://regex101.com/r/ajQDTz/2). + +To debug such issues, you can simplify your pattern and see if you can find a match with `bumpver grep` . + +```shell +$ bumpver grep 'YYYY.BUILD[PYTAGNUM]' setup.py + 45: name='myproject', + 46: version='2019.1001b0', + 47: license='MIT', + +``` + +Here we can see that the pattern for setup.py should be changed to used single quotes instead of doublequotes. + +As with `bumpver update`, if your pattern is not found, `bumpver grep` will show an error message with the regular expression it uses, to help you debug the issue. + +```shell +$ bumpver grep 'YYYY.BUILD[PYTAGNUM]' setup.py +ERROR - Pattern not found: 'YYYY.BUILD[PYTAGNUM]' +# https://regex101.com/... +``` + +An example of a more complex pattern is one where you want to keep a version badge in your README up to date. + +```shell +$ bumpver grep 'shields.io/badge/CalVer-YYYY.BUILD[--TAG]-blue' README.md + 61: + 62: [img_version]: https://img.shields.io/badge/CalVer-2020.1001--beta-blue + 63: [url_version]: https://pypi.org/org/package/ +``` + + +## Bump It Up + + +### Version State + +The `current_version` is considered global state and must be stored somewhere. Typically this might be in a `VERSION` file, or some other file which is part of the repository. This creates the risk that parallel branches can have different states. If the `current_version` were defined only by files in the local checkout, the same version might be generated on different systems for different commits. + +To avoid this issue, `bumpver` treats Git/Mercurial tags as the canonical / [SSOT][url_ssot] for the most recent version and attempts to change this state in the most atomic way possible. This is why some actions of the `bumpver` command can take a few seconds, as it is synchronizing with the remote repository to get the most recent versions and to push any new version tags as soon as possible. + +[url_ssot]: https://en.wikipedia.org/wiki/Single_source_of_truth + + +### The Current Version + +The current version is either + + - Typically: The largest Git/Mercurial tag which matches the `version_pattern` from your config, sorted using [`pkg_resources.parse_version`][url_setuptools_pkg_resources]. + - Rarely: Before any tags have been created, the value of `current_version` in `bumpver.toml` / `setup.cfg` / `pyproject.toml`. + +[url_setuptools_pkg_resources]: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#parsing-utilities + +As part of doing `bumpver update` and `bumpver show`, your local tags are updated using `git fetch --tags`/`hg pull`. + +```shell +$ bumpver show -vv +2020-10-18T20:20:58.062 DEBUG bumpver.cli - Logging configured. +2020-10-18T20:20:58.065 DEBUG bumpver.config - Config Parsed: Config( + ... +2020-10-18T20:20:58.067 DEBUG bumpver.vcs - vcs found: git +2020-10-18T20:20:58.067 INFO bumpver.vcs - fetching tags from remote (to turn off use: -n / --no-fetch) +2020-10-18T20:20:58.068 DEBUG bumpver.vcs - git fetch +2020-10-18T20:21:00.886 DEBUG bumpver.vcs - git tag --list +2020-10-18T20:21:00.890 INFO bumpver.cli - Latest version from git tag: 2020.1019 +Current Version: 2020.1019 +``` + +Here we see that: + +- Git had a newer version than we had locally (`2020.1019` vs `2020.1018`). +- It took 2 seconds to fetch the tags from the remote repository. + +The approach of fetching tags before the version is bumped/incremented, helps to reduce the risk that the newest tag is not known locally. This means that it less likely for the same version to be generated by different systems for different commits. This would result in an ambiguous version tag, which may not be the end of the world, but is better to avoid. Typically this might happen if you have a build system where multiple builds are triggered at the same time. + +For a small project (with only one maintainer and no automated packaging) this is a non-issue and you can always use `-n/--no-fetch` to skip fetching the tags. + + +### Dry Mode + +Once you have a valid configuration, you can use `bumpver update --dry` to see the changes it would make (and leave your project files untouched). + +```diff +$ bumpver update --dry --no-fetch +INFO - Old Version: 2019.1001-beta +INFO - New Version: 2019.1002-beta --- README.md +++ README.md @@ -11,7 +11,7 @@ [![Supported Python Versions][pyversions_img]][pyversions_ref] --[![Version v201901.0001][version_img]][version_ref] -+[![Version v201902.0002][version_img]][version_ref] +-[![Version 2019.1001-beta][version_img]][version_ref] ++[![Version 2019.1002-beta][version_img]][version_ref] [![PyPI Releases][pypi_img]][pypi_ref] --- src/mymodule_v1/__init__.py +++ src/mymodule_v1/__init__.py @@ -1,1 +1,1 @@ --__version__ = "v201901.0001-beta" -+__version__ = "v201902.0002-beta" +-__version__ = "2019.1001-beta" ++__version__ = "2019.1002-beta" --- src/mymodule_v2/__init__.py +++ src/mymodule_v2/__init__.py @@ -1,1 +1,1 @@ --__version__ = "v201901.0001-beta" -+__version__ = "v201902.0002-beta" +-__version__ = "2019.1001-beta" ++__version__ = "2019.1002-beta" --- setup.py +++ setup.py @@ -44,7 +44,7 @@ name="myproject", -- version="201901.1b0", -+ version="201902.2b0", +- version="2019.1001b0", ++ version="2019.1002b0", license="MIT", ``` -If there is no match for a pattern, bump will report an error. -```shell -$ pycalver bump --dry --no-fetch -INFO - Old Version: v201901.0001-beta -INFO - New Version: v201902.0002-beta -ERROR - No match for pattern 'img.shields.io/static/v1.svg?label=PyCalVer&message={pycalver}&color=blue' -ERROR - Pattern compiles to regex 'img\.shields\.io/static/v1\.svg\?label=PyCalVer&message=(?Pv(?P\d{4})(?P(?:0[0-9]|1[0-2]))\.(?P\d{4,})(?:-(?P -(?:alpha|beta|dev|rc|post|final)))?)&color=blue' -``` +### VCS Parameters (git/mercurial) -The internally used regular expression is also shown, which you can use to debug the issue, for example on [regex101.com](https://regex101.com/r/ajQDTz/2). +The individual steps performed by `bumpver update`: +0. Check that you have no local changes that are uncommitted. +1. *Fetch* the most recent global VCS tags from origin. +2. Generate the updated version string. +3. Replace version strings in all files configured in `file_patterns`. +4. *Commit* the updated files. +5. *Tag* the new commit. +6. *Push* the new commit and tag. -### Pattern Search and Replacement +The configuration for these steps can be done with the following parameters: -The `pycalver:file_patterns` section of the configuration is used both to search -and also to replace version strings in your projects files. Everything except -for valid placeholders is treated as literal text. Available placeholders are: +| Parameter | Type | Description | +|------------------|----------|-----------------------------------------| +| `commit_message` | string¹ | Template for commit message in step 4. | +| `commit` | boolean | Create a commit with all updated files. | +| `tag` | boolean² | Tag the newly created commit. | +| `push` | boolean² | Push to the default remote. | +- ¹ Available placeholders for the `commit_message`: `{new_version}`, `{old_version}`, `{new_version_pep440}`, `{old_version_pep440}` +- ² Requires `commit = True` -| placeholder | range / example(s) | comment | -|---------------------|---------------------|-----------------| -| `{pycalver}` | v201902.0001-beta | | -| `{pep440_pycalver}` | 201902.1b0 | | -| `{year}` | 2019... | `%Y` | -| `{yy}` | 18, 19..99, 01, 02 | `%y` | -| `{quarter}` | 1, 2, 3, 4 | | -| `{month}` | 09, 10, 11, 12 | `%m` | -| `{iso_week}` | 00..53 | `%W` | -| `{us_week}` | 00..53 | `%U` | -| `{dom}` | 01..31 | `%d` | -| `{doy}` | 001..366 | `%j` | -| `{build}` | .0123 | lexical id | -| `{build_no}` | 0123, 12345 | ... | -| `{release}` | -alpha, -beta, -rc | --release= | -| `{release_tag}` | alpha, beta, rc | ... | -| `{semver}` | 1.2.3 | | -| `{MAJOR}` | 1..9, 10..99, 100.. | --major | -| `{MINOR}` | 1..9, 10..99, 100.. | --minor | -| `{PATCH}` | 1..9, 10..99, 100.. | --patch | - - -There are some limitations to keep in mind: - - 1. A version string cannot span multiple lines. - 2. Characters generated by a placeholder cannot be escaped. - 3. The timezone is always UTC. - -The lack of escaping may for example be an issue with badge URLs. -You may want to put the following text in your README.md (note -that shields.io parses the two "-" dashes before `beta` as one -literal "-"): - -``` -https://img.shields.io/badge/myproject-v201812.0116--beta-blue.svg -``` - -While you could use the following pattern, which will work fine for a -while: +An example configuration might look like this: ```ini -README.md = - /badge/myproject-v{year}{month}.{build_no}--{release_tag}-blue.svg -``` - -Eventually this will break, when you do a `final` release, at -which point the following will be put in your README.md: - -``` -https://img.shields.io/badge/myproject-v201812.0117--final-blue.svg -``` - -When what you probably wanted was this (with the `--final` tag omitted): - -``` -https://img.shields.io/badge/myproject-v201812.0117-blue.svg -``` - -### Examples - -The easiest way to test a pattern is with the `pycalver test` sub-command. - -```shell -$ pycalver test 'v18w01' 'v{yy}w{iso_week}' -New Version: v19w06 -PEP440 : v19w06 - -$ pycalver test 'v18.01' 'v{yy}w{iso_week}' -ERROR - Invalid version string 'v18.01' for pattern - 'v{yy}w{iso_week}'/'v(?P\d{2})w(?P(?:[0-4]\d|5[0-3]))' -ERROR - Invalid version 'v18.01' and/or pattern 'v{yy}w{iso_week}'. -``` - -As you can see, each pattern is internally translated to a regular -expression. - -The `pycalver test` sub-command accepts the same cli flags as `pycalver -bump` to update the components that are not updated automatically (eg. -based on the calendar). - -```shell -$ pycalver test 'v18.1.1' 'v{yy}.{MINOR}.{PATCH}' -New Version: v19.1.1 -PEP440 : 19.1.1 - -$ pycalver test 'v18.1.1' 'v{yy}.{MINOR}.{PATCH}' --patch -New Version: v19.1.2 -PEP440 : 19.1.2 - -$ pycalver test 'v18.1.2' 'v{yy}.{MINOR}.{PATCH}' --minor -New Version: v19.2.0 -PEP440 : 19.2.0 - -$ pycalver test 'v201811.0051-beta' '{pycalver}' -New Version: v201902.0052-beta -PEP440 : 201902.52b0 - -$ pycalver test 'v201811.0051-beta' '{pycalver}' --release rc -New Version: v201902.0052-rc -PEP440 : 201902.52rc0 - -$ pycalver test 'v201811.0051-beta' '{pycalver}' --release final -New Version: v201902.0052 -PEP440 : 201902.52 -``` - -Note that pypi/setuptools/pip will normalize version strings to a format -defined in [PEP440][pep_440_ref]. You can use a format that deviates from -this, just be aware that version strings processed by these tools will look -different. - - -### Version State - -The "current version" is considered global state that needs to be -stored somewhere. Typically this might be stored in a `VERSION` -file, or some other file which is part of the repository. This -creates the risk that parallel branches can have different -states. If the "current version" were defined only by files in -the local checkout, the same version might be generated for -different commits. - -To avoid this issue, pycalver treats VCS tags as the canonical / -[SSOT][ssot_ref] for the most recent version and attempts to -change this state in the most atomic way possible. This is why -some actions of the `pycalver` command can take a while, as it is -synchronizing with the remote repository to get the most recent -versions and to push any new version tags as soon as possible. - - -### The Current Version - -The current version that will be bumped is defined either as - - - Typically: The lexically largest git/mercurial tag which matches the - `version_pattern` from your config. - - Initially: Before any tags have been created (or you're not using a - supported VCS), the value of `pycalver.current_version` in `setup.cfg` / - `pyproject.toml` / `pycalver.toml`. - -As part of doing `pycalver bump`, your local VCS index is updated using -`git fetch --tags`/`hg pull`. This reduces the risk that some tags are -unknown locally and makes it less likely that the same version string is -generated for different commits, which would result in an ambiguous version -tag. This can happen if multiple maintainers produce a release at the same -time or if a build system is triggered multiple times and multiple builds -run concurrently to each other. For a small project (with only one -maintainer and no build system) this is a non-issue and you can always use -`-n/--no-fetch` to skip updating the tags. - -```shell -$ time pycalver show --verbose -INFO - fetching tags from remote (to turn off use: -n / --no-fetch) -INFO - Working dir version : v201812.0018 -INFO - Latest version from git tag: v201901.0019-beta -Current Version: v201901.0019-beta -PEP440 : 201901.19b0 - -real 0m4,254s - -$ time pycalver show --verbose --no-fetch +[bumpver] ... -real 0m0,840s -``` - - -### Bump It Up - -To increment the current version and publish a new version, you can use the -`pycalver bump` sub-command. `bump` is configured in the `pycalver` config -section: - -```ini -[pycalver] -current_version = "v201812.0006-beta" -version_pattern = "{pycalver}" +commit_message = "bump version to {new_version}" commit = True tag = True push = True ``` -This configuration is appropriate to create a commit which +If everything looks OK, you can do `bumpver update`. -1. contains the changes to the version strings, -2. contains no other changes (unrelated to bumping the version), -3. is tagged with the new version, -4. has a version tag that is unique in the repository. - -In order to make sure only changes to version strings are in the commit, -you need to make sure you have a clean VCS checkout when you invoke -`pycalver bump`. - -The steps performed by `bump` are: - -0. Check that your repo doesn't have any local changes. -1. *Fetch* the most recent global VCS tags from origin - (`-n`/`--no-fetch` to disable). -2. Generate a new version, incremented from the current version. -3. Update version strings in all files configured in `file_patterns`. -4. *Commit* the updated version strings. -5. *Tag* the new commit. -6. *Push* the new commit and tag. - -Again, you can use `--dry` to inspect the changes first. - -``` -$ pycalver bump --dry ---- setup.cfg -+++ setup.cfg -@@ -65,7 +65,7 @@ - - [pycalver] --current_version = v201812.0005-beta -+current_version = v201812.0006-beta - commit = True - tag = True - push = True -... -``` - -If everything looks OK, you can do `pycalver bump`. - -``` -$ pycalver bump --verbose +```shell +$ bumpver update --verbose INFO - fetching tags from remote (to turn off use: -n / --no-fetch) -INFO - Old Version: v201812.0005-beta -INFO - New Version: v201812.0006-beta -INFO - git commit --file /tmp/tmpph_npey9 -INFO - git tag --annotate v201812.0006-beta --message v201812.0006-beta -INFO - git push origin v201812.0006-beta +INFO - Old Version: 2020.1005 +INFO - New Version: 2020.1006 +INFO - git commit --message 'bump version to 2020.1006' +INFO - git tag --annotate 2020.1006 --message 2020.1006 +INFO - git push origin --follow-tags 2020.1006 HEAD ``` - - -## The PyCalVer Format - -The PyCalVer format for version strings has three parts: - -``` - - o Year and Month of Release - | o Sequential Build Number - | | o Release Tag (optional) - | | | - ---+--- --+-- --+-- - v201812 .0123 -beta - - -``` - -Some examples: - -``` -v201711.0001-alpha -v201712.0027-beta -v201801.0031 -v201801.0032-post -... -v202207.18133 -v202207.18134 -``` - -This slightly verbose format was chosen in part to be distinctive from -others, so that users of your package can see at a glance that your project -will strive to maintain the one semantic that really matters: **newer == -better**. - -To convince you of the merits of not breaking things, here are some -resources which PyCalVer was inspired by: - - - ["Speculation" talk by Rich - Hicky](https://www.youtube.com/watch?v=oyLBGkS5ICk) - - [Designing a Version by Mahmoud - Hashemi](http://sedimental.org/designing_a_version.html) - - [calver.org](https://calver.org/) - - ["The cargo cult of versioning" by Kartik - Agaram](http://akkartik.name/post/versioning) - - The [bumpversion][bumpversion_ref] project, upon which - PyCalVer is partially based. - - ["Our Software Dependency Problem" by Russ Cox](https://research.swtch.com/deps) - - -### Parsing - -These version strings can be parsed with the following regular expression: - -```python -import re - -# https://regex101.com/r/fnj60p/10 -PYCALVER_PATTERN = r""" -\b -(?P - (?P - v # "v" version prefix - (?P\d{4}) - (?P\d{2}) - ) - (?P - \. # "." build nr prefix - (?P\d{4,}) - ) - (?P - \- # "-" release prefix - (?Palpha|beta|dev|rc|post) - )? -)(?:\s|$) -""" -PYCALVER_REGEX = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE) - -version_str = "v201712.0001-alpha" -version_match = PYCALVER_REGEX.match(version_str) - -assert version_match.groupdict() == { - "pycalver" : "v201712.0001-alpha", - "vYYYYMM" : "v201712", - "year" : "2017", - "month" : "12", - "build" : ".0001", - "build_no" : "0001", - "release" : "-alpha", - "release_tag": "alpha", -} - -version_str = "v201712.0033" -version_match = PYCALVER_REGEX.match(version_str) - -assert version_match.groupdict() == { - "pycalver" : "v201712.0033", - "vYYYYMM" : "v201712", - "year" : "2017", - "month" : "12", - "build" : ".0033", - "build_no" : "0033", - "release" : None, - "release_tag": None, -} -``` - -### Incrementing Behaviour - -To see how version strings are incremented, we can use -`pycalver test`: - -```shell -$ pycalver test v201801.0033-beta -New Version: v201902.0034-beta -PEP440 : 201902.34b0 -``` - -This is the simple case: - - - The calendar component is updated to the current year and - month. - - The build number is incremented by 1. - - The optional release tag is preserved as is. - -You can explicitly update the release tag by using the -`--release=` argument: - -```shell -$ pycalver test v201801.0033-alpha --release=beta -New Version: v201902.0034-beta -PEP440 : 201902.34b0 - -$ pycalver test v201902.0034-beta --release=final -New Version: v201902.0035 -PEP440 : 201902.35 -``` - -To maintain lexical ordering of version numbers, the version number is padded -with extra zeros (see [Lexical Ids](#lexical-ids) ). - - -### Lexical Ids - -The build number padding may eventually be exhausted. In order to preserve -lexical ordering, build numbers for the `{build_no}` pattern are -incremented in a special way. Examples will perhaps illustrate more -clearly. - -```python -"0001" -"0002" -"0003" -... -"0999" -"11000" -"11001" -... -"19998" -"19999" -"220000" -"220001" -``` - -What is happening here is that the left-most digit is incremented -early/preemptively. Whenever the left-most digit would change, the padding -of the id is expanded through a multiplication by 11. - -```python ->>> prev_id = "0999" ->>> num_digits = len(prev_id) ->>> num_digits -4 ->>> prev_int = int(prev_id, 10) ->>> prev_int -999 ->>> maybe_next_int = prev_int + 1 ->>> maybe_next_int -1000 ->>> maybe_next_id = f"{maybe_next_int:0{num_digits}}" ->>> maybe_next_id -"1000" ->>> is_padding_ok = prev_id[0] == maybe_next_id[0] ->>> is_padding_ok -False ->>> if is_padding_ok: -... # normal case -... next_id = maybe_next_id -... else: -... # extra padding needed -... next_int = maybe_next_int * 11 -... next_id = str(next_int) ->>> next_id -"11000" -``` - -This behaviour ensures that the following semantic is always preserved: -`new_version > old_version`. This will be true, regardless of padding -expansion. To illustrate the issue this solves, consider what would happen -if we did not expand the padding and instead just incremented numerically. - -```python -"0001" -"0002" -"0003" -... -"0999" -"1000" -... -"9999" -"10000" -``` - -Here we eventually run into a build number where the lexical ordering is -not preserved, since `"10000" > "9999" == False` (because the string `"1"` -is lexically smaller than `"9"`). With large enough padding this may be a -non issue, but it's better to not have to think about it. - -Just as an example of why lexical ordering is a nice property to have, -there are lots of software which read git tags, but which have no logic to -parse version strings. This software can nonetheless order the version tags -correctly using commonly available lexical ordering. At the most basic -level it can allow you to use the UNIX `sort` command, for example to parse -VCS tags. - - -```shell -$ printf "v0.9.0\nv0.10.0\nv0.11.0\n" | sort -v0.10.0 -v0.11.0 -v0.9.0 - -$ printf "v0.9.0\nv0.10.0\nv0.11.0\n" | sort -n -v0.10.0 -v0.11.0 -v0.9.0 - -$ printf "0998\n0999\n11000\n11001\n11002\n" | sort -0998 -0999 -11000 -11001 -11002 -``` - -This sorting even works correctly in JavaScript! - -``` -> var versions = ["11002", "11001", "11000", "0999", "0998"]; -> versions.sort(); -["0998", "0999", "11000", "11001", "11002"] -``` - - -## Semantics of PyCalVer - -> Disclaimer: This section can of course only be aspirational. There is nothing -> to prevent package maintainers from publishing packages with different -> semantics than what is presented here. - -PyCalVer places a greater burden on package maintainers than SemVer. -Backward incompatibility is not encoded in the version string, because -**maintainers should not intentionally introduce breaking changes**. This -is great for users of a package, who can worry a bit less about an update -causing their project to break. A paranoid user can of course still pin to -a known good version, and freezing dependencies for deployments is still a -good practice, but for development, users ideally shouldn't need any -version specifiers in their requirements.txt. This way they always get the -newest bug fixes and features. - -Part of the reason for the distinctive PyCalVer version string, is for -users to be able to recognize, just from looking at the version string, -that a package comes with the promise (or at least aspiration) that it -won't break, that it is safe for users to update. Compare this to a SemVer -version string, where maintainers explicitly state that an update _might_ -break their program and that they _may_ have to do extra work after -updating and even if it hasn't in the past, the package maintainers -anticipate that they might make such breaking changes in the future. - -In other words, the onus is on the user of a package to update their -software, if they want to update to the latest version of a package. With -PyCalVer the onus is on package maintainer to maintain backward -compatibility. - -Ideally users can trust the promise of a maintainer that the following -semantics will always be true: - - - Newer is compatible. - - Newer has fewer bugs. - - Newer has more features. - - Newer has equal or better performance. - -Alas, the world is not ideal. So how do users and maintainers deal with changes -that violate these promises? - - -### Intentional Breaking Changes - -> Namespaces are a honking great idea -> - let's do more of those! -> -> - The Zen of Python - -If you must make a breaking change to a package, **instead of incrementing a -number**, the recommended approach with PyCalVer is to **create a whole new -namespace**. Put differently, the major version becomes part of the name of the -module or even of the package. Typically you might add a numerical suffix, eg. -`mypkg -> mypkg2`. - -In the case of python distributions, you can include multiple module -packages like this. - -```python -# setup.py -setuptools.setup( - name="my-package", - license="MIT", - packages=["mypkg", "mypkg2"], - package_dir={"": "src"}, - ... -) -``` - -In other words, you can ship older versions side by side with newer ones, -and users can import whichever one they need. Alternatively you can publish -a new package distribution, with new namespace, but please consider also -renaming the module. - -```python -# setup.py -setuptools.setup( - name="my-package-v2", - license="MIT", - packages=["mypkg2"], - package_dir={"": "src"}, - ... -) -``` - -Users will have an easier time working with your package if `import mypkg2` -is enough to determine which version of your project they are using. A further -benefit of creating multiple modules is that users can import both old and -new modules in the same environment and can use some packages which depend -on the old version as well as some that depend on the new version. The -downside for users, is that they may have to do minimal changes to their -code, even if the breaking change did not affect them. - -```diff -- import mypkg -+ import mypkg2 - - def usage_code(): -- mypkg.myfun() -+ mypkg2.myfun() -``` - - -### Costs and Benefits - -If this seems like overkill because it's a lot of work for you as a -maintainer, consider first investing some time in your tools, so you -minimize future work required to create new packages. I've [done this for -my personal projects][bootstrapit_ref], but you may find [other -approaches][cookiecutter_ref] to be more appropriate for your use. - -If this seems like overkill because you're not convinced that imposing a -very small burden on users is such a big deal, consider that your own -projects may indirectly depend on dozens of libraries which you've never -even heard of. If every maintainer introduced breaking changes only once -per year, users who depend on only a dozen libraries would be dealing with -packaging issues every month! In other words: *Breaking things is a big -deal*. A bit of extra effort for a few maintainers seems like a fair trade -to lower the effort imposed on many users, who would be perfectly happy to -continue using the old code until _they_ decide when to upgrade. - - -### Unintentional Breaking Changes - -The other kind of breaking change is the non-intentional kind, otherwise -known as a "bug" or "regression". Realize first of all, that it is -impossible for any versioning system to encode that this has happened: -Since the maintainer isn't knowingly introducing a bug they naturally can't -update their version numbers to reflect what they don't know about. Instead -we have to deal with these issues after the fact. - -The first thing a package maintainer can do is to minimize the chance of -inflicting buggy software on users. After any non-trivial (potentially breaking) -change, it is a good practice to first create an `-alpha`/`-beta`/`-rc` release. -These so called `--pre` releases are intended to be downloaded only by the few -and the brave: Those who are willing to participate in testing. After any issues -are ironed out with the `--pre` releases, a `final` release can be made for the -wider public. - -Note that the default behaviour of `pip install ` (without any version -specifier) is to download the latest `final` release. It will download a `--pre` -release *only* if - - 1. no `final` release is available - 2. the `--pre` flag is explicitly used, or - 3. if the requirement specifier _explicitly_ includes the version number of a - pre release, eg. `pip install mypkg==v201812.0007-alpha`. - -Should a release include a bug (heaven forbid and despite all precautions), -then the maintainer should publish a new release which either fixes the bug -or reverts the change. If users previously downloaded a version of the -package which included the bug, they only have to do `pip install --upgrade -` and the issue will be resolved. - -Perhaps a timeline will illustrate more clearly: - -``` -v201812.0665 # last stable release -v201812.0666-beta # pre release for testers -v201901.0667 # final release after testing - -# bug is discovered which effects v201812.0666-beta and v201901.0667 - -v201901.0668-beta # fix is issued for testers -v201901.0669 # fix is issued everybody - -# Alternatively, revert before fixing - -v201901.0668 # same as v201812.0665 -v201901.0669-beta # reintroduce change from v201812.0666-beta + fix -v201901.0670 # final release after testing -``` - -In the absolute worst case, a change is discovered to break backward -compatibility, but the change is nonetheless considered to be desirable. At that -point, a new release should be made to revert the change. - -This allows 1. users who _were_ exposed to the breaking change to update to the -latest release and get the old (working) code again, and 2. users who _were not_ -exposed to the breaking change to never even know anything was broken. - -Remember that the goal is to always make things easy for users who have -your package as a dependency. If there is any issue whatsoever, all they -should have to do is `pip install --update`. If this doesn't work, they may -have to *temporarily* pin to a known good version, until a fixed release -has been published. - -After this immediate fire has been extinguished, if the breaking change is -worth keeping, then **create a new module or even a new package**. This -package will perhaps have 99% overlap to the previous one and the old one -may eventually be abandoned. - -``` -mypkg v201812.0665 # last stable release -mypkg v201812.0666-rc # pre release for testers -mypkg v201901.0667 # final release after testing period - -# bug is discovered in v201812.0666-beta and v201901.0667 - -mypkg v201901.0668 # same as v201812.0665 - -# new package is created with compatibility breaking code - -mypkg2 v201901.0669 # same as v201901.0667 -mypkg v201901.0669 # updated readme, declaring support - # level for mypkg, pointing to mypgk2 - # and documenting how to upgrade. -``` - - -### Pinning is not a Panacea - -Freezing your dependencies by using `pip freeze` to create a file with packages -pinned to specific version numbers is great to get a stable and repeatable -deployment. - -The main problem with pinning is that it is another burden imposed on users, -and it is a burden which in practice only some can bear. The vast majority of -users either 1) pin their dependencies and update them without determining what -changed or if it is safe for them to update, or 2) pin their dependencies and -forget about them. In case 1 the only benefit is that users might at least be -aware of when an update happened, so they can perhaps correlate that a new bug -in their software might be related to a recent update. Other than that, keeping -tabs on dependencies and updating without diligence is hardly better than not -having pinned at all. In case 2, an insurmountable debt will pile up and the -dependencies of a project are essentially frozen in the past. - -Yes, it is true that users will be better off if they have sufficient test -coverage to determine for themselves that their code is not broken even after -their dependencies are updated. It is also true however, that a package -maintainer is usually in a better position to judge if a change might cause -something to break. - - -### Zeno's 1.0 and The Eternal Beta - -There are two opposite approaches to backward compatibility which find a -reflection in the version numbers they use. In the case of SemVer, if a -project has a commitment to backward compatibility, it may end up never -incriminating the major version, leading to the [Zeno 1.0 -paradox][zeno_1_dot_0_ref]. On the other end are projects that avoid any -commitment to backward compatibility and forever keep the "beta" label. - -Of course an unpaid Open Source developer *does not owe anybody a -commitment to backward compatibility*. Especially when a project is young -and going through major changes, such a commitment may not make any sense. -For these cases you can still use PyCalVer, just so long as there is a big -fat warning at the top of your README, that your project is not ready for -production yet. - -Note that there is a difference between software that is considered to be -in a "beta" state and individual releases which have a `-beta` tag. These -do not mean the same thing. In the case of releases of python packages, the -release tag (`-alpha`, `-beta`, `-rc`) says something about the stability -of a *particular release*. This is similar ([perhaps -identical][pep_101_ref]) to the meaning of release tags used by the CPython -interpreter. A release tag is not a statement about the general stability -of the software as a whole, it is metadata about a particular release -artifact of a package, eg. a `.whl` file. - - -[repo_ref]: https://gitlab.com/mbarkhau/pycalver - -[setuptools_ref]: https://setuptools.readthedocs.io/en/latest/setuptools.html#specifying-your-project-s-version - -[ssot_ref]: https://en.wikipedia.org/wiki/Single_source_of_truth - -[pep_440_ref]: https://www.python.org/dev/peps/pep-0440/ - -[zeno_1_dot_0_ref]: http://sedimental.org/designing_a_version.html#semver-and-release-blockage - -[pep_101_ref]: https://www.python.org/dev/peps/pep-0101/ - -[bumpversion_ref]: https://github.com/peritus/bumpversion - -[bootstrapit_ref]: https://gitlab.com/mbarkhau/bootstrapit - -[cookiecutter_ref]: https://cookiecutter.readthedocs.io - - -[github_build_img]: https://github.com/mbarkhau/pycalver/workflows/CI/badge.svg -[github_build_ref]: https://github.com/mbarkhau/pycalver/actions?query=workflow%3ACI - -[gitlab_build_img]: https://gitlab.com/mbarkhau/pycalver/badges/master/pipeline.svg -[gitlab_build_ref]: https://gitlab.com/mbarkhau/pycalver/pipelines - -[codecov_img]: https://gitlab.com/mbarkhau/pycalver/badges/master/coverage.svg -[codecov_ref]: https://mbarkhau.gitlab.io/pycalver/cov - -[license_img]: https://img.shields.io/badge/License-MIT-blue.svg -[license_ref]: https://gitlab.com/mbarkhau/pycalver/blob/master/LICENSE - -[mypy_img]: https://img.shields.io/badge/mypy-checked-green.svg -[mypy_ref]: https://mbarkhau.gitlab.io/pycalver/mypycov - -[style_img]: https://img.shields.io/badge/code%20style-%20sjfmt-f71.svg -[style_ref]: https://gitlab.com/mbarkhau/straitjacket/ - -[downloads_img]: https://pepy.tech/badge/pycalver/month -[downloads_ref]: https://pepy.tech/project/pycalver - -[version_img]: https://img.shields.io/static/v1.svg?label=PyCalVer&message=v202007.0036&color=blue -[version_ref]: https://pypi.org/project/pycalver/ - -[pypi_img]: https://img.shields.io/badge/PyPI-wheels-green.svg -[pypi_ref]: https://pypi.org/project/pycalver/#files - -[pyversions_img]: https://img.shields.io/pypi/pyversions/pycalver.svg -[pyversions_ref]: https://pypi.python.org/pypi/pycalver - diff --git a/bootstrapit.sh b/bootstrapit.sh index 6c94052..cde4d52 100644 --- a/bootstrapit.sh +++ b/bootstrapit.sh @@ -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 diff --git a/docker_base.Dockerfile b/docker_base.Dockerfile index bff005d..5cb7b50 100644 --- a/docker_base.Dockerfile +++ b/docker_base.Dockerfile @@ -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 diff --git a/license.header b/license.header index 6b5623a..cb08b33 100644 --- a/license.header +++ b/license.header @@ -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 diff --git a/pycalver1k.svg b/pycalver1k.svg new file mode 100644 index 0000000..b7133c9 --- /dev/null +++ b/pycalver1k.svg @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + 2020 + + + + + + ver + + + + + + + diff --git a/pycalver1k2_128.png b/pycalver1k2_128.png new file mode 100644 index 0000000..30ea018 Binary files /dev/null and b/pycalver1k2_128.png differ diff --git a/pycalver1k_128.png b/pycalver1k_128.png new file mode 100644 index 0000000..f351048 Binary files /dev/null and b/pycalver1k_128.png differ diff --git a/pylint-ignore.md b/pylint-ignore.md index 3bdf018..ac42083 100644 --- a/pylint-ignore.md +++ b/pylint-ignore.md @@ -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 - ignoring the message. -- 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= ; 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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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 ` -- `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) -``` - +1. If a message refers to a valid issue, update your code rather than + ignoring the message. +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. +3. If a message is a false positive, add a comment of this form to your code: + `# pylint:disable= ; explain why this is a false positive` diff --git a/requirements/development.txt b/requirements/development.txt index d2d0505..f729ddc 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -46,3 +46,6 @@ graphviz # run failed tests first pytest-cache + +# to update the readme examples +rich diff --git a/requirements/integration.txt b/requirements/integration.txt index 1395383..4076d54 100644 --- a/requirements/integration.txt +++ b/requirements/integration.txt @@ -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 diff --git a/requirements/pypi.txt b/requirements/pypi.txt index 0504771..1062912 100644 --- a/requirements/pypi.txt +++ b/requirements/pypi.txt @@ -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" diff --git a/scratch.py b/scratch.py deleted file mode 100644 index cfdbc6d..0000000 --- a/scratch.py +++ /dev/null @@ -1,3 +0,0 @@ -import sys - -print(sys.version) diff --git a/scripts/bootstrapit_update.sh b/scripts/bootstrapit_update.sh index 2a9247c..00d837a 100644 --- a/scripts/bootstrapit_update.sh +++ b/scripts/bootstrapit_update.sh @@ -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; diff --git a/scripts/update_readme_examples.py b/scripts/update_readme_examples.py new file mode 100644 index 0000000..8eba9e3 --- /dev/null +++ b/scripts/update_readme_examples.py @@ -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"" + end_marker = f"" + + 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) diff --git a/setup.cfg b/setup.cfg index 8d1258d..2efe452 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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)", diff --git a/setup.py b/setup.py index 78c823f..0c35a5d 100644 --- a/setup.py +++ b/setup.py @@ -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,63 +26,56 @@ install_requires = [ ] +long_description = "\n\n".join((read("README.md"), read("CHANGELOG.md"))) + + +# See https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Other Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + "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): - 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"))) + import lib3to6 + package_dir = lib3to6.fix(package_dir) setuptools.setup( - name="pycalver", + name="bumpver", 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.", + 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=['pycalver'], + packages=setuptools.find_packages("src/"), package_dir=package_dir, install_requires=install_requires, entry_points=""" [console_scripts] - pycalver=pycalver.cli:cli + bumpver=bumpver.cli:cli """, + python_requires=">=2.7", zip_safe=True, - - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Environment :: Other Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: Unix", - "Operating System :: POSIX", - "Operating System :: Microsoft :: Windows", - "Operating System :: MacOS :: MacOS X", - "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules", - ], + classifiers=classifiers, ) diff --git a/src/bumpver/__init__.py b/src/bumpver/__init__.py new file mode 100644 index 0000000..c6ce541 --- /dev/null +++ b/src/bumpver/__init__.py @@ -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" diff --git a/src/bumpver/__main__.py b/src/bumpver/__main__.py new file mode 100644 index 0000000..588684c --- /dev/null +++ b/src/bumpver/__main__.py @@ -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() diff --git a/src/bumpver/cli.py b/src/bumpver/cli.py new file mode 100755 index 0000000..357e2b4 --- /dev/null +++ b/src/bumpver/cli.py @@ -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= 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="", + 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="", + 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="", + 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="", + 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="", + 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() diff --git a/src/bumpver/config.py b/src/bumpver/config.py new file mode 100644 index 0000000..69ad6c6 --- /dev/null +++ b/src/bumpver/config.py @@ -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}") diff --git a/src/bumpver/parse.py b/src/bumpver/parse.py new file mode 100644 index 0000000..205c01a --- /dev/null +++ b/src/bumpver/parse.py @@ -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) diff --git a/src/bumpver/patterns.py b/src/bumpver/patterns.py new file mode 100644 index 0000000..157d62b --- /dev/null +++ b/src/bumpver/patterns.py @@ -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)"), +] diff --git a/src/bumpver/pysix.py b/src/bumpver/pysix.py new file mode 100644 index 0000000..f11c7d8 --- /dev/null +++ b/src/bumpver/pysix.py @@ -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) diff --git a/src/bumpver/regexfmt.py b/src/bumpver/regexfmt.py new file mode 100644 index 0000000..8d7a850 --- /dev/null +++ b/src/bumpver/regexfmt.py @@ -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[1-9][0-9]{3})(?P(?:1[0-2]|0[1-9]))" + >>> print(format_regex(regex)) + \[CalVer[ ]v + (?P[1-9][0-9]{3}) + (?P + (?: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" "®ex=" + pysix.quote(regex_pattern), + ) + ) diff --git a/src/bumpver/rewrite.py b/src/bumpver/rewrite.py new file mode 100644 index 0000000..93a9b53 --- /dev/null +++ b/src/bumpver/rewrite.py @@ -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 = "", + ... line_sep = "\n", + ... old_lines = ["foo"], + ... new_lines = ["bar"], + ... ) + >>> diff_lines(rfd) + ['--- ', '+++ ', '@@ -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) diff --git a/src/bumpver/utils.py b/src/bumpver/utils.py new file mode 100644 index 0000000..5f7c5bc --- /dev/null +++ b/src/bumpver/utils.py @@ -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 diff --git a/src/pycalver/patterns.py b/src/bumpver/v1patterns.py similarity index 58% rename from src/pycalver/patterns.py rename to src/bumpver/v1patterns.py index b9b097f..265fffd 100644 --- a/src/pycalver/patterns.py +++ b/src/bumpver/v1patterns.py @@ -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] diff --git a/src/bumpver/v1rewrite.py b/src/bumpver/v1rewrite.py new file mode 100644 index 0000000..bb594dd --- /dev/null +++ b/src/bumpver/v1rewrite.py @@ -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 = "", +) -> 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) diff --git a/src/pycalver/version.py b/src/bumpver/v1version.py similarity index 51% rename from src/pycalver/version.py rename to src/bumpver/v1version.py index ab2bd2c..b149974 100644 --- a/src/pycalver/version.py +++ b/src/bumpver/v1version.py @@ -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) - - return dict(field_value_items) + 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) - - return _parse_version_info(match.groupdict()) + 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, + 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_vinfo = old_vinfo + cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info(date) - 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()) + 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: - 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)) diff --git a/src/bumpver/v2patterns.py b/src/bumpver/v2patterns.py new file mode 100644 index 0000000..ccf41ee --- /dev/null +++ b/src/bumpver/v2patterns.py @@ -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] diff --git a/src/bumpver/v2rewrite.py b/src/bumpver/v2rewrite.py new file mode 100644 index 0000000..e70ea12 --- /dev/null +++ b/src/bumpver/v2rewrite.py @@ -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 = "", +) -> 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) diff --git a/src/bumpver/v2version.py b/src/bumpver/v2version.py new file mode 100644 index 0000000..4dfe62d --- /dev/null +++ b/src/bumpver/v2version.py @@ -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[] 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 diff --git a/src/pycalver/vcs.py b/src/bumpver/vcs.py similarity index 63% rename from src/pycalver/vcs.py rename to src/bumpver/vcs.py index 3430ba2..87c144d 100644 --- a/src/pycalver/vcs.py +++ b/src/bumpver/vcs.py @@ -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,11 +100,13 @@ 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 - return True + else: + return True except Exception: return False @@ -139,20 +145,25 @@ class VCSAPI: def commit(self, message: str) -> None: """Commit added files.""" - message_data = message.encode("utf-8") - - tmp_file = tempfile.NamedTemporaryFile("wb", delete=False) - assert " " not in tmp_file.name - - fobj: typ.IO[bytes] - - 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) - os.unlink(tmp_file.name) + + 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] + + with tmp_file as fobj: + fobj.write(message_data) + + 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: """Create an annotated tag.""" @@ -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 [] diff --git a/src/bumpver/version.py b/src/bumpver/version.py new file mode 100644 index 0000000..8c2d277 --- /dev/null +++ b/src/bumpver/version.py @@ -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)) diff --git a/src/pycalver/__init__.py b/src/pycalver/__init__.py deleted file mode 100644 index c1658bf..0000000 --- a/src/pycalver/__init__.py +++ /dev/null @@ -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" diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py deleted file mode 100755 index 8e8a3e9..0000000 --- a/src/pycalver/__main__.py +++ /dev/null @@ -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() diff --git a/src/pycalver/cli.py b/src/pycalver/cli.py deleted file mode 100755 index 0316382..0000000 --- a/src/pycalver/cli.py +++ /dev/null @@ -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="", 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="", - 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() diff --git a/src/pycalver/config.py b/src/pycalver/config.py deleted file mode 100644 index a214d0c..0000000 --- a/src/pycalver/config.py +++ /dev/null @@ -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}") diff --git a/src/pycalver/lex_id.py b/src/pycalver/lex_id.py deleted file mode 100644 index 5c6af28..0000000 --- a/src/pycalver/lex_id.py +++ /dev/null @@ -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() diff --git a/src/pycalver/parse.py b/src/pycalver/parse.py deleted file mode 100644 index 402ad39..0000000 --- a/src/pycalver/parse.py +++ /dev/null @@ -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 diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py deleted file mode 100644 index 2a566c3..0000000 --- a/src/pycalver/rewrite.py +++ /dev/null @@ -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("", 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 = "", - ... line_sep = "\n", - ... old_lines = ["foo"], - ... new_lines = ["bar"], - ... ) - >>> diff_lines(rfd) - ['--- ', '+++ ', '@@ -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) diff --git a/test/fixtures/project_a/README.md b/test/fixtures/project_a/README.md index 6d069c5..34780a9 100644 --- a/test/fixtures/project_a/README.md +++ b/test/fixtures/project_a/README.md @@ -1,3 +1,3 @@ -# PyCalVer README Fixture +# Python CalVer README Fixture -Current Version: v201612.0123-alpha +Current Version: v2016.0123-alpha diff --git a/test/fixtures/project_a/pycalver.toml b/test/fixtures/project_a/bumpver.toml similarity index 51% rename from test/fixtures/project_a/pycalver.toml rename to test/fixtures/project_a/bumpver.toml index 438ccf8..b65155d 100644 --- a/test/fixtures/project_a/pycalver.toml +++ b/test/fixtures/project_a/bumpver.toml @@ -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}"', ] diff --git a/test/fixtures/project_b/setup.cfg b/test/fixtures/project_b/setup.cfg index 36ed0b4..ccc0cf4 100644 --- a/test/fixtures/project_b/setup.cfg +++ b/test/fixtures/project_b/setup.cfg @@ -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 = diff --git a/test/fixtures/project_b/setup.py b/test/fixtures/project_b/setup.py index d4082bb..942a85b 100644 --- a/test/fixtures/project_b/setup.py +++ b/test/fixtures/project_b/setup.py @@ -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", +) diff --git a/test/fixtures/project_c/pyproject.toml b/test/fixtures/project_c/pyproject.toml index e460fb7..95b7f3b 100644 --- a/test/fixtures/project_c/pyproject.toml +++ b/test/fixtures/project_c/pyproject.toml @@ -1,4 +1,4 @@ -[pycalver] +[bumpver] current_version = "v2017q1.54321" version_pattern = "v{year}q{quarter}.{build_no}" commit = true diff --git a/test/fixtures/project_d/pyproject.toml b/test/fixtures/project_d/pyproject.toml new file mode 100644 index 0000000..c59dcaf --- /dev/null +++ b/test/fixtures/project_d/pyproject.toml @@ -0,0 +1,6 @@ +[calver] +current_version = "v2017q1.54321" +version_pattern = "vYYYYqQ.BUILD" +commit = true +tag = true +push = true diff --git a/test/test_cli.py b/test/test_cli.py index 810fed8..fbfcca6 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -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,94 +110,137 @@ 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}" - old_version = "0.1.0" - new_version = "0.1.1" + semver_patterns = [ + "{semver}", + "{MAJOR}.{MINOR}.{PATCH}", + "MAJOR.MINOR.PATCH", + ] - 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 + 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_pattern]) - 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 - old_version = "0.1.1" - new_version = "0.2.0" + old_version = "0.1.1" + new_version = "0.2.0" - result = runner.invoke(cli.cli, ['test', "-vv", "--minor", old_version, semver_pattern]) - assert result.exit_code == 0 - assert f"Version: {new_version}\n" in result.output + result = runner.invoke(cli.cli, ['test', "-vv", "--minor", old_version, semver_pattern]) + assert result.exit_code == 0 + assert f"Version: {new_version}\n" in result.output - old_version = "0.1.1" - new_version = "1.0.0" + old_version = "0.1.1" + new_version = "1.0.0" - result = runner.invoke(cli.cli, ['test', "-vv", "--major", old_version, semver_pattern]) - assert result.exit_code == 0 - assert f"Version: {new_version}\n" in result.output + result = runner.invoke(cli.cli, ['test', "-vv", "--major", old_version, semver_pattern]) + assert result.exit_code == 0 + assert f"Version: {new_version}\n" in result.output 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" diff --git a/test/test_config.py b/test/test_config.py index 4d15e36..94ae52d 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -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(): @@ -141,32 +246,35 @@ 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" + project_path = util.FIXTURES_DIR / "project_a" + 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" + 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" + 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 diff --git a/test/test_lex_id.py b/test/test_lex_id.py deleted file mode 100644 index 198e3ed..0000000 --- a/test/test_lex_id.py +++ /dev/null @@ -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 diff --git a/test/test_parse.py b/test/test_parse.py index 6f60923..5bccaa2 100644 --- a/test/test_parse.py +++ b/test/test_parse.py @@ -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 @@ -11,17 +18,17 @@ setuptools.setup( def test_default_parse_patterns(): - lines = SETUP_PY_FIXTURE.splitlines() - patterns = ["{pycalver}", "{pep440_pycalver}"] - - matches = list(parse.iter_matches(lines, patterns)) + lines = SETUP_PY_FIXTURE.splitlines() + patterns = ["{pycalver}", "{pep440_pycalver}"] + 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" @@ -30,16 +37,16 @@ def test_default_parse_patterns(): def test_explicit_parse_patterns(): lines = SETUP_PY_FIXTURE.splitlines() - patterns = ["__version__ = '{pycalver}'", "version='{pep440_pycalver}'"] - - matches = list(parse.iter_matches(lines, patterns)) + patterns = ["__version__ = '{pycalver}'", "version='{pep440_pycalver}'"] + 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'" @@ -57,16 +64,17 @@ README_RST_FIXTURE = """ def test_badge_parse_patterns(): lines = README_RST_FIXTURE.splitlines() - patterns = ["badge/CalVer-{calver}{build}-{release}-blue.svg", ":alt: CalVer {pycalver}"] + 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" diff --git a/test/test_patterns.py b/test/test_patterns.py index 541bbfe..9222280 100644 --- a/test/test_patterns.py +++ b/test/test_patterns.py @@ -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,10 +259,10 @@ 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) - expected = 'click.version_option(version="v201812.0123-beta")' + 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) - expected = 'package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"}' + 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) diff --git a/test/test_rewrite.py b/test/test_rewrite.py index 872fd71..a306920 100644 --- a/test/test_rewrite.py +++ b/test/test_rewrite.py @@ -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" + patterns = copy.deepcopy(cfg.file_patterns) + 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") diff --git a/test/test_version.py b/test/test_version.py index e8d2207..b2bfa84 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -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 - assert version_match is not None + version_str = version_match.group(0) - 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 - 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 diff --git a/test/util.py b/test/util.py index e6b9acb..6b62c92 100644 --- a/test/util.py +++ b/test/util.py @@ -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