Skip to content

Commit b5e0840

Browse files
feat(version): add MANUAL_VERSION, --next and --patch to version command (#1724)
* feat(version): add MANUAL_VERSION, --next, and --patch to version command Rebased onto master: keep --tag with TagRules alongside the new flags. - Merge version command logic for manual/next/patch with tag normalization - Refresh help regression fixtures and cz version --help SVG - Fix TagRules.find_tag_for annotation (VersionProtocol) Closes #1679 Made-with: Cursor * fix(test): align cz version --help fixtures per Python minor version Argparse wraps the usage line differently before vs after Python 3.13, so separate regression baselines are required for CI matrix jobs. Made-with: Cursor * fix(version): improve coverage, UX, and docs for cz version - Rename VersionIncrement.safe_cast to from_value; add tests - Map NONE to no bump; user-facing error for USE_GIT_COMMITS - Add tests for patch component, unknown scheme, and default output - Clarify get_version_scheme Protocol check; fix TagRules docstring example - Document MANUAL_VERSION, --next, and --patch; refresh help fixtures/SVG Made-with: Cursor * refactor(version_schemes): restore Version and VersionScheme type aliases Re-export VersionScheme = type[VersionProtocol] and Version = VersionProtocol for downstream custom schemes and parity with the public API on master. Import TypeAlias at runtime alongside Increment/Prerelease. Made-with: Cursor * fix: address review comments on version command PR - Parametrize invalid combination tests (major/minor/patch/tag without project) - Fix version scheme validation to use hasattr check instead of broken isinstance/issubclass on Protocol with non-method members - Format test file with ruff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add TODO comment for USE_GIT_COMMITS future work (#1678) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d157e09 commit b5e0840

17 files changed

Lines changed: 482 additions & 224 deletions

commitizen/bump.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
if TYPE_CHECKING:
1616
from collections.abc import Generator, Iterable
1717

18-
from commitizen.version_schemes import Increment, Version
18+
from commitizen.version_schemes import Increment, VersionProtocol
1919

2020
VERSION_TYPES = [None, PATCH, MINOR, MAJOR]
2121

@@ -131,8 +131,8 @@ def _resolve_files_and_regexes(
131131

132132

133133
def create_commit_message(
134-
current_version: Version | str,
135-
new_version: Version | str,
134+
current_version: VersionProtocol | str,
135+
new_version: VersionProtocol | str,
136136
message_template: str | None = None,
137137
) -> str:
138138
if message_template is None:

commitizen/cli.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
InvalidCommandArgumentError,
2222
NoCommandFoundError,
2323
)
24+
from commitizen.version_increment import VersionIncrement
2425

2526
logger = logging.getLogger(__name__)
2627

@@ -542,13 +543,19 @@ def __call__(
542543
},
543544
{
544545
"name": ["--major"],
545-
"help": "Output just the major version. Must be used with --project or --verbose.",
546+
"help": (
547+
"Output just the major version. Must be used with MANUAL_VERSION, "
548+
"--project, or --verbose."
549+
),
546550
"action": "store_true",
547551
"exclusive_group": "group2",
548552
},
549553
{
550554
"name": ["--minor"],
551-
"help": "Output just the minor version. Must be used with --project or --verbose.",
555+
"help": (
556+
"Output just the minor version. Must be used with MANUAL_VERSION, "
557+
"--project, or --verbose."
558+
),
552559
"action": "store_true",
553560
"exclusive_group": "group2",
554561
},
@@ -558,6 +565,33 @@ def __call__(
558565
"action": "store_true",
559566
"exclusive_group": "group2",
560567
},
568+
{
569+
"name": ["--patch"],
570+
"help": (
571+
"Output the patch version only. Must be used with MANUAL_VERSION, "
572+
"--project, or --verbose."
573+
),
574+
"action": "store_true",
575+
"exclusive_group": "group2",
576+
},
577+
{
578+
"name": ["--next"],
579+
"help": "Output the next version.",
580+
"type": str,
581+
"nargs": "?",
582+
"default": None,
583+
"const": "USE_GIT_COMMITS",
584+
"choices": ["USE_GIT_COMMITS"]
585+
+ [str(increment) for increment in VersionIncrement],
586+
"exclusive_group": "group2",
587+
},
588+
{
589+
"name": "manual_version",
590+
"type": str,
591+
"nargs": "?",
592+
"help": "Use the version provided instead of the version from the project. Can be used to test the selected version scheme.",
593+
"metavar": "MANUAL_VERSION",
594+
},
561595
],
562596
},
563597
],

