commitizen 4.9.1__tar.gz → 4.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {commitizen-4.9.1 → commitizen-4.10.0}/PKG-INFO +4 -3
  2. commitizen-4.10.0/commitizen/__version__.py +1 -0
  3. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/bump.py +33 -48
  4. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/changelog_formats/base.py +4 -4
  5. commitizen-4.10.0/commitizen/changelog_formats/restructuredtext.py +88 -0
  6. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cli.py +12 -2
  7. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cmd.py +9 -7
  8. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/bump.py +3 -4
  9. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/changelog.py +8 -7
  10. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/check.py +19 -8
  11. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/commit.py +45 -33
  12. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/init.py +87 -158
  13. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/version.py +17 -1
  14. commitizen-4.10.0/commitizen/config/__init__.py +46 -0
  15. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/config/base_config.py +10 -7
  16. commitizen-4.10.0/commitizen/config/factory.py +22 -0
  17. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/config/json_config.py +10 -14
  18. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/config/toml_config.py +17 -20
  19. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/config/yaml_config.py +12 -14
  20. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/base.py +10 -10
  21. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/conventional_commits/conventional_commits.py +8 -7
  22. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/utils.py +3 -3
  23. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/defaults.py +5 -0
  24. commitizen-4.10.0/commitizen/project_info.py +47 -0
  25. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/uv_provider.py +1 -1
  26. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/version_schemes.py +3 -1
  27. {commitizen-4.9.1 → commitizen-4.10.0}/pyproject.toml +3 -3
  28. commitizen-4.9.1/commitizen/__version__.py +0 -1
  29. commitizen-4.9.1/commitizen/changelog_formats/restructuredtext.py +0 -92
  30. commitizen-4.9.1/commitizen/config/__init__.py +0 -58
  31. {commitizen-4.9.1 → commitizen-4.10.0}/LICENSE +0 -0
  32. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/__init__.py +0 -0
  33. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/__main__.py +0 -0
  34. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/changelog.py +0 -0
  35. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/changelog_formats/__init__.py +0 -0
  36. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/changelog_formats/asciidoc.py +0 -0
  37. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/changelog_formats/markdown.py +0 -0
  38. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/changelog_formats/textile.py +0 -0
  39. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/__init__.py +0 -0
  40. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/example.py +0 -0
  41. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/info.py +0 -0
  42. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/list_cz.py +0 -0
  43. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/commands/schema.py +0 -0
  44. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/__init__.py +0 -0
  45. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/conventional_commits/__init__.py +0 -0
  46. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/conventional_commits/conventional_commits_info.txt +0 -0
  47. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/customize/__init__.py +0 -0
  48. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/customize/customize.py +0 -0
  49. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/customize/customize_info.txt +0 -0
  50. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/exceptions.py +0 -0
  51. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/jira/__init__.py +0 -0
  52. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/jira/jira.py +0 -0
  53. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/cz/jira/jira_info.txt +0 -0
  54. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/exceptions.py +0 -0
  55. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/factory.py +0 -0
  56. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/git.py +0 -0
  57. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/hooks.py +0 -0
  58. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/out.py +0 -0
  59. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/__init__.py +0 -0
  60. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/base_provider.py +0 -0
  61. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/cargo_provider.py +0 -0
  62. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/commitizen_provider.py +0 -0
  63. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/composer_provider.py +0 -0
  64. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/npm_provider.py +0 -0
  65. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/pep621_provider.py +0 -0
  66. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/poetry_provider.py +0 -0
  67. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/providers/scm_provider.py +0 -0
  68. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/py.typed +0 -0
  69. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/question.py +0 -0
  70. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/tags.py +0 -0
  71. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/templates/CHANGELOG.adoc.j2 +0 -0
  72. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/templates/CHANGELOG.md.j2 +0 -0
  73. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/templates/CHANGELOG.rst.j2 +0 -0
  74. {commitizen-4.9.1 → commitizen-4.10.0}/commitizen/templates/CHANGELOG.textile.j2 +0 -0
  75. {commitizen-4.9.1 → commitizen-4.10.0}/docs/README.md +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: commitizen
3
- Version: 4.9.1
3
+ Version: 4.10.0
4
4
  Summary: Python commitizen client tool
5
5
  License: MIT License
6
6
 
@@ -23,6 +23,7 @@ License: MIT License
23
23
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
24
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
25
  SOFTWARE.
