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
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
1"""Command-line entry point for python-commitlint.
3Defines the ``commitlint`` Click group with two subcommands:
5- ``lint`` — validate a commit message against the configured rules
6- ``convert`` — translate a ``commitlint.config.js`` to ``.commitlintrc.yaml``
7"""
9import json
10import sys
11from pathlib import Path
13import click
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
21@click.group(name="commitlint")
22def commitlint() -> None:
23 """python-commitlint command-line interface."""
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.
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)
66 if not commit_message:
67 click.echo("Error: No commit message provided", err=True)
68 sys.exit(1)
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)
77 if output_format == "json":
78 _print_json_output(result)
79 else:
80 _print_text_output(result, quiet)
82 sys.exit(0 if result.valid else 1)
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``.
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")
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)
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
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)
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 )
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 )
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 )
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")
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 }
216 click.echo(json.dumps(output, indent=2))
219if __name__ == "__main__":
220 commitlint()