commitizen/commands/init.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
NoAnswersError,
1818
)
1919
from commitizen.git import get_latest_tag_name, get_tag_names, smart_open
20-
from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme
20+
from commitizen.version_schemes import (
21+
KNOWN_SCHEMES,
22+
VersionProtocol,
23+
get_version_scheme,
24+
)
2125

2226
if TYPE_CHECKING:
2327
from commitizen.config import (
@@ -265,7 +269,7 @@ def _ask_version_scheme(self) -> str:
265269
).unsafe_ask()
266270
return scheme
267271

268-
def _ask_major_version_zero(self, version: Version) -> bool:
272+
def _ask_major_version_zero(self, version: VersionProtocol) -> bool:
269273
"""Ask for setting: major_version_zero"""
270274
if version.major > 0:
271275
return False
@@ -323,7 +327,7 @@ def _write_config_to_file(
323327
cz_name: str,
324328
version_provider: str,
325329
version_scheme: str,
326-
version: Version,
330+
version: VersionProtocol,
327331
tag_format: str,
328332
update_changelog_on_bump: bool,
329333
major_version_zero: bool,

commitizen/commands/version.py

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,32 @@
22
import sys
33
from typing import TypedDict
44

5+
from packaging.version import InvalidVersion
6+
57
from commitizen import out
68
from commitizen.__version__ import __version__
79
from commitizen.config import BaseConfig
810
from commitizen.exceptions import NoVersionSpecifiedError, VersionSchemeUnknown
911
from commitizen.providers import get_provider
1012
from commitizen.tags import TagRules
11-
from commitizen.version_schemes import get_version_scheme
13+
from commitizen.version_increment import VersionIncrement
14+
from commitizen.version_schemes import Increment, get_version_scheme
1215

1316

1417
class VersionArgs(TypedDict, total=False):
18+
manual_version: str | None
19+
next: str | None
20+
21+
# Exclusive groups 1
1522
commitizen: bool
1623
report: bool
1724
project: bool
1825
verbose: bool
26+
27+
# Exclusive groups 2
1928
major: bool
2029
minor: bool
30+
patch: bool
2131
tag: bool
2232

2333

@@ -43,40 +53,85 @@ def __call__(self) -> None:
4353
if self.arguments.get("verbose"):
4454
out.write(f"Installed Commitizen Version: {__version__}")
4555

46-
if not self.arguments.get("commitizen") and (
47-
self.arguments.get("project") or self.arguments.get("verbose")
56+
if self.arguments.get("commitizen"):
57+
out.write(__version__)
58+
return
59+
60+
if (
61+
self.arguments.get("project")
62+
or self.arguments.get("verbose")
63+
or self.arguments.get("next")
64+
or self.arguments.get("manual_version")
4865
):
66+
version_str = self.arguments.get("manual_version")
67+
if version_str is None:
68+
try:
69+
version_str = get_provider(self.config).get_version()
70+
except NoVersionSpecifiedError:
71+
out.error("No project information in this project.")
72+
return
4973
try:
50-
version = get_provider(self.config).get_version()
51-
except NoVersionSpecifiedError:
52-
out.error("No project information in this project.")
53-
return
54-
try:
55-
version_scheme = get_version_scheme(self.config.settings)(version)
74+
scheme_factory = get_version_scheme(self.config.settings)
5675
except VersionSchemeUnknown:
5776
out.error("Unknown version scheme.")
5877
return
5978

79+
try:
80+
version = scheme_factory(version_str)
81+
except InvalidVersion:
82+
out.error(f"Invalid version: '{version_str}'")
83+
return
84+
85+
if next_increment_str := self.arguments.get("next"):
86+
if next_increment_str == "USE_GIT_COMMITS":
87+
# TODO: implement USE_GIT_COMMITS by deriving the increment from
88+
# git history. This requires refactoring the bump logic out of
89+
# `commitizen/commands/bump.py` so it can be reused here. See #1678.
90+
out.error("--next USE_GIT_COMMITS is not implemented yet.")
91+
return
92+
93+
next_increment = VersionIncrement.from_value(next_increment_str)
94+
increment: Increment | None
95+
if next_increment == VersionIncrement.NONE:
96+
increment = None
97+
elif next_increment == VersionIncrement.PATCH:
98+
increment = "PATCH"
99+
elif next_increment == VersionIncrement.MINOR:
100+
increment = "MINOR"
101+
else:
102+
increment = "MAJOR"
103+
version = version.bump(increment=increment)
104+
60105
if self.arguments.get("major"):
61-
version = f"{version_scheme.major}"
62-
elif self.arguments.get("minor"):
63-
version = f"{version_scheme.minor}"
64-
elif self.arguments.get("tag"):
106+
out.write(version.major)
107+
return
108+
if self.arguments.get("minor"):
109+
out.write(version.minor)
110+
return
111+
if self.arguments.get("patch"):
112+
out.write(version.micro)
113+
return
114+
115+
display_version: str
116+
if self.arguments.get("tag"):
65117
tag_rules = TagRules.from_settings(self.config.settings)
66-
version = tag_rules.normalize_tag(version_scheme)
118+
display_version = tag_rules.normalize_tag(version)
119+
else:
120+
display_version = str(version)
67121

68122
out.write(
69-
f"Project Version: {version}"
123+
f"Project Version: {display_version}"
70124
if self.arguments.get("verbose")
71-
else version
125+
else display_version
72126
)
73127
return
74128

75-
if self.arguments.get("major") or self.arguments.get("minor"):
76-
out.error(
77-
"Major or minor version can only be used with --project or --verbose."
78-
)
79-
return
129+
for argument in ("major", "minor", "patch"):
130+
if self.arguments.get(argument):
131+
out.error(
132+
f"{argument} can only be used with MANUAL_VERSION, --project or --verbose."
133+
)
134+
return
80135

81136
if self.arguments.get("tag"):
82137
out.error("Tag can only be used with --project or --verbose.")

commitizen/out.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,35 @@
99
sys.stdout.reconfigure(encoding="utf-8")
1010

1111

12-
def write(value: str, *args: object) -> None:
12+
def write(value: object, *args: object) -> None:
1313
"""Intended to be used when value is multiline."""
1414
print(value, *args)
1515

1616

17-
def line(value: str, *args: object, **kwargs: Any) -> None:
17+
def line(value: object, *args: object, **kwargs: Any) -> None:
1818
"""Wrapper in case I want to do something different later."""
1919
print(value, *args, **kwargs)
2020

2121

22-
def error(value: str) -> None:
23-
message = colored(value, "red")
22+
def error(value: object) -> None:
23+
message = colored(str(value), "red")
2424
line(message, file=sys.stderr)
2525

2626

27-
def success(value: str) -> None:
28-
message = colored(value, "green")
27+
def success(value: object) -> None:
28+
message = colored(str(value), "green")
2929
line(message)
3030

3131

32-
def info(value: str) -> None:
33-
message = colored(value, "blue")
32+
def info(value: object) -> None:
33+
message = colored(str(value), "blue")
3434
line(message)
3535

3636

37-
def diagnostic(value: str) -> None:
37+
def diagnostic(value: object) -> None:
3838
line(value, file=sys.stderr)
3939

4040

41-
def warn(value: str) -> None:
42-
message = colored(value, "magenta")
41+
def warn(value: object) -> None:
42+
message = colored(str(value), "magenta")
4343
line(message, file=sys.stderr)

commitizen/tags.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from commitizen.version_schemes import (
1515
DEFAULT_SCHEME,
1616
InvalidVersion,
17-
Version,
17+
VersionProtocol,
1818
VersionScheme,
1919
get_version_scheme,
2020
)
@@ -23,8 +23,6 @@
2323
import sys
2424
from collections.abc import Iterable, Sequence
2525

26-
from commitizen.version_schemes import VersionScheme
27-
2826
# Self is Python 3.11+ but backported in typing-extensions
2927
if sys.version_info < (3, 11):
3028
from typing_extensions import Self
@@ -75,7 +73,7 @@ class TagRules:
7573
assert not rules.is_version_tag("warn1.0.0", warn=True) # Does warn
7674
7775
assert rules.search_version("# My v1.0.0 version").version == "1.0.0"
78-
assert rules.extract_version("v1.0.0") == Version("1.0.0")
76+
assert rules.extract_version("v1.0.0") == rules.scheme("1.0.0")
7977
try:
8078
assert rules.extract_version("not-a-v1.0.0")
8179
except InvalidVersion:
@@ -145,7 +143,7 @@ def get_version_tags(
145143
"""Filter in version tags and warn on unexpected tags"""
146144
return [tag for tag in tags if self.is_version_tag(tag, warn)]
147145

148-
def extract_version(self, tag: GitTag) -> Version:
146+
def extract_version(self, tag: GitTag) -> VersionProtocol:
149147
"""
150148
Extract a version from the tag as defined in tag formats.
151149
@@ -195,7 +193,7 @@ def search_version(self, text: str, last: bool = False) -> VersionTag | None:
195193
return VersionTag(version, match.group(0))
196194

197195
def normalize_tag(
198-
self, version: Version | str, tag_format: str | None = None
196+
self, version: VersionProtocol | str, tag_format: str | None = None
199197
) -> str:
200198
"""
201199
The tag and the software version might be different.
@@ -225,7 +223,7 @@ def normalize_tag(
225223
)
226224

227225
def find_tag_for(
228-
self, tags: Iterable[GitTag], version: Version | str
226+
self, tags: Iterable[GitTag], version: VersionProtocol | str
229227
) -> GitTag | None:
230228
"""Find the first matching tag for a given version."""
231229
version = self.scheme(version) if isinstance(version, str) else version
@@ -234,7 +232,7 @@ def find_tag_for(
234232
# If the requested version is incomplete (e.g., "1.2"), try to find the latest
235233
# matching tag that shares the provided prefix.
236234
if len(release) < 3:
237-
matching_versions: list[tuple[Version, GitTag]] = []
235+
matching_versions: list[tuple[VersionProtocol, GitTag]] = []
238236
for tag in tags:
239237
try:
240238
tag_version = self.extract_version(tag)

commitizen/version_increment.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
from enum import IntEnum
4+
5+
6+
class VersionIncrement(IntEnum):
7+
"""Semantic versioning bump increments.
8+
9+
IntEnum keeps a total order compatible with NONE < PATCH < MINOR < MAJOR
10+
for comparisons across the codebase.
11+
12+
- NONE: no bump (docs-only / style commits, etc.)
13+
- PATCH: backwards-compatible bug fixes
14+
- MINOR: backwards-compatible features
15+
- MAJOR: incompatible API changes
16+
"""
17+
18+
NONE = 0
19+
PATCH = 1
20+
MINOR = 2
21+
MAJOR = 3
22+
23+
def __str__(self) -> str:
24+
return self.name
25+
26+
@classmethod
27+
def from_value(cls, value: object) -> VersionIncrement:
28+
if not isinstance(value, str):
29+
return VersionIncrement.NONE
30+
try:
31+
return cls[value]
32+
except KeyError:
33+
return VersionIncrement.NONE

0 commit comments

Comments
 (0)