Coverage for src / python_commitlint / cli.py: 89%

79 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-28 02:54 +0000

1"""Command-line entry point for python-commitlint. 

2 

3Defines the ``commitlint`` Click group with two subcommands: 

4 

5- ``lint`` — validate a commit message against the configured rules 

6- ``convert`` — translate a ``commitlint.config.js`` to ``.commitlintrc.yaml`` 

7""" 

8 

9import json 

10import sys 

11from pathlib import Path 

12 

13import click 

14 

15from python_commitlint.config.converter import convert_js_to_yaml 

16from python_commitlint.core.exceptions import ConfigurationError 

17from python_commitlint.core.models import LintResult, ValidationError 

18from python_commitlint.linter import CommitLinterFactory 

19 

20 

21@click.group(name="commitlint") 

22def commitlint() -> None: 

23 """python-commitlint command-line interface.""" 

24 

25 

26@commitlint.command(name="lint") 

27@click.argument("message", required=False) 

28@click.option( 

29 "-c", 

30 "--config", 

31 type=click.Path(exists=True, dir_okay=False, path_type=Path), 

32 help="Path to configuration file (default: .commitlintrc.yaml)", 

33) 

34@click.option( 

35 "--stdin", 

36 is_flag=True, 

37 help="Read commit message from stdin", 

38) 

39@click.option( 

40 "--format", 

41 "output_format", 

42 type=click.Choice(["text", "json"]), 

43 default="text", 

44 help="Output format", 

45) 

46@click.option( 

47 "-q", 

48 "--quiet", 

49 is_flag=True, 

50 help="Suppress output except for errors", 

51) 

52def lint_command( 

53 message: str | None, 

54 config: Path | None, 

55 stdin: bool, 

56 output_format: str, 

57 quiet: bool, 

58) -> None: 

59 """Validate a commit message and exit non-zero on failure. 

60 

61 The message can be supplied as a positional argument or read from 

62 stdin via ``--stdin`` (typical git ``commit-msg`` hook usage). 

63 """ 

64 commit_message = _get_commit_message(message, stdin) 

65 

66 if not commit_message: 

67 click.echo("Error: No commit message provided", err=True) 

68 sys.exit(1) 

69 

70 try: 

71 linter = CommitLinterFactory.create(config_path=config) 

72 result = linter.lint(commit_message) 

73 except ConfigurationError as e: 

74 click.echo(click.style(f"Configuration error: {e}", fg="red"), err=True) 

75 sys.exit(1) 

76 

77 if output_format == "json": 

78 _print_json_output(result) 

79 else: 

80 _print_text_output(result, quiet) 

81 

82 sys.exit(0 if result.valid else 1) 

83 

84 

85@commitlint.command(name="convert") 

86@click.argument( 

87 "input_file", 

88 type=click.Path(exists=True, dir_okay=False, path_type=Path), 

89) 

90@click.option( 

91 "-o", 

92 "--output", 

93 type=click.Path(dir_okay=False, path_type=Path), 

94 help="Output file path (default: .commitlintrc.yaml)", 

95) 

96@click.option( 

97 "--dry-run", 

98 is_flag=True, 

99 help="Print the converted configuration without writing to file", 

100) 

101def convert_command( 

102 input_file: Path, 

103 output: Path | None, 

104 dry_run: bool, 

105) -> None: 

106 """Convert a ``commitlint.config.js`` file to ``.commitlintrc.yaml``. 

107 

108 With ``--dry-run`` the YAML is written to stdout; otherwise it is 

109 written to ``--output`` (or ``.commitlintrc.yaml`` by default). 

110 """ 

111 if output is None and not dry_run: 

112 output = Path(".commitlintrc.yaml") 

113 

114 try: 

115 if dry_run: 

116 yaml_content = convert_js_to_yaml(input_file) 

117 click.echo(yaml_content) 

118 else: 

119 yaml_content = convert_js_to_yaml(input_file, output) 

120 click.echo( 

121 click.style( 

122 f"Successfully converted {input_file} to {output}", 

123 fg="green", 

124 bold=True, 

125 ) 

126 ) 

127 except (OSError, ValueError) as e: 

128 # Catch the user-facing failure modes (file I/O, decode/encode 

129 # errors) and let any other exception propagate as a real 

130 # traceback — those would indicate an internal bug. 

131 click.echo( 

132 click.style( 

133 f"Error converting file ({type(e).__name__}): {e}", fg="red" 

134 ), 

135 err=True, 

136 ) 

137 sys.exit(1) 

138 

139 

140def _get_commit_message(message: str | None, stdin: bool) -> str | None: 

141 if stdin: 

142 # An empty stdin returns "" — falsy, so the caller treats it as 

143 # "no message provided" and exits with an error. 

144 return sys.stdin.read().strip() 

145 return message 

146 

147 

148def _print_text_output(result: LintResult, quiet: bool) -> None: 

149 _print_error_lines(result.errors) 

150 if not quiet: 

151 _print_warning_lines(result.warnings) 

152 _print_status_summary(result, quiet) 

153 

154 

155def _print_error_lines(errors: list[ValidationError]) -> None: 

156 for error in errors: 

157 click.echo( 

158 f"{click.style('x', fg='red')} " 

159 f"{error.message} " 

160 f"{click.style(error.rule, fg='yellow')}" 

161 ) 

162 

163 

164def _print_warning_lines(warnings: list[ValidationError]) -> None: 

165 for warning in warnings: 

166 click.echo( 

167 f"{click.style('!', fg='yellow')} " 

168 f"{warning.message} " 

169 f"{click.style(warning.rule, fg='yellow')}" 

170 ) 

171 

172 

173def _print_status_summary(result: LintResult, quiet: bool) -> None: 

174 if result.has_errors: 

175 _print_error_count(result) 

176 return 

177 if not result.has_warnings and not quiet: 

178 click.echo( 

179 click.style("Commit message is valid!", fg="green", bold=True) 

180 ) 

181 

182 

183def _print_error_count(result: LintResult) -> None: 

184 warning_count = len(result.warnings) 

185 summary = f"{len(result.errors)} error(s)" 

186 if warning_count > 0: 

187 summary += f", {warning_count} warning(s)" 

188 click.echo(f"\n{click.style('x', fg='red')} {summary} found") 

189 

190 

191def _print_json_output(result: LintResult) -> None: 

192 output = { 

193 "valid": result.valid, 

194 "errors": [ 

195 { 

196 "rule": error.rule, 

197 "message": error.message, 

198 "severity": error.severity.value, 

199 "line": error.line, 

200 "column": error.column, 

201 } 

202 for error in result.errors 

203 ], 

204 "warnings": [ 

205 { 

206 "rule": warning.rule, 

207 "message": warning.message, 

208 "severity": warning.severity.value, 

209 "line": warning.line, 

210 "column": warning.column, 

211 } 

212 for warning in result.warnings 

213 ], 

214 } 

215 

216 click.echo(json.dumps(output, indent=2)) 

217 

218 

219if __name__ == "__main__": 

220 commitlint()