26
+ License-File: LICENSE
26
27
  Keywords: commitizen,conventional,commits,git
27
28
  Author: Santiago Fraire
28
29
  Author-email: santiwilly@gmail.com
@@ -55,7 +56,7 @@ Requires-Dist: prompt_toolkit (!=3.0.52)
55
56
  Requires-Dist: pyyaml (>=3.08)
56
57
  Requires-Dist: questionary (>=2.0,<3.0)
57
58
  Requires-Dist: termcolor (>=1.1.0,<4.0.0)
58
- Requires-Dist: tomlkit (>=0.5.3,<1.0.0)
59
+ Requires-Dist: tomlkit (>=0.8.0,<1.0.0)
59
60
  Requires-Dist: typing-extensions (>=4.0.1,<5.0.0) ; python_version < "3.11"
60
61
  Project-URL: Changelog, https://github.com/commitizen-tools/commitizen/blob/master/CHANGELOG.md
61
62
  Project-URL: Documentation, https://commitizen-tools.github.io/commitizen/
@@ -0,0 +1 @@
1
+ __version__ = "4.10.0"
@@ -3,13 +3,13 @@ from __future__ import annotations
3
3
  import os
4
4
  import re
5
5
  from collections import OrderedDict
6
- from collections.abc import Iterable
6
+ from collections.abc import Generator, Iterable
7
7
  from glob import iglob
8
8
  from logging import getLogger
9
9
  from string import Template
10
10
  from typing import cast
11
11
 
12
- from commitizen.defaults import BUMP_MESSAGE, ENCODING, MAJOR, MINOR, PATCH
12
+ from commitizen.defaults import BUMP_MESSAGE, MAJOR, MINOR, PATCH
13
13
  from commitizen.exceptions import CurrentVersionNotFoundError
14
14
  from commitizen.git import GitCommit, smart_open
15
15
  from commitizen.version_schemes import Increment, Version
@@ -64,8 +64,8 @@ def update_version_in_files(
64
64
  new_version: str,
65
65
  files: Iterable[str],
66
66
  *,
67
- check_consistency: bool = False,
68
- encoding: str = ENCODING,
67
+ check_consistency: bool,
68
+ encoding: str,
69
69
  ) -> list[str]:
70
70
  """Change old version to the new one in every file given.
71
71
 
@@ -75,16 +75,22 @@ def update_version_in_files(
75
75
 
76
76
  Returns the list of updated files.
77
77
  """
78
- # TODO: separate check step and write step
79
- updated = []
80
- for path, regex in _files_and_regexes(files, current_version):
81
- current_version_found, version_file = _bump_with_regex(
82
- path,
83
- current_version,
84
- new_version,
85
- regex,
86
- encoding=encoding,
87
- )
78
+ updated_files = []
79
+
80
+ for path, pattern in _resolve_files_and_regexes(files, current_version):
81
+ current_version_found = False
82
+ bumped_lines = []
83
+
84
+ with open(path, encoding=encoding) as version_file:
85
+ for line in version_file:
86
+ bumped_line = (
87
+ line.replace(current_version, new_version)
88
+ if pattern.search(line)
89
+ else line
90
+ )
91
+
92
+ current_version_found = current_version_found or bumped_line != line
93
+ bumped_lines.append(bumped_line)
88
94
 
89
95
  if check_consistency and not current_version_found:
90
96
  raise CurrentVersionNotFoundError(
@@ -93,53 +99,32 @@ def update_version_in_files(
93
99
  "version_files are possibly inconsistent."
94
100
  )
95
101
 
102
+ bumped_version_file_content = "".join(bumped_lines)
103
+
96
104
  # Write the file out again
97
105
  with smart_open(path, "w", encoding=encoding) as file:
98
- file.write(version_file)
99
- updated.append(path)
100
- return updated
106
+ file.write(bumped_version_file_content)
107
+ updated_files.append(path)
108
+
109
+ return updated_files
101
110
 
102
111
 
103
- def _files_and_regexes(patterns: Iterable[str], version: str) -> list[tuple[str, str]]:
112
+ def _resolve_files_and_regexes(
113
+ patterns: Iterable[str], version: str
114
+ ) -> Generator[tuple[str, re.Pattern], None, None]:
104
115
  """
105
116
  Resolve all distinct files with their regexp from a list of glob patterns with optional regexp
106
117
  """
107
- out: set[tuple[str, str]] = set()
118
+ filepath_set: set[tuple[str, str]] = set()
108
119
  for pattern in patterns:
109
120
  drive, tail = os.path.splitdrive(pattern)
110
121
  path, _, regex = tail.partition(":")
111
122
  filepath = drive + path
112
- if not regex:
113
- regex = re.escape(version)
123
+ regex = regex or re.escape(version)
114
124
 
115
- for file in iglob(filepath):
116
- out.add((file, regex))
125
+ filepath_set.update((path, regex) for path in iglob(filepath))
117
126
 
118
- return sorted(out)
119
-
120
-
121
- def _bump_with_regex(
122
- version_filepath: str,
123
- current_version: str,
124
- new_version: str,
125
- regex: str,
126
- encoding: str = ENCODING,
127
- ) -> tuple[bool, str]:
128
- current_version_found = False
129
- lines = []
130
- pattern = re.compile(regex)
131
- with open(version_filepath, encoding=encoding) as f:
132
- for line in f:
133
- if not pattern.search(line):
134
- lines.append(line)
135
- continue
136
-
137
- bumped_line = line.replace(current_version, new_version)
138
- if bumped_line != line:
139
- current_version_found = True
140
- lines.append(bumped_line)
141
-
142
- return current_version_found, "".join(lines)
127
+ return ((path, re.compile(regex)) for path, regex in sorted(filepath_set))
143
128
 
144
129
 
145
130
  def create_commit_message(
@@ -24,11 +24,9 @@ class BaseFormat(ChangelogFormat, metaclass=ABCMeta):
24
24
  # Constructor needs to be redefined because `Protocol` prevent instantiation by default
25
25
  # See: https://bugs.python.org/issue44807
26
26
  self.config = config
27
- self.encoding = self.config.settings["encoding"]
28
- self.tag_format = self.config.settings["tag_format"]
29
27
  self.tag_rules = TagRules(
30
28
  scheme=get_version_scheme(self.config.settings),
31
- tag_format=self.tag_format,
29
+ tag_format=self.config.settings["tag_format"],
32
30
  legacy_tag_formats=self.config.settings["legacy_tag_formats"],
33
31
  ignored_tag_formats=self.config.settings["ignored_tag_formats"],
34
32
  )
@@ -37,7 +35,9 @@ class BaseFormat(ChangelogFormat, metaclass=ABCMeta):
37
35
  if not os.path.isfile(filepath):
38
36
  return Metadata()
39
37
 
40
- with open(filepath, encoding=self.encoding) as changelog_file:
38
+ with open(
39
+ filepath, encoding=self.config.settings["encoding"]
40
+ ) as changelog_file:
41
41
  return self.get_metadata_from_file(changelog_file)
42
42
 
43
43
  def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from itertools import zip_longest
4
+ from typing import IO
5
+
6
+ from commitizen.changelog import Metadata
7
+
8
+ from .base import BaseFormat
9
+
10
+
11
+ class RestructuredText(BaseFormat):
12
+ extension = "rst"
13
+
14
+ def get_metadata_from_file(self, file: IO[str]) -> Metadata:
15
+ """
16
+ RestructuredText section titles are not one-line-based,
17
+ they spread on 2 or 3 lines and levels are not predefined
18
+ but determined by their occurrence order.
19
+
20
+ It requires its own algorithm.
21
+
22
+ For a more generic approach, you need to rely on `docutils`.
23
+ """
24
+ out_metadata = Metadata()
25
+ unreleased_title_kind: str | tuple[str, str] | None = None
26
+ is_overlined_title = False
27
+ lines = [line.strip().lower() for line in file.readlines()]
28
+
29
+ for index, (first, second, third) in enumerate(
30
+ zip_longest(lines, lines[1:], lines[2:], fillvalue="")
31
+ ):
32
+ title: str | None = None
33
+ kind: str | tuple[str, str] | None = None
34
+ if _is_overlined_title(first, second, third):
35
+ title = second
36
+ kind = (first[0], third[0])
37
+ is_overlined_title = True
38
+ elif not is_overlined_title and _is_underlined_title(first, second):
39
+ title = first
40
+ kind = second[0]
41
+ else:
42
+ is_overlined_title = False
43
+
44
+ if not title:
45
+ continue
46
+
47
+ if "unreleased" in title:
48
+ unreleased_title_kind = kind
49
+ out_metadata.unreleased_start = index
50
+ continue
51
+
52
+ if unreleased_title_kind and unreleased_title_kind == kind:
53
+ out_metadata.unreleased_end = index
54
+ # Try to find the latest release done
55
+ if version := self.tag_rules.search_version(title):
56
+ out_metadata.latest_version = version[0]
57
+ out_metadata.latest_version_tag = version[1]
58
+ out_metadata.latest_version_position = index
59
+ break
60
+
61
+ if (
62
+ out_metadata.unreleased_start is not None
63
+ and out_metadata.unreleased_end is None
64
+ ):
65
+ out_metadata.unreleased_end = (
66
+ out_metadata.latest_version_position
67
+ if out_metadata.latest_version
68
+ else len(lines)
69
+ )
70
+
71
+ return out_metadata
72
+
73
+
74
+ def _is_overlined_title(first: str, second: str, third: str) -> bool:
75
+ return (
76
+ len(first) == len(third) >= len(second)
77
+ and first[0] == third[0]
78
+ and all(char == first[0] for char in first)
79
+ and _is_underlined_title(second, third)
80
+ )
81
+
82
+
83
+ def _is_underlined_title(first: str, second: str) -> bool:
84
+ return (
85
+ len(second) >= len(first)
86
+ and not second.isalnum()
87
+ and all(char == second[0] for char in second)
88
+ )
@@ -160,7 +160,6 @@ data = {
160
160
  {
161
161
  "name": ["-l", "--message-length-limit"],
162
162
  "type": int,
163
- "default": 0,
164
163
  "help": "length limit of the commit message; 0 for no limit",
165
164
  },
166
165
  {
@@ -499,7 +498,6 @@ data = {
499
498
  {
500
499
  "name": ["-l", "--message-length-limit"],
501
500
  "type": int,
502
- "default": 0,
503
501
  "help": "length limit of the commit message; 0 for no limit",
504
502
  },
505
503
  ],
@@ -543,6 +541,18 @@ data = {
543
541
  "action": "store_true",
544
542
  "exclusive_group": "group1",
545
543
  },
544
+ {
545
+ "name": ["--major"],
546
+ "help": "get just the major version",
547
+ "action": "store_true",
548
+ "exclusive_group": "group2",
549
+ },
550
+ {
551
+ "name": ["--minor"],
552
+ "help": "get just the minor version",
553
+ "action": "store_true",
554
+ "exclusive_group": "group2",
555
+ },
546
556
  ],
547
557
  },
548
558
  ],
@@ -22,13 +22,15 @@ def _try_decode(bytes_: bytes) -> str:
22
22
  try:
23
23
  return bytes_.decode("utf-8")
24
24
  except UnicodeDecodeError:
25
- charset_match = from_bytes(bytes_).best()
26
- if charset_match is None:
27
- raise CharacterSetDecodeError()
28
- try:
29
- return bytes_.decode(charset_match.encoding)
30
- except UnicodeDecodeError as e:
31
- raise CharacterSetDecodeError() from e
25
+ pass
26
+
27
+ charset_match = from_bytes(bytes_).best()
28
+ if charset_match is None:
29
+ raise CharacterSetDecodeError()
30
+ try:
31
+ return bytes_.decode(charset_match.encoding)
32
+ except UnicodeDecodeError as e:
33
+ raise CharacterSetDecodeError() from e
32
34
 
33
35
 
34
36
  def run(cmd: str, env: Mapping[str, str] | None = None) -> Command:
@@ -67,7 +67,6 @@ class Bump:
67
67
  raise NotAGitProjectError()
68
68
 
69
69
  self.config: BaseConfig = config
70
- self.encoding = config.settings["encoding"]
71
70
  self.arguments = arguments
72
71
  self.bump_settings = cast(
73
72
  BumpArgs,
@@ -216,8 +215,8 @@ class Bump:
216
215
 
217
216
  rules = TagRules.from_settings(cast(Settings, self.bump_settings))
218
217
  current_tag = rules.find_tag_for(git.get_tags(), current_version)
219
- current_tag_version = getattr(
220
- current_tag, "name", rules.normalize_tag(current_version)
218
+ current_tag_version = (
219
+ current_tag.name if current_tag else rules.normalize_tag(current_version)
221
220
  )
222
221
 
223
222
  is_initial = self._is_initial_tag(current_tag, self.arguments["yes"])
@@ -335,7 +334,7 @@ class Bump:
335
334
  str(new_version),
336
335
  self.bump_settings["version_files"],
337
336
  check_consistency=self.check_consistency,
338
- encoding=self.encoding,
337
+ encoding=self.config.settings["encoding"],
339
338
  )
340
339
  )
341
340
 
@@ -67,7 +67,6 @@ class Changelog:
67
67
  else changelog_file_name
68
68
  )
69
69
 
70
- self.encoding = self.config.settings["encoding"]
71
70
  self.cz = factory.committer_factory(self.config)
72
71
 
73
72
  self.start_rev = arguments.get("start_rev") or self.config.settings.get(
@@ -104,12 +103,10 @@ class Changelog:
104
103
  or defaults.CHANGE_TYPE_ORDER,
105
104
  )
106
105
  self.rev_range = arguments.get("rev_range")
107
- self.tag_format = (
108
- arguments.get("tag_format") or self.config.settings["tag_format"]
109
- )
110
106
  self.tag_rules = TagRules(
111
107
  scheme=self.scheme,
112
- tag_format=self.tag_format,
108
+ tag_format=arguments.get("tag_format")
109
+ or self.config.settings["tag_format"],
113
110
  legacy_tag_formats=self.config.settings["legacy_tag_formats"],
114
111
  ignored_tag_formats=self.config.settings["ignored_tag_formats"],
115
112
  merge_prereleases=arguments.get("merge_prerelease")
@@ -159,7 +156,9 @@ class Changelog:
159
156
  def _write_changelog(
160
157
  self, changelog_out: str, lines: list[str], changelog_meta: changelog.Metadata
161
158
  ) -> None:
162
- with smart_open(self.file_name, "w", encoding=self.encoding) as changelog_file:
159
+ with smart_open(
160
+ self.file_name, "w", encoding=self.config.settings["encoding"]
161
+ ) as changelog_file:
163
162
  partial_changelog: str | None = None
164
163
  if self.incremental:
165
164
  new_lines = changelog.incremental_build(
@@ -261,7 +260,9 @@ class Changelog:
261
260
 
262
261
  lines = []
263
262
  if self.incremental and os.path.isfile(self.file_name):
264
- with open(self.file_name, encoding=self.encoding) as changelog_file:
263
+ with open(
264
+ self.file_name, encoding=self.config.settings["encoding"]
265
+ ) as changelog_file:
265
266
  lines = changelog_file.readlines()
266
267
 
267
268
  self._write_changelog(changelog_out, lines, changelog_meta)
@@ -7,6 +7,7 @@ from typing import TypedDict
7
7
  from commitizen import factory, git, out
8
8
  from commitizen.config import BaseConfig
9
9
  from commitizen.exceptions import (
10
+ CommitMessageLengthExceededError,
10
11
  InvalidCommandArgumentError,
11
12
  InvalidCommitMessageError,
12
13
  NoCommitsFoundError,
@@ -18,7 +19,7 @@ class CheckArgs(TypedDict, total=False):
18
19
  commit_msg: str
19
20
  rev_range: str
20
21
  allow_abort: bool
21
- message_length_limit: int
22
+ message_length_limit: int | None
22
23
  allowed_prefixes: list[str]
23
24
  message: str
24
25
  use_default_range: bool
@@ -41,8 +42,11 @@ class Check:
41
42
  self.allow_abort = bool(
42
43
  arguments.get("allow_abort", config.settings["allow_abort"])
43
44
  )
45
+
44
46
  self.use_default_range = bool(arguments.get("use_default_range"))
45
- self.max_msg_length = arguments.get("message_length_limit", 0)
47
+ self.max_msg_length = arguments.get(
48
+ "message_length_limit", config.settings.get("message_length_limit", None)
49
+ )
46
50
 
47
51
  # we need to distinguish between None and [], which is a valid value
48
52
  allowed_prefixes = arguments.get("allowed_prefixes")
@@ -71,7 +75,6 @@ class Check:
71
75
  self.commit_msg = sys.stdin.read()
72
76
 
73
77
  self.config: BaseConfig = config
74
- self.encoding = config.settings["encoding"]
75
78
  self.cz = factory.committer_factory(self.config)
76
79
 
77
80
  def __call__(self) -> None:
@@ -79,6 +82,7 @@ class Check:
79
82
 
80
83
  Raises:
81
84
  InvalidCommitMessageError: if the commit provided not follows the conventional pattern
85
+ NoCommitsFoundError: if no commit is found with the given range
82
86
  """
83
87
  commits = self._get_commits()
84
88
  if not commits:
@@ -88,7 +92,7 @@ class Check:
88
92
  invalid_msgs_content = "\n".join(
89
93
  f'commit "{commit.rev}": "{commit.message}"'
90
94
  for commit in commits
91
- if not self._validate_commit_message(commit.message, pattern)
95
+ if not self._validate_commit_message(commit.message, pattern, commit.rev)
92
96
  )
93
97
  if invalid_msgs_content:
94
98
  # TODO: capitalize the first letter of the error message for consistency in v5
@@ -105,7 +109,9 @@ class Check:
105
109
  # Get commit message from command line (--message)
106
110
  return self.commit_msg
107
111
 
108
- with open(self.commit_msg_file, encoding=self.encoding) as commit_file:
112
+ with open(
113
+ self.commit_msg_file, encoding=self.config.settings["encoding"]
114
+ ) as commit_file:
109
115
  # Get commit message from file (--commit-msg-file)
110
116
  return commit_file.read()
111
117
 
@@ -151,7 +157,7 @@ class Check:
151
157
  return "\n".join(lines)
152
158
 
153
159
  def _validate_commit_message(
154
- self, commit_msg: str, pattern: re.Pattern[str]
160
+ self, commit_msg: str, pattern: re.Pattern[str], commit_hash: str
155
161
  ) -> bool:
156
162
  if not commit_msg:
157
163
  return self.allow_abort
@@ -159,9 +165,14 @@ class Check:
159
165
  if any(map(commit_msg.startswith, self.allowed_prefixes)):
160
166
  return True
161
167
 
162
- if self.max_msg_length:
168
+ if self.max_msg_length is not None:
163
169
  msg_len = len(commit_msg.partition("\n")[0].strip())
164
170
  if msg_len > self.max_msg_length:
165
- return False
171
+ raise CommitMessageLengthExceededError(
172
+ f"commit validation: failed!\n"
173
+ f"commit message length exceeds the limit.\n"
174
+ f'commit "{commit_hash}": "{commit_msg}"\n'
175
+ f"message length limit: {self.max_msg_length} (actual: {msg_len})"
176
+ )
166
177
 
167
178
  return bool(pattern.match(commit_msg))
@@ -33,7 +33,7 @@ class CommitArgs(TypedDict, total=False):
33
33
  dry_run: bool
34
34
  edit: bool
35
35
  extra_cli_args: str
36
- message_length_limit: int
36
+ message_length_limit: int | None
37
37
  no_retry: bool
38
38
  signoff: bool
39
39
  write_message_to_file: Path | None
@@ -48,47 +48,53 @@ class Commit:
48
48
  raise NotAGitProjectError()
49
49
 
50
50
  self.config: BaseConfig = config
51
- self.encoding = config.settings["encoding"]
52
51
  self.cz = factory.committer_factory(self.config)
53
52
  self.arguments = arguments
54
- self.temp_file: str = get_backup_file_path()
53
+ self.backup_file_path = get_backup_file_path()
55
54
 
56
55
  def _read_backup_message(self) -> str | None:
57
56
  # Check the commit backup file exists
58
- if not os.path.isfile(self.temp_file):
57
+ if not self.backup_file_path.is_file():
59
58
  return None
60
59
 
61
60
  # Read commit message from backup
62
- with open(self.temp_file, encoding=self.encoding) as f:
61
+ with open(
62
+ self.backup_file_path, encoding=self.config.settings["encoding"]
63
+ ) as f:
63
64
  return f.read().strip()
64
65
 
65
- def _prompt_commit_questions(self) -> str:
66
+ def _get_message_by_prompt_commit_questions(self) -> str:
66
67
  # Prompt user for the commit message
67
- cz = self.cz
68
- questions = cz.questions()
68
+ questions = self.cz.questions()
69
69
  for question in (q for q in questions if q["type"] == "list"):
70
70
  question["use_shortcuts"] = self.config.settings["use_shortcuts"]
71
71
  try:
72
- answers = questionary.prompt(questions, style=cz.style)
72
+ answers = questionary.prompt(questions, style=self.cz.style)
73
73
  except ValueError as err:
74
74
  root_err = err.__context__
75
75
  if isinstance(root_err, CzException):
76
- raise CustomError(root_err.__str__())
76
+ raise CustomError(str(root_err))
77
77
  raise err
78
78
 
79
79
  if not answers:
80
80
  raise NoAnswersError()
81
81
 
82
- message = cz.message(answers)
83
- message_len = len(message.partition("\n")[0].strip())
84
- message_length_limit = self.arguments.get("message_length_limit", 0)
85
- if 0 < message_length_limit < message_len:
86
- raise CommitMessageLengthExceededError(
87
- f"Length of commit message exceeds limit ({message_len}/{message_length_limit})"
88
- )
82
+ message = self.cz.message(answers)
83
+ if limit := self.arguments.get(
84
+ "message_length_limit", self.config.settings.get("message_length_limit", 0)
85
+ ):
86
+ self._validate_subject_length(message=message, length_limit=limit)
89
87
 
90
88
  return message
91
89
 
90
+ def _validate_subject_length(self, *, message: str, length_limit: int) -> None:
91
+ # By the contract, message_length_limit is set to 0 for no limit
92
+ subject = message.partition("\n")[0].strip()
93
+ if len(subject) > length_limit:
94
+ raise CommitMessageLengthExceededError(
95
+ f"Length of commit message exceeds limit ({len(subject)}/{length_limit}), subject: '{subject}'"
96
+ )
97
+
92
98
  def manual_edit(self, message: str) -> str:
93
99
  editor = git.get_core_editor()
94
100
  if editor is None:
@@ -108,16 +114,18 @@ class Commit:
108
114
 
109
115
  def _get_message(self) -> str:
110
116
  if self.arguments.get("retry"):
111
- m = self._read_backup_message()
112
- if m is None:
117
+ commit_message = self._read_backup_message()
118
+ if commit_message is None:
113
119
  raise NoCommitBackupError()
114
- return m
120
+ return commit_message
115
121
 
116
- if self.config.settings.get("retry_after_failure") and not self.arguments.get(
117
- "no_retry"
122
+ if (
123
+ self.config.settings.get("retry_after_failure")
124
+ and not self.arguments.get("no_retry")
125
+ and (backup_message := self._read_backup_message())
118
126
  ):
119
- return self._read_backup_message() or self._prompt_commit_questions()
120
- return self._prompt_commit_questions()
127
+ return backup_message
128
+ return self._get_message_by_prompt_commit_questions()
121
129
 
122
130
  def __call__(self) -> None:
123
131
  extra_args = self.arguments.get("extra_cli_args", "")
@@ -139,15 +147,17 @@ class Commit:
139
147
  if write_message_to_file is not None and write_message_to_file.is_dir():
140
148
  raise NotAllowed(f"{write_message_to_file} is a directory")
141
149
 
142
- m = self._get_message()
150
+ commit_message = self._get_message()
143
151
  if self.arguments.get("edit"):
144
- m = self.manual_edit(m)
152
+ commit_message = self.manual_edit(commit_message)
145
153
 
146
- out.info(f"\n{m}\n")
154
+ out.info(f"\n{commit_message}\n")
147
155
 
148
156
  if write_message_to_file:
149
- with smart_open(write_message_to_file, "w", encoding=self.encoding) as file:
150
- file.write(m)
157
+ with smart_open(
158
+ write_message_to_file, "w", encoding=self.config.settings["encoding"]
159
+ ) as file:
160
+ file.write(commit_message)
151
161
 
152
162
  if dry_run:
153
163
  raise DryRunExit()
@@ -155,13 +165,15 @@ class Commit:
155
165
  if self.config.settings["always_signoff"] or signoff:
156
166
  extra_args = f"{extra_args} -s".strip()
157
167
 
158
- c = git.commit(m, args=extra_args)
168
+ c = git.commit(commit_message, args=extra_args)
159
169
  if c.return_code != 0:
160
170
  out.error(c.err)
161
171
 
162
172
  # Create commit backup
163
- with smart_open(self.temp_file, "w", encoding=self.encoding) as f:
164
- f.write(m)
173
+ with smart_open(
174
+ self.backup_file_path, "w", encoding=self.config.settings["encoding"]
175
+ ) as f:
176
+ f.write(commit_message)
165
177
 
166
178
  raise CommitError()
167
179
 
@@ -170,7 +182,7 @@ class Commit:
170
182
  return
171
183
 
172
184
  with contextlib.suppress(FileNotFoundError):
173
- os.remove(self.temp_file)
185
+ self.backup_file_path.unlink()
174
186
  out.write(c.err)
175
187
  out.write(c.out)
176
188
  out.success("Commit successful!")