diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 808141c32f..00d90b48c8 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -21,6 +21,7 @@ NotAllowed, NoVersionSpecifiedError, ) +from commitizen.providers import get_provider logger = getLogger("commitizen") @@ -94,14 +95,14 @@ def find_increment(self, commits: List[git.GitCommit]) -> Optional[str]: def __call__(self): # noqa: C901 """Steps executed to bump.""" + provider = get_provider(self.config) + current_version: str = provider.get_version() + try: - current_version_instance: Version = Version(self.bump_settings["version"]) + current_version_instance: Version = Version(current_version) except TypeError: raise NoVersionSpecifiedError() - # Initialize values from sources (conf) - current_version: str = self.config.settings["version"] - tag_format: str = self.bump_settings["tag_format"] bump_commit_message: str = self.bump_settings["bump_message"] version_files: List[str] = self.bump_settings["version_files"] @@ -280,7 +281,7 @@ def __call__(self): # noqa: C901 check_consistency=self.check_consistency, ) - self.config.set_key("version", str(new_version)) + provider.set_version(str(new_version)) if self.pre_bump_hooks: hooks.run( diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index dc47e7aa0c..45d553c710 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -4,6 +4,7 @@ from commitizen import out from commitizen.__version__ import __version__ from commitizen.config import BaseConfig +from commitizen.providers import get_provider class Version: @@ -21,14 +22,14 @@ def __call__(self): out.write(f"Python Version: {self.python_version}") out.write(f"Operating System: {self.operating_system}") elif self.parameter.get("project"): - version = self.config.settings["version"] + version = get_provider(self.config).get_version() if version: out.write(f"{version}") else: out.error("No project information in this project.") elif self.parameter.get("verbose"): out.write(f"Installed Commitizen Version: {__version__}") - version = self.config.settings["version"] + version = get_provider(self.config).get_version() if version: out.write(f"Project Version: {version}") else: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index f2447483e9..296193534f 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -29,6 +29,7 @@ class Settings(TypedDict, total=False): name: str version: Optional[str] version_files: List[str] + version_provider: Optional[str] tag_format: Optional[str] bump_message: Optional[str] allow_abort: bool @@ -59,6 +60,7 @@ class Settings(TypedDict, total=False): "name": "cz_conventional_commits", "version": None, "version_files": [], + "version_provider": "commitizen", "tag_format": None, # example v$version "bump_message": None, # bumped v$current_version to $new_version "allow_abort": False, diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index c7d0b50e69..ba4aca1397 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -31,6 +31,7 @@ class ExitCode(enum.IntEnum): INVALID_MANUAL_VERSION = 24 INIT_FAILED = 25 RUN_HOOK_FAILED = 26 + VERSION_PROVIDER_UNKNOWN = 27 class CommitizenException(Exception): @@ -173,3 +174,7 @@ class InitFailedError(CommitizenException): class RunHookError(CommitizenException): exit_code = ExitCode.RUN_HOOK_FAILED + + +class VersionProviderUnknown(CommitizenException): + exit_code = ExitCode.VERSION_PROVIDER_UNKNOWN diff --git a/commitizen/providers.py b/commitizen/providers.py new file mode 100644 index 0000000000..17b99e7b23 --- /dev/null +++ b/commitizen/providers.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import re +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Callable, ClassVar, Optional, cast + +import importlib_metadata as metadata +import tomlkit +from packaging.version import VERSION_PATTERN, Version + +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionProviderUnknown +from commitizen.git import get_tags + +PROVIDER_ENTRYPOINT = "commitizen.provider" +DEFAULT_PROVIDER = "commitizen" + + +class VersionProvider(ABC): + """ + Abstract base class for version providers. + + Each version provider should inherit and implement this class. + """ + + config: BaseConfig + + def __init__(self, config: BaseConfig): + self.config = config + + @abstractmethod + def get_version(self) -> str: + """ + Get the current version + """ + ... + + @abstractmethod + def set_version(self, version: str): + """ + Set the new current version + """ + ... + + +class CommitizenProvider(VersionProvider): + """ + Default version provider: Fetch and set version in commitizen config. + """ + + def get_version(self) -> str: + return self.config.settings["version"] # type: ignore + + def set_version(self, version: str): + self.config.set_key("version", version) + + +class FileProvider(VersionProvider): + """ + Base class for file-based version providers + """ + + filename: ClassVar[str] + + @property + def file(self) -> Path: + return Path() / self.filename + + +class TomlProvider(FileProvider): + """ + Base class for TOML-based version providers + """ + + def get_version(self) -> str: + document = tomlkit.parse(self.file.read_text()) + return self.get(document) + + def set_version(self, version: str): + document = tomlkit.parse(self.file.read_text()) + self.set(document, version) + self.file.write_text(tomlkit.dumps(document)) + + def get(self, document: tomlkit.TOMLDocument) -> str: + return document["project"]["version"] # type: ignore + + def set(self, document: tomlkit.TOMLDocument, version: str): + document["project"]["version"] = version # type: ignore + + +class Pep621Provider(TomlProvider): + """ + PEP-621 version management + """ + + filename = "pyproject.toml" + + +class PoetryProvider(TomlProvider): + """ + Poetry version management + """ + + filename = "pyproject.toml" + + def get(self, pyproject: tomlkit.TOMLDocument) -> str: + return pyproject["tool"]["poetry"]["version"] # type: ignore + + def set(self, pyproject: tomlkit.TOMLDocument, version: str): + pyproject["tool"]["poetry"]["version"] = version # type: ignore + + +class CargoProvider(TomlProvider): + """ + Cargo version management + """ + + filename = "Cargo.toml" + + def get(self, document: tomlkit.TOMLDocument) -> str: + return document["package"]["version"] # type: ignore + + def set(self, document: tomlkit.TOMLDocument, version: str): + document["package"]["version"] = version # type: ignore + + +class JsonProvider(FileProvider): + """ + Base class for JSON-based version providers + """ + + indent: ClassVar[int] = 2 + + def get_version(self) -> str: + document = json.loads(self.file.read_text()) + return self.get(document) + + def set_version(self, version: str): + document = json.loads(self.file.read_text()) + self.set(document, version) + self.file.write_text(json.dumps(document, indent=self.indent) + "\n") + + def get(self, document: dict[str, Any]) -> str: + return document["version"] # type: ignore + + def set(self, document: dict[str, Any], version: str): + document["version"] = version + + +class NpmProvider(JsonProvider): + """ + npm package.json version management + """ + + filename = "package.json" + + +class ComposerProvider(JsonProvider): + """ + Composer version management + """ + + filename = "composer.json" + indent = 4 + + +class ScmProvider(VersionProvider): + """ + A provider fetching the current/last version from the repository history + + The version is fetched using `git describe` and is never set. + + It is meant for `setuptools-scm` or any package manager `*-scm` provider. + """ + + TAG_FORMAT_REGEXS = { + "$version": r"(?P.+)", + "$major": r"(?P\d+)", + "$minor": r"(?P\d+)", + "$patch": r"(?P\d+)", + "$prerelease": r"(?P\w+\d+)?", + "$devrelease": r"(?P\.dev\d+)?", + } + + def _tag_format_matcher(self) -> Callable[[str], Optional[str]]: + pattern = self.config.settings.get("tag_format") or VERSION_PATTERN + for var, tag_pattern in self.TAG_FORMAT_REGEXS.items(): + pattern = pattern.replace(var, tag_pattern) + + regex = re.compile(f"^{pattern}$", re.VERBOSE) + + def matcher(tag: str) -> Optional[str]: + match = regex.match(tag) + if not match: + return None + groups = match.groupdict() + if "version" in groups: + return groups["version"] + elif "major" in groups: + return "".join( + ( + groups["major"], + f".{groups['minor']}" if groups.get("minor") else "", + f".{groups['patch']}" if groups.get("patch") else "", + groups["prerelease"] if groups.get("prerelease") else "", + groups["devrelease"] if groups.get("devrelease") else "", + ) + ) + elif pattern == VERSION_PATTERN: + return str(Version(tag)) + return None + + return matcher + + def get_version(self) -> str: + matcher = self._tag_format_matcher() + return next( + (cast(str, matcher(t.name)) for t in get_tags() if matcher(t.name)), "0.0.0" + ) + + def set_version(self, version: str): + # Not necessary + pass + + +def get_provider(config: BaseConfig) -> VersionProvider: + """ + Get the version provider as defined in the configuration + + :raises VersionProviderUnknown: if the provider named by `version_provider` is not found. + """ + provider_name = config.settings["version_provider"] or DEFAULT_PROVIDER + try: + (ep,) = metadata.entry_points(name=provider_name, group=PROVIDER_ENTRYPOINT) + except ValueError: + raise VersionProviderUnknown(f'Version Provider "{provider_name}" unknown.') + provider_cls = ep.load() + return cast(VersionProvider, provider_cls(config)) diff --git a/docs/config.md b/docs/config.md index c6cf12b02f..cf38c5c37f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -7,6 +7,7 @@ | `name` | `str` | `"cz_conventional_commits"` | Name of the committing rules to use | | `version` | `str` | `None` | Current version. Example: "0.1.2" | | `version_files` | `list` | `[ ]` | Files were the version will be updated. A pattern to match a line, can also be specified, separated by `:` [See more][version_files] | +| `version_provider` | `str` | `commitizen` | Version provider used to read and write version [See more](#version-providers) | | `tag_format` | `str` | `None` | Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [See more][tag_format] | | `update_changelog_on_bump` | `bool` | `false` | Create changelog when running `cz bump` | | `gpg_sign` | `bool` | `false` | Use gpg signed tags instead of lightweight tags. | @@ -112,6 +113,65 @@ commitizen: - fg:#858585 italic ``` +## Version providers + +Commitizen can read and write version from different sources. +By default, it use the `commitizen` one which is using the `version` field from the commitizen settings. +But you can use any `commitizen.provider` entrypoint as value for `version_provider`. + +Commitizen provides some version providers for some well known formats: + +| name | description | +| ---- | ----------- | +| `commitizen` | Default version provider: Fetch and set version in commitizen config. | +| `scm` | Fetch the version from git and does not need to set it back | +| `pep621` | Get and set version from `pyproject.toml` `project.version` field | +| `poetry` | Get and set version from `pyproject.toml` `tool.poetry.version` field | +| `cargo` | Get and set version from `Cargo.toml` `project.version` field | +| `npm` | Get and set version from `package.json` `project.version` field | +| `composer` | Get and set version from `composer.json` `project.version` field | + +!!! note + The `scm` provider is meant to be used with `setuptools-scm` or any packager `*-scm` plugin. + +### Custom version provider + +You can add you own version provider by extending `VersionProvider` and exposing it on the `commitizen.provider` entrypoint. + +Here a quick example of a `my-provider` provider reading and writing version in a `VERSION` file. + +```python title="my_provider.py" +from pathlib import Path +from commitizen.providers import VersionProvider + + +class MyProvider(VersionProvider): + file = Path() / "VERSION" + + def get_version(self) -> str: + return self.file.read_text() + + def set_version(self, version: str): + self.file.write_text(version) + +``` + +```python title="setup.py" +from setuptools import setup + +setup( + name='my-commitizen-provider', + version='0.1.0', + py_modules=['my_provider'], + install_requires=['commitizen'], + entry_points = { + 'commitizen.provider': [ + 'my-provider = my_provider:MyProvider', + ] + } +) +``` + [version_files]: bump.md#version_files [tag_format]: bump.md#tag_format [bump_message]: bump.md#bump_message diff --git a/docs/exit_codes.md b/docs/exit_codes.md index 54be954ff4..e7c7454478 100644 --- a/docs/exit_codes.md +++ b/docs/exit_codes.md @@ -30,4 +30,7 @@ These exit codes can be found in `commitizen/exceptions.py::ExitCode`. | NotAllowed | 20 | `--incremental` cannot be combined with a `rev_range` | | NoneIncrementExit | 21 | The commits found are not eligible to be bumped | | CharacterSetDecodeError | 22 | The character encoding of the command output could not be determined | -| GitCommandError | 23 | Unexpected failure while calling a git command | +| GitCommandError | 23 | Unexpected failure while calling a git command | +| InvalidManualVersion | 24 | Manually provided version is invalid | +| InitFailedError | 25 | Failed to initialize pre-commit | +| VersionProviderUnknown | 26 | `version_provider` setting is set to an unknown version provider indentifier | diff --git a/docs/faq.md b/docs/faq.md index 7c076f0a62..060de78c30 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -5,26 +5,19 @@ PEP621 establishes a `[project]` definition inside `pyproject.toml` ```toml [project] name = "spam" -version = "2020.0.0" +version = "2.5.1" ``` -Commitizen **won't** use the `project.version` as a source of truth because it's a -tool aimed for any kind of project. - -If we were to use it, it would increase the complexity of the tool. Also why -wouldn't we support other project files like `cargo.toml` or `package.json`? - -Instead of supporting all the different project files, you can use `version_files` -inside `[tool.commitizen]`, and it will cheaply keep any of these project files in sync +Commitizen provides a [`pep621` version provider](config.md#version-providers) to get and set version from this field. +You just need to set the proper `version_provider` setting: ```toml -[tool.commitizen] +[project] +name = "spam" version = "2.5.1" -version_files = [ - "pyproject.toml:^version", - "cargo.toml:^version", - "package.json:\"version\":" -] + +[tool.commitizen] +version_provider = "pep621" ``` ## Why are `revert` and `chore` valid types in the check pattern of cz conventional_commits but not types we can select? diff --git a/mkdocs.yml b/mkdocs.yml index 7d8dc430b0..e9206b18c1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,3 +35,5 @@ markdown_extensions: - admonition - codehilite - extra + - pymdownx.highlight + - pymdownx.superfences diff --git a/poetry.lock b/poetry.lock index 7e7904280e..f7d7e797b3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -536,14 +536,14 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "markdown" -version = "3.4.1" +version = "3.3.7" description = "Python implementation of Markdown." category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" files = [ - {file = "Markdown-3.4.1-py3-none-any.whl", hash = "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186"}, - {file = "Markdown-3.4.1.tar.gz", hash = "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff"}, + {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, + {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, ] [package.dependencies] @@ -643,48 +643,66 @@ files = [ [[package]] name = "mkdocs" -version = "1.3.0" +version = "1.4.2" description = "Project documentation with Markdown." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "mkdocs-1.3.0-py3-none-any.whl", hash = "sha256:26bd2b03d739ac57a3e6eed0b7bcc86168703b719c27b99ad6ca91dc439aacde"}, - {file = "mkdocs-1.3.0.tar.gz", hash = "sha256:b504405b04da38795fec9b2e5e28f6aa3a73bb0960cb6d5d27ead28952bd35ea"}, + {file = "mkdocs-1.4.2-py3-none-any.whl", hash = "sha256:c8856a832c1e56702577023cd64cc5f84948280c1c0fcc6af4cd39006ea6aa8c"}, + {file = "mkdocs-1.4.2.tar.gz", hash = "sha256:8947af423a6d0facf41ea1195b8e1e8c85ad94ac95ae307fe11232e0424b11c5"}, ] [package.dependencies] -click = ">=3.3" +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = ">=4.3" -Jinja2 = ">=2.10.2" -Markdown = ">=3.2.1" +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +jinja2 = ">=2.11.1" +markdown = ">=3.2.1,<3.4" mergedeep = ">=1.3.4" packaging = ">=20.5" -PyYAML = ">=3.10" +pyyaml = ">=5.1" pyyaml-env-tag = ">=0.1" +typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] [[package]] name = "mkdocs-material" -version = "4.6.3" -description = "A Material Design theme for MkDocs" +version = "8.5.11" +description = "Documentation that simply works" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "mkdocs-material-4.6.3.tar.gz", hash = "sha256:1d486635b03f5a2ec87325842f7b10c7ae7daa0eef76b185572eece6a6ea212c"}, - {file = "mkdocs_material-4.6.3-py2.py3-none-any.whl", hash = "sha256:7f3afa0a09c07d0b89a6a9755fdb00513aee8f0cec3538bb903325c80f66f444"}, + {file = "mkdocs_material-8.5.11-py3-none-any.whl", hash = "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"}, + {file = "mkdocs_material-8.5.11.tar.gz", hash = "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7"}, ] [package.dependencies] +jinja2 = ">=3.0.2" markdown = ">=3.2" -mkdocs = ">=1.0" -Pygments = ">=2.4" -pymdown-extensions = ">=6.3" +mkdocs = ">=1.4.0" +mkdocs-material-extensions = ">=1.1" +pygments = ">=2.12" +pymdown-extensions = ">=9.4" +requests = ">=2.26" + +[[package]] +name = "mkdocs-material-extensions" +version = "1.1.1" +description = "Extension pack for Python Markdown and MkDocs Material." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mkdocs_material_extensions-1.1.1-py3-none-any.whl", hash = "sha256:e41d9f38e4798b6617ad98ca8f7f1157b1e4385ac1459ca1e4ea219b556df945"}, + {file = "mkdocs_material_extensions-1.1.1.tar.gz", hash = "sha256:9c003da71e2cc2493d910237448c672e00cefc800d3d6ae93d2fc69979e3bd93"}, +] [[package]] name = "mypy" @@ -857,14 +875,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.20.0" +version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"}, - {file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"}, + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, ] [package.dependencies] @@ -873,8 +891,7 @@ identify = ">=1.0.0" importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" +virtualenv = ">=20.10.0" [[package]] name = "prompt-toolkit" @@ -1285,18 +1302,6 @@ files = [ [package.extras] tests = ["pytest", "pytest-cov"] -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -1518,4 +1523,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "bbe0d066f84d0f48ea255f0015ca216f455ac49d471e96162a14456dd7a6a12a" +content-hash = "12181f1e683c5555c8be62a7de363a6c89b179ca070fbd4f0c68519d9509e47d" diff --git a/pyproject.toml b/pyproject.toml index 458eef8a05..2884715b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,7 @@ types-PyYAML = "^5.4.3" types-termcolor = "^0.1.1" # documentation mkdocs = "^1.0" -mkdocs-material = "^4.1" +mkdocs-material = "^8.5.11" pydocstyle = "^5.0.2" pytest-xdist = "^3.1.0" @@ -104,6 +104,15 @@ cz_conventional_commits = "commitizen.cz.conventional_commits:ConventionalCommit cz_jira = "commitizen.cz.jira:JiraSmartCz" cz_customize = "commitizen.cz.customize:CustomizeCommitsCz" +[tool.poetry.plugins."commitizen.provider"] +cargo = "commitizen.providers:CargoProvider" +commitizen = "commitizen.providers:CommitizenProvider" +composer = "commitizen.providers:ComposerProvider" +npm = "commitizen.providers:NpmProvider" +pep621 = "commitizen.providers:Pep621Provider" +poetry = "commitizen.providers:PoetryProvider" +scm = "commitizen.providers:ScmProvider" + [tool.isort] profile = "black" known_first_party = ["commitizen", "tests"] diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 21a5230c61..2a117aa5a1 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -833,3 +833,23 @@ def test_bump_manual_version_disallows_prerelease_offset(mocker): "--prerelease-offset cannot be combined with MANUAL_VERSION" ) assert expected_error_message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_git_project") +def test_bump_use_version_provider(mocker: MockFixture): + mock = mocker.MagicMock(name="provider") + mock.get_version.return_value = "0.0.0" + get_provider = mocker.patch( + "commitizen.commands.bump.get_provider", return_value=mock + ) + + create_file_and_commit("fix: fake commit") + testargs = ["cz", "bump", "--yes", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + + assert git.tag_exist("0.0.1") + get_provider.assert_called_once() + mock.get_version.assert_called_once() + mock.set_version.assert_called_once_with("0.0.1") diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index 7e6ec3c851..3f9de50d00 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -1,8 +1,12 @@ import platform import sys +import pytest +from pytest_mock import MockerFixture + from commitizen import commands from commitizen.__version__ import __version__ +from commitizen.config.base_config import BaseConfig def test_version_for_showing_project_version(config, capsys): @@ -70,3 +74,35 @@ def test_version_for_showing_commitizen_system_info(config, capsys): assert f"Commitizen Version: {__version__}" in captured.out assert f"Python Version: {sys.version}" in captured.out assert f"Operating System: {platform.system()}" in captured.out + + +@pytest.mark.parametrize("project", (True, False)) +@pytest.mark.usefixtures("tmp_git_project") +def test_version_use_version_provider( + mocker: MockerFixture, + config: BaseConfig, + capsys: pytest.CaptureFixture, + project: bool, +): + version = "0.0.0" + mock = mocker.MagicMock(name="provider") + mock.get_version.return_value = version + get_provider = mocker.patch( + "commitizen.commands.version.get_provider", return_value=mock + ) + + commands.Version( + config, + { + "report": False, + "project": project, + "commitizen": False, + "verbose": not project, + }, + )() + captured = capsys.readouterr() + + assert version in captured.out + get_provider.assert_called_once() + mock.get_version.assert_called_once() + mock.set_version.assert_not_called() diff --git a/tests/test_conf.py b/tests/test_conf.py index d39de8a048..ff28f71144 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -44,6 +44,7 @@ _settings = { "name": "cz_jira", "version": "1.0.0", + "version_provider": "commitizen", "tag_format": None, "bump_message": None, "allow_abort": False, @@ -63,6 +64,7 @@ _new_settings = { "name": "cz_jira", "version": "2.0.0", + "version_provider": "commitizen", "tag_format": None, "bump_message": None, "allow_abort": False, diff --git a/tests/test_version_providers.py b/tests/test_version_providers.py new file mode 100644 index 0000000000..1c48fc3603 --- /dev/null +++ b/tests/test_version_providers.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import os +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING, Iterator, Optional, Type + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionProviderUnknown +from commitizen.providers import ( + CargoProvider, + CommitizenProvider, + ComposerProvider, + NpmProvider, + Pep621Provider, + PoetryProvider, + ScmProvider, + VersionProvider, + get_provider, +) +from tests.utils import create_file_and_commit, create_tag + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +@pytest.fixture +def chdir(tmp_path: Path) -> Iterator[Path]: + cwd = Path() + os.chdir(tmp_path) + yield tmp_path + os.chdir(cwd) + + +def test_default_version_provider_is_commitizen_config(config: BaseConfig): + provider = get_provider(config) + + assert isinstance(provider, CommitizenProvider) + + +def test_raise_for_unknown_provider(config: BaseConfig): + config.settings["version_provider"] = "unknown" + with pytest.raises(VersionProviderUnknown): + get_provider(config) + + +def test_commitizen_provider(config: BaseConfig, mocker: MockerFixture): + config.settings["version"] = "42" + mock = mocker.patch.object(config, "set_key") + + provider = CommitizenProvider(config) + assert provider.get_version() == "42" + + provider.set_version("43.1") + mock.assert_called_once_with("version", "43.1") + + +FILE_PROVIDERS = dict( + pep621=( + "pyproject.toml", + Pep621Provider, + """\ + [project] + version = "0.1.0" + """, + """\ + [project] + version = "42.1" + """, + ), + poetry=( + "pyproject.toml", + PoetryProvider, + """\ + [tool.poetry] + version = "0.1.0" + """, + """\ + [tool.poetry] + version = "42.1" + """, + ), + cargo=( + "Cargo.toml", + CargoProvider, + """\ + [package] + version = "0.1.0" + """, + """\ + [package] + version = "42.1" + """, + ), + npm=( + "package.json", + NpmProvider, + """\ + { + "name": "whatever", + "version": "0.1.0" + } + """, + """\ + { + "name": "whatever", + "version": "42.1" + } + """, + ), + composer=( + "composer.json", + ComposerProvider, + """\ + { + "name": "whatever", + "version": "0.1.0" + } + """, + """\ + { + "name": "whatever", + "version": "42.1" + } + """, + ), +) + + +@pytest.mark.parametrize( + "id,filename,cls,content,expected", + (pytest.param(id, *FILE_PROVIDERS[id], id=id) for id in FILE_PROVIDERS), +) +def test_file_providers( + config: BaseConfig, + chdir: Path, + id: str, + filename: str, + cls: Type[VersionProvider], + content: str, + expected: str, +): + file = chdir / filename + file.write_text(dedent(content)) + config.settings["version_provider"] = id + + provider = get_provider(config) + assert isinstance(provider, cls) + assert provider.get_version() == "0.1.0" + + provider.set_version("42.1") + assert file.read_text() == dedent(expected) + + +@pytest.mark.parametrize( + "tag_format,tag,version", + ( + (None, "0.1.0", "0.1.0"), + (None, "v0.1.0", "0.1.0"), + ("v$version", "v0.1.0", "0.1.0"), + ("version-$version", "version-0.1.0", "0.1.0"), + ("version-$version", "version-0.1", "0.1"), + ("version-$version", "version-0.1.0rc1", "0.1.0rc1"), + ("v$minor.$major.$patch", "v1.0.0", "0.1.0"), + ("version-$major.$minor.$patch", "version-0.1.0", "0.1.0"), + ("v$major.$minor$prerelease$devrelease", "v1.0rc1", "1.0rc1"), + ("v$major.$minor.$patch$prerelease$devrelease", "v0.1.0", "0.1.0"), + ("v$major.$minor.$patch$prerelease$devrelease", "v0.1.0rc1", "0.1.0rc1"), + ("v$major.$minor.$patch$prerelease$devrelease", "v1.0.0.dev0", "1.0.0.dev0"), + ), +) +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider( + config: BaseConfig, tag_format: Optional[str], tag: str, version: str +): + create_file_and_commit("test: fake commit") + create_tag(tag) + create_file_and_commit("test: fake commit") + create_tag("should-not-match") + + config.settings["version_provider"] = "scm" + config.settings["tag_format"] = tag_format + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == version + + # Should not fail on set_version() + provider.set_version("43.1") + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_without_matching_tag(config: BaseConfig): + create_file_and_commit("test: fake commit") + create_tag("should-not-match") + create_file_and_commit("test: fake commit") + + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_without_commits_and_tags(config: BaseConfig): + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0"