diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98e85d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + Test: + runs-on: "${{ matrix.os }}" + strategy: + matrix: + os: + - ubuntu-latest + python-version: + - "3.9" + - "3.x" + steps: + - uses: actions/checkout@v4 + - name: "Set up Python ${{ matrix.python-version }}" + uses: actions/setup-python@v5 + with: + python-version: "${{ matrix.python-version }}" + cache: pip + - run: "pip install -e .[dev]" + - run: py.test -vvv --cov . + + Lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pre-commit/action@v3.0.1 + + Build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pip install -U build + - run: python -m build . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c2a6d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.py[cod] +.coverage +/htmlcov +build +dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..32db09f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.5 + hooks: + - id: ruff + args: + - --fix + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + exclude: .*ambr + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..107f6df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Aarni Koskela + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..01a5a50 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# hatch-calver + +A plugin for [hatch][hatch] to support [calendar versioning][calver]. + +## Setup + +Add `hatch-calver` as a build dependency to your project. + +```toml +[build-system] +requires = [ + "hatchling", + "hatch-calver", +] +build-backend = "hatchling.build" +``` + +Then, set `tool.hatch.version.scheme` to `"calver"`. + +```toml +[tool.hatch.version] +scheme = "calver" +``` + +### Configuring the CalVer scheme + +You can optionally set `calver-scheme` to a dot-separated string +of parts specified in the [calver scheme][calver_scheme] specification. +It defaults to `YYYY.MM.DD`. + +```toml +[tool.hatch.version] +scheme = "calver" +calver-scheme = "YY.MM" +``` + +Note that your project's versions should conform to the scheme you specify; +otherwise, determining where to put e.g. patch versions will be quite ambiguous. + +## Usage + +You can use Hatch's [standard versioning][hatch_version_updating] commands. + +To update your project's version to the current date, run `hatch version release` +(or `hatch version date`). + +As with the regular versioning scheme, you can chain multiple segment updates. +The date part of the version will _not_ be updated unless you explicitly specify it. + +The CalVer scheme specified for your project specifies which segment of the +PEP 440 "release" segments are automatically determined; for instance, for a `YYYY.MM.DD` +scheme, the 4th field of the release segment will be considered the `patch` field. + +In other words, if you specify `YYYY.MM.DD` as your scheme, and it's the 16th of September 2024: + +| Original version | Command | New version | +|------------------|-------------------------|-----------------| +| `2024.07.22` | `hatch version release` | `2024.09.16` | +| `2024.07.22` | `hatch version date,a` | `2024.09.16a0` | +| `2021.01.01` | `hatch version rc` | `2021.01.01rc0` | +| `2024.7.22` | `hatch version patch` | `2021.07.22.1` | + +[hatch]: https://hatch.pypa.io/ +[hatch_version_updating]: https://hatch.pypa.io/latest/version/#updating +[hatch_version_segments]: https://hatch.pypa.io/latest/version/#supported-segments +[calver]: https://calver.org/ +[calver_scheme]: https://calver.org/#scheme diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8a5d4a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hatch-calver" +description = "Hatch plugin for CalVer versioning" +readme = "README.md" +license = "MIT" +requires-python = ">=3.9" +dependencies = ["hatchling"] +dynamic = ["version"] + +authors = [ + { name = "Aarni Koskela", email = "akx@iki.fi" }, +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Hatch", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: OS Independent", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.9", + "Topic :: Software Development", +] + +[project.urls] +Repository = "https://github.com/akx/hatch-calver" +Issues = "https://github.com/akx/hatch-calver/issues" +PyPI = "https://pypi.org/project/hatch-calver/" + +[project.entry-points.hatch] +calver = "hatch_calver.hatch_hooks" + +[project.optional-dependencies] +dev = ["pytest", "pytest-cov"] + +[tool.hatch.version] +path = "src/hatch_calver/__init__.py" +scheme = "calver" +calver-scheme = "YYYY.MM.DD" + +[tool.coverage.report] +exclude_lines = [ + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.ruff] +line-length = 110 + +[tool.ruff.lint] +select = [ + "B", + "COM812", + "E", + "F", + "I", + "W", +] +ignore = [ + "E501", +] diff --git a/src/hatch_calver/__init__.py b/src/hatch_calver/__init__.py new file mode 100644 index 0000000..a08cbd5 --- /dev/null +++ b/src/hatch_calver/__init__.py @@ -0,0 +1 @@ +__version__ = "2024.9.16rc0" diff --git a/src/hatch_calver/bump.py b/src/hatch_calver/bump.py new file mode 100644 index 0000000..b00aa85 --- /dev/null +++ b/src/hatch_calver/bump.py @@ -0,0 +1,119 @@ +""" +Parts of this code has been adapted from the Hatchling project's +`hatchling.version.scheme.standard` module, which is licensed under +the MIT License (Copyright (c) 2017-present Ofek Lev ). +""" + +from __future__ import annotations + +import datetime +from typing import Any + +from packaging.version import Version + +SCHEME_PART_TO_FORMATTER = { + # See https://calver.org/#scheme + "YYYY": lambda d: d.year, + "YY": lambda d: d.year - 2000, + "0Y": lambda d: f"{d.year % 100:02}", + "MM": lambda d: d.month, + "0M": lambda d: f"{d.month:02}", + "WW": lambda d: d.isocalendar()[1], + "0W": lambda d: f"{d.isocalendar()[1]:02}", + "DD": lambda d: d.day, + "0D": lambda d: f"{d.day:02}", +} + +SCHEME_PART_MIN_LENGTHS = { + "YYYY": 4, + "YY": 2, + "0D": 2, + "0M": 2, + "0W": 2, + "0Y": 2, +} + + +def _map_scheme_part(part: str, dt: datetime.datetime) -> str: + formatter = SCHEME_PART_TO_FORMATTER.get(part) + if formatter is None: + err = f"Unknown calver-scheme part: {part} (expected one of {', '.join(SCHEME_PART_TO_FORMATTER)})" + raise ValueError(err) + + return str(formatter(dt)) + + +def _update_version(version: Version, **kwargs: Any) -> None: + parts = {} + for part_name in ("epoch", "release", "pre", "post", "dev", "local"): + if part_name in kwargs: + parts[part_name] = kwargs[part_name] + elif parts: # We've set a part, so clear out the following ones + parts[part_name] = None + + version._version = version._version._replace(**parts) + + +def bump_calver( + original_version: str, + desired_version: str, + *, + calver_scheme_string: str = "YYYY.MM.DD", + version_date: datetime.datetime | None = None, +) -> str: + from packaging.version import Version + from packaging.version import _parse_letter_version as parse_letter_version + + if not version_date: + version_date = datetime.datetime.now(tz=datetime.timezone.utc) + + scheme_parts = str(calver_scheme_string).split(".") + v = Version(original_version) + instructions = desired_version.split(",") + for inst in instructions: + if inst in {"date", "release"}: + # Update the prefix of the current `release`, + # but keep the remaining (non-specified) parts as-is. + release = ( + *(_map_scheme_part(part, version_date) for part in scheme_parts), + *v.release[len(scheme_parts) :], + ) + v._version = v._version._replace(release=release) + _update_version(v, release=release) + elif inst in {"micro", "patch", "fix"}: + # We'll assume the first part after any part specified by the calver scheme is the micro/patch/fix part. + old_micro = v.release[len(scheme_parts)] if len(v.release) > len(scheme_parts) else 0 + new_release = (*v.release[: len(scheme_parts)], old_micro + 1) + _update_version(v, release=new_release) + elif inst in {"a", "b", "c", "rc", "alpha", "beta", "pre", "preview"}: + phase, number = parse_letter_version(inst, 0) + if v.pre: + current_phase, current_number = parse_letter_version(*v.pre) + if phase == current_phase: + number = current_number + 1 + + _update_version(v, pre=(phase, number)) + elif inst in {"post", "rev", "r"}: + number = 0 if v.post is None else v.post + 1 + _update_version(v, post=parse_letter_version(inst, number)) + elif inst == "dev": + number = 0 if v.dev is None else v.dev + 1 + _update_version(v, dev=(inst, number)) + else: + if len(instructions) > 1: + raise ValueError("Cannot specify multiple update operations with an explicit version") + return str(inst) + # Small hack – `packaging.Version` strips leading zeroes from the release part, so + # check if we need to pad any of them. This technically breaks the type annotations of + # `packaging._Version`, but `Version.__str__()` doesn't mind... + v._version = v._version._replace( + release=( + tuple( + str(val).zfill(SCHEME_PART_MIN_LENGTHS.get(scheme_parts[i], 1)) + if i < len(scheme_parts) + else val + for (i, val) in enumerate(v._version.release) + ) + ), + ) + return str(v) diff --git a/src/hatch_calver/calver_scheme.py b/src/hatch_calver/calver_scheme.py new file mode 100644 index 0000000..16964ee --- /dev/null +++ b/src/hatch_calver/calver_scheme.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from hatchling.version.scheme.plugin.interface import VersionSchemeInterface + +from hatch_calver.bump import bump_calver + + +class CalverScheme(VersionSchemeInterface): + PLUGIN_NAME = "calver" + + def update(self, desired_version: str, original_version: str, version_data: dict) -> str: + if not desired_version: + return original_version + + version = bump_calver( + original_version, + desired_version, + calver_scheme_string=self.config.get("calver-scheme", "YYYY.MM.DD"), + ) + + if self.config.get("validate-bump", True): + from packaging.version import Version + + next_version = Version(version) + if next_version <= Version(original_version): + raise ValueError( + f"Version `{version}` is not higher than the original version `{original_version}`", + ) + return version diff --git a/src/hatch_calver/hatch_hooks.py b/src/hatch_calver/hatch_hooks.py new file mode 100644 index 0000000..3bf9a3a --- /dev/null +++ b/src/hatch_calver/hatch_hooks.py @@ -0,0 +1,8 @@ +from hatchling.plugin import hookimpl + + +@hookimpl +def hatch_register_version_scheme(): + from hatch_calver.calver_scheme import CalverScheme + + return CalverScheme diff --git a/tests/test_hatch_calver.py b/tests/test_hatch_calver.py new file mode 100644 index 0000000..f0be773 --- /dev/null +++ b/tests/test_hatch_calver.py @@ -0,0 +1,56 @@ +import datetime + +import pytest + +from hatch_calver.bump import bump_calver + +DT = datetime.datetime(2024, 9, 16, 13, 48) + +cases = [ + # Test that `release`/`date` instructions update the date part to the current date + ("0", "release", "2024.9.16", "YYYY.MM.DD"), + ("0", "date", "2024.09.16", "YYYY.0M.DD"), + ("0.0", "date", "24.38", "YY.WW"), + # Test patch + ("2024.09.16", "patch", "2024.09.16.1", "YYYY.0M.DD"), + ("2024.09.16.1", "patch", "2024.09.16.2", "YYYY.0M.DD"), + # Test prereleases + ("2024.09.16", "a,alpha", "2024.09.16a1", "YYYY.0M.DD"), + ("2024.09.16", "b,beta,b", "2024.09.16b2", "YYYY.0M.DD"), + ("2024.09.16", "c,pre,rc", "2024.9.16rc2", "YYYY.M.DD"), # pre = rc in PEP440 terms + # Test postreleases + ("2024.09.16", "post,rev,r", "2024.09.16.post2", "YYYY.0M.DD"), + # Test dev + ("2024.09.16", "dev", "2024.9.16.dev0", "YYYY.M.DD"), + # Weird long instructions + ("2024.09.16.42", "patch,micro,fix,post,post", "2024.09.16.45.post1", "YYYY.0M.DD"), + ("2024.09.10.1.post3", "date,patch,micro,fix,post,post", "24.09.16.4.post1", "YY.0M.DD"), + # Cases which do not update the date part (`release`/`date` not specified) + ("2023", "patch", "2023.1", "YYYY"), + ("2023.12", "patch", "2023.12.1", "YYYY.MM"), + # Test that we re-pad packaging.Version-mangled bits + ("2024.9.3", "patch", "2024.09.03.1", "YYYY.0M.0D"), + # Test simply setting the version + ("0", "2024.1.1", "2024.1.1", "this won't matter"), +] + + +@pytest.mark.parametrize(("original_version", "desired_version", "expected", "scheme"), cases) +def test_bump(original_version, desired_version, expected, scheme): + calver = bump_calver(original_version, desired_version, calver_scheme_string=scheme, version_date=DT) + assert calver == expected + + +def test_smoke(): + # Just exercises the "no date set" path – we can't know what the date will be + assert bump_calver("0", "date") + + +def test_bad_spec(): + with pytest.raises(ValueError): + assert bump_calver("0", "date", calver_scheme_string="HEY.THERE") + + +def test_bad_multiple_instructions(): + with pytest.raises(ValueError): + assert bump_calver("0", "2024.8.15,patch")