zendev-commit 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,572 @@
1
+ """Commit-message profiles, validation, and zendev's interactive commit tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import tomllib
9
+ from collections import OrderedDict
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Annotated, Literal, TextIO, TypedDict
14
+
15
+ import questionary
16
+ import typer
17
+
18
+ from zendev.conventional import ParseIssue, parse_conventional_commit
19
+ from zendev.gitmoji import load_emoji_conventions, load_gitmojis, match_gitmoji, parse_gitmoji_commit
20
+
21
+ __all__ = [
22
+ "COMMIT_CONVENTION_EXAMPLES",
23
+ "EMOJI_MAP",
24
+ "TYPE_DISPLAY_ORDER",
25
+ "TYPE_SHORT_DESCRIPTIONS",
26
+ "CommitProfile",
27
+ "CommitProfileSelection",
28
+ "ValidationResult",
29
+ "ZendevAnswers",
30
+ "ask",
31
+ "commit_app",
32
+ "format_commit_convention_help_body",
33
+ "hook_app",
34
+ "hook_main",
35
+ "is_valid_commit_message",
36
+ "main",
37
+ "message",
38
+ "report_invalid_commit_message",
39
+ "resolve_commit_profile",
40
+ "schema_pattern",
41
+ "suggest_commit_message",
42
+ "validate_commit_message",
43
+ ]
44
+
45
+
46
+ class CommitProfile(StrEnum):
47
+ """Supported commit-message contracts."""
48
+
49
+ ZENDEV = "zendev"
50
+ CONVENTIONAL = "conventional"
51
+ GITMOJI = "gitmoji"
52
+
53
+
54
+ class CommitProfileSelection(StrEnum):
55
+ """CLI values include automatic repository configuration discovery."""
56
+
57
+ AUTO = "auto"
58
+ ZENDEV = "zendev"
59
+ CONVENTIONAL = "conventional"
60
+ GITMOJI = "gitmoji"
61
+
62
+
63
+ commit_app = typer.Typer(
64
+ add_completion=False,
65
+ help="Create a commit using zendev's interactive message convention.",
66
+ pretty_exceptions_enable=False,
67
+ rich_markup_mode=None,
68
+ )
69
+ hook_app = typer.Typer(
70
+ add_completion=False,
71
+ help="Validate a commit message against a configured profile.",
72
+ pretty_exceptions_enable=False,
73
+ rich_markup_mode=None,
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class ValidationResult:
79
+ valid: bool
80
+ profile: CommitProfile
81
+ issue: ParseIssue | None = None
82
+
83
+
84
+ _CONVENTIONS = load_emoji_conventions()
85
+ _CONVENTION_BY_GITMOJI_NAME = {convention.gitmoji.name: convention for convention in _CONVENTIONS}
86
+
87
+ EMOJI_MAP: dict[str, str] = {convention.type: convention.gitmoji.emoji for convention in _CONVENTIONS}
88
+ _DESCRIPTIONS: dict[str, str] = {convention.type: convention.gitmoji.description for convention in _CONVENTIONS}
89
+
90
+ # Short labels for CLI / CI help tables come directly from the vendored catalog.
91
+ TYPE_SHORT_DESCRIPTIONS: dict[str, str] = dict(_DESCRIPTIONS)
92
+
93
+ _LEGACY_DISPLAY_ORDER: tuple[str, ...] = (
94
+ "init",
95
+ "feat",
96
+ "fix",
97
+ "refactor",
98
+ "perf",
99
+ "docs",
100
+ "test",
101
+ "build",
102
+ "ci",
103
+ "chore",
104
+ "style",
105
+ )
106
+ TYPE_DISPLAY_ORDER: tuple[str, ...] = _LEGACY_DISPLAY_ORDER + tuple(
107
+ convention.type for convention in _CONVENTIONS if convention.type not in _LEGACY_DISPLAY_ORDER
108
+ )
109
+
110
+ COMMIT_CONVENTION_EXAMPLES: tuple[str, ...] = (
111
+ "🎉 init: begin a project",
112
+ "✨ feat: add JSON logging mode",
113
+ "🐛 fix(parser): handle null token",
114
+ "🚀 deploy: publish the package",
115
+ )
116
+
117
+ BUMP_PATTERN = r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):"
118
+ BUMP_MAP: OrderedDict[str, str] = OrderedDict(
119
+ (
120
+ (r"^.+!$", "MAJOR"),
121
+ (r"^BREAKING[\-\ ]CHANGE", "MAJOR"),
122
+ (r"^feat", "MINOR"),
123
+ (r"^fix", "PATCH"),
124
+ (r"^refactor", "PATCH"),
125
+ (r"^perf", "PATCH"),
126
+ )
127
+ )
128
+
129
+ SPECIAL_COMMIT_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ", "reword! ")
130
+
131
+ assert set(TYPE_DISPLAY_ORDER) == set(EMOJI_MAP.keys())
132
+ assert set(TYPE_SHORT_DESCRIPTIONS.keys()) == set(EMOJI_MAP.keys())
133
+
134
+
135
+ def _configured_commit_profile(start: Path | None = None) -> CommitProfile | None:
136
+ current = (start or Path.cwd()).resolve()
137
+ if current.is_file():
138
+ current = current.parent
139
+ for directory in (current, *current.parents):
140
+ config_path = directory / "pyproject.toml"
141
+ if not config_path.is_file():
142
+ continue
143
+ try:
144
+ payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
145
+ except (OSError, tomllib.TOMLDecodeError) as error:
146
+ raise ValueError(f"{config_path}: failed to load commit profile ({error}).") from error
147
+
148
+ tool = payload.get("tool", {})
149
+ if not isinstance(tool, dict):
150
+ raise ValueError(f"{config_path}: tool must be a TOML table.")
151
+ zendev = tool.get("zendev", {})
152
+ if not isinstance(zendev, dict):
153
+ raise ValueError(f"{config_path}: tool.zendev must be a TOML table.")
154
+ commit = zendev.get("commit", {})
155
+ if not isinstance(commit, dict):
156
+ raise ValueError(f"{config_path}: tool.zendev.commit must be a TOML table.")
157
+ value = commit.get("profile")
158
+ if value is None:
159
+ return None
160
+ if not isinstance(value, str):
161
+ raise ValueError(f"{config_path}: tool.zendev.commit.profile must be a string.")
162
+ try:
163
+ return CommitProfile(value)
164
+ except ValueError as error:
165
+ choices = ", ".join(profile.value for profile in CommitProfile)
166
+ raise ValueError(f"{config_path}: unknown commit profile {value!r}; expected one of {choices}.") from error
167
+ return None
168
+
169
+
170
+ def resolve_commit_profile(
171
+ profile: CommitProfile | str | None = None,
172
+ *,
173
+ start: Path | None = None,
174
+ ) -> CommitProfile:
175
+ """Resolve an explicit profile or the nearest pyproject setting."""
176
+
177
+ if profile is None or profile == "auto":
178
+ return _configured_commit_profile(start) or CommitProfile.ZENDEV
179
+ if isinstance(profile, CommitProfile):
180
+ return profile
181
+ return CommitProfile(profile)
182
+
183
+
184
+ def _parse_scope(text: str) -> str:
185
+ return "-".join(text.strip().split())
186
+
187
+
188
+ def _parse_subject(text: str) -> str:
189
+ subject = text.strip(".").strip()
190
+ if not subject:
191
+ raise ValueError("Subject is required.")
192
+ return subject
193
+
194
+
195
+ class ZendevAnswers(TypedDict):
196
+ prefix: str
197
+ scope: str
198
+ subject: str
199
+ body: str
200
+ footer: str
201
+ is_breaking_change: bool
202
+
203
+
204
+ def message(answers: ZendevAnswers) -> str:
205
+ prefix = answers["prefix"]
206
+ scope = answers["scope"]
207
+ subject = answers["subject"]
208
+ body = answers["body"]
209
+ footer = answers["footer"]
210
+ is_breaking_change = answers["is_breaking_change"]
211
+
212
+ emoji = EMOJI_MAP.get(prefix, "")
213
+ formatted_scope = f"({scope})" if scope else ""
214
+ title = f"{emoji} {prefix}{formatted_scope}"
215
+
216
+ if is_breaking_change:
217
+ footer = f"BREAKING CHANGE: {footer}"
218
+
219
+ formatted_body = f"\n\n{body}" if body else ""
220
+ formatted_footer = f"\n\n{footer}" if footer else ""
221
+
222
+ return f"{title}: {subject}{formatted_body}{formatted_footer}"
223
+
224
+
225
+ def schema_pattern(*, require_emoji: bool = True) -> str:
226
+ types = "|".join(re.escape(name) for name in EMOJI_MAP)
227
+ if require_emoji:
228
+ pairs: list[str] = []
229
+ for convention in _CONVENTIONS:
230
+ gitmoji = convention.gitmoji
231
+ tokens = {gitmoji.emoji, gitmoji.emoji.replace("\ufe0f", ""), gitmoji.code}
232
+ token_pattern = "|".join(re.escape(token) for token in sorted(tokens, key=len, reverse=True))
233
+ pairs.append(r"(?:" + token_pattern + r") " + re.escape(convention.type))
234
+ header = r"(?:" + "|".join(pairs) + r")"
235
+ else:
236
+ header = r"(?:\S+ )?(?:" + types + r")"
237
+ return (
238
+ r"(?s)"
239
+ + header
240
+ + r"(\(\S+\))?" # optional scope
241
+ + r"!?"
242
+ + r": "
243
+ + r"([^\n\r]+)" # subject
244
+ + r"((\n\n.*)|(\s*))?$"
245
+ )
246
+
247
+
248
+ def normalize_commit_message(text: str, *, comment_char: str = "#") -> str:
249
+ lines: list[str] = []
250
+ scissors = f"{comment_char} ------------------------ >8 ------------------------"
251
+ for line in text.splitlines():
252
+ if line.startswith(scissors):
253
+ break
254
+ if comment_char and line.startswith(comment_char):
255
+ continue
256
+ lines.append(line.rstrip())
257
+ return "\n".join(lines).strip()
258
+
259
+
260
+ def _validate_zendev(normalized: str) -> ValidationResult:
261
+ match = match_gitmoji(normalized)
262
+ if match is None:
263
+ without_emoji, _ = parse_conventional_commit(normalized)
264
+ issue = (
265
+ ParseIssue("missing-emoji", "An emoji prefix is required.")
266
+ if without_emoji is not None
267
+ else ParseIssue(
268
+ "invalid-zendev-header",
269
+ "Expected <emoji> <type>(<scope>)!: <description>.",
270
+ )
271
+ )
272
+ return ValidationResult(False, CommitProfile.ZENDEV, issue)
273
+
274
+ separator_end = len(match.token) + 1
275
+ if normalized[len(match.token)] != " " or separator_end >= len(normalized) or normalized[separator_end].isspace():
276
+ return ValidationResult(
277
+ False,
278
+ CommitProfile.ZENDEV,
279
+ ParseIssue(
280
+ "invalid-zendev-separator",
281
+ "The emoji or shortcode must be followed by exactly one space.",
282
+ ),
283
+ )
284
+
285
+ parsed, issue = parse_conventional_commit(match.remainder)
286
+ if parsed is None:
287
+ return ValidationResult(
288
+ False,
289
+ CommitProfile.ZENDEV,
290
+ issue
291
+ or ParseIssue(
292
+ "invalid-zendev-header",
293
+ "Expected <emoji-or-shortcode> <type>(<scope>)!: <description>.",
294
+ ),
295
+ )
296
+
297
+ expected_type = _CONVENTION_BY_GITMOJI_NAME[match.gitmoji.name].type
298
+ if parsed.header.type != expected_type:
299
+ return ValidationResult(
300
+ False,
301
+ CommitProfile.ZENDEV,
302
+ ParseIssue(
303
+ "emoji-type-mismatch",
304
+ f"{match.token} must be paired with type {expected_type!r}, not {parsed.header.type!r}.",
305
+ ),
306
+ )
307
+ return ValidationResult(True, CommitProfile.ZENDEV)
308
+
309
+
310
+ def validate_commit_message(
311
+ text: str,
312
+ *,
313
+ profile: CommitProfile | str | None = None,
314
+ comment_char: str = "#",
315
+ ) -> ValidationResult:
316
+ """Validate a complete message against the selected commit profile."""
317
+
318
+ selected = resolve_commit_profile(profile)
319
+ normalized = normalize_commit_message(text, comment_char=comment_char)
320
+ if not normalized:
321
+ return ValidationResult(False, selected, ParseIssue("empty-message", "The commit message is empty."))
322
+ if normalized.startswith(SPECIAL_COMMIT_PREFIXES):
323
+ return ValidationResult(True, selected)
324
+
325
+ if selected is CommitProfile.ZENDEV:
326
+ return _validate_zendev(normalized)
327
+ if selected is CommitProfile.CONVENTIONAL:
328
+ parsed, issue = parse_conventional_commit(normalized)
329
+ else:
330
+ parsed, issue = parse_gitmoji_commit(normalized)
331
+ return ValidationResult(parsed is not None, selected, issue)
332
+
333
+
334
+ def is_valid_commit_message(
335
+ text: str,
336
+ *,
337
+ profile: CommitProfile | str | None = None,
338
+ comment_char: str = "#",
339
+ ) -> bool:
340
+ return validate_commit_message(text, profile=profile, comment_char=comment_char).valid
341
+
342
+
343
+ def suggest_commit_message(text: str) -> str | None:
344
+ normalized = normalize_commit_message(text)
345
+ if not normalized or is_valid_commit_message(normalized):
346
+ return None
347
+ parsed, _ = parse_conventional_commit(normalized)
348
+ if parsed is None:
349
+ return None
350
+ emoji = EMOJI_MAP.get(parsed.header.type)
351
+ if emoji is None:
352
+ return None
353
+ return f"{emoji} {normalized}"
354
+
355
+
356
+ def _format_type_table_lines() -> list[str]:
357
+ lines: list[str] = []
358
+ type_width = max(map(len, TYPE_DISPLAY_ORDER))
359
+ for name in TYPE_DISPLAY_ORDER:
360
+ emoji = EMOJI_MAP[name]
361
+ desc = TYPE_SHORT_DESCRIPTIONS[name]
362
+ lines.append(f" {emoji} {name:{type_width}} {desc}")
363
+ return lines
364
+
365
+
366
+ def format_commit_convention_help_body(
367
+ *,
368
+ include_special_prefix_note: bool = True,
369
+ profile: CommitProfile | str | None = None,
370
+ ) -> str:
371
+ selected = resolve_commit_profile(profile)
372
+ if selected is CommitProfile.CONVENTIONAL:
373
+ parts = [
374
+ "",
375
+ " Expected: <type>(<scope>)!: <description>",
376
+ " [blank line + optional body]",
377
+ " [blank line + optional footer(s)]",
378
+ "",
379
+ " Examples:",
380
+ " feat: add JSON logging mode",
381
+ " fix(parser): handle null token",
382
+ " feat(api)!: replace the response envelope",
383
+ "",
384
+ ]
385
+ elif selected is CommitProfile.GITMOJI:
386
+ parts = [
387
+ "",
388
+ " Expected: <gitmoji> (<scope>): <message>",
389
+ f" Catalog: {len(load_gitmojis())} official Unicode/shortcode intentions from gitmoji.dev",
390
+ "",
391
+ " Examples:",
392
+ " ✨ Introduce JSON logging mode",
393
+ " :bug: (parser): Handle null token",
394
+ " ♿️ (account): Improve modal accessibility",
395
+ "",
396
+ ]
397
+ else:
398
+ parts = [
399
+ "",
400
+ " Expected: <emoji-or-shortcode> <type>(<scope>)!: <description>",
401
+ f" Catalog: {len(_CONVENTIONS)} strict emoji-to-type pairs covering every Gitmoji intention",
402
+ "",
403
+ " Type table:",
404
+ *_format_type_table_lines(),
405
+ "",
406
+ " Examples:",
407
+ *(f" {example}" for example in COMMIT_CONVENTION_EXAMPLES),
408
+ "",
409
+ ]
410
+ if include_special_prefix_note:
411
+ parts.append(" Merge, Revert, fixup!, squash!, amend!, and reword! prefixes are allowed (git-generated).")
412
+ parts.append("")
413
+ return "\n".join(parts).rstrip()
414
+
415
+
416
+ def report_invalid_commit_message(
417
+ normalized: str,
418
+ *,
419
+ context: Literal["hook", "ci"],
420
+ file: TextIO,
421
+ profile: CommitProfile | str | None = None,
422
+ result: ValidationResult | None = None,
423
+ ) -> None:
424
+ """Print a unified error for invalid messages (commit-msg hook or CI title check)."""
425
+ selected = resolve_commit_profile(profile)
426
+ validation = result or validate_commit_message(normalized, profile=selected)
427
+ suggestion = suggest_commit_message(normalized) if selected is CommitProfile.ZENDEV else None
428
+ if context == "hook":
429
+ print("Invalid commit message.", file=file)
430
+ elif selected is CommitProfile.ZENDEV:
431
+ print("::error::Title does not match zendev emoji commit conventions.", file=file)
432
+ else:
433
+ print(f"::error::Title does not match the {selected.value} commit profile.", file=file)
434
+
435
+ if validation.issue is not None:
436
+ print(f"{validation.issue.message} (line {validation.issue.line})", file=file)
437
+
438
+ print(format_commit_convention_help_body(profile=selected), file=file)
439
+
440
+ if suggestion:
441
+ print(f"Maybe you meant: `{suggestion.splitlines()[0]}`.", file=file)
442
+ elif context == "hook":
443
+ print("Example: `✨ feat: generalize upgrade`.", file=file)
444
+
445
+ received_line = normalized.splitlines()[0] if normalized else ""
446
+ print(f"Received: {received_line!r}", file=file)
447
+
448
+
449
+ @hook_app.command()
450
+ def commit_message(
451
+ commit_msg_file: Annotated[
452
+ Path,
453
+ typer.Argument(
454
+ exists=True,
455
+ file_okay=True,
456
+ dir_okay=False,
457
+ readable=True,
458
+ help="Path to the commit message file provided by Git or the hook runner.",
459
+ ),
460
+ ],
461
+ profile: Annotated[
462
+ CommitProfileSelection,
463
+ typer.Option(
464
+ "--profile",
465
+ help="Validation profile; auto reads [tool.zendev.commit] and falls back to zendev.",
466
+ ),
467
+ ] = CommitProfileSelection.AUTO,
468
+ ) -> None:
469
+ """Validate the message file supplied by Git or a hook runner."""
470
+
471
+ try:
472
+ selected = resolve_commit_profile(profile.value, start=commit_msg_file.parent)
473
+ except ValueError as error:
474
+ raise typer.BadParameter(str(error), param_hint="--profile") from error
475
+ message_text = commit_msg_file.read_text(encoding="utf-8")
476
+ comment_char = _git_comment_char(commit_msg_file.parent)
477
+ normalized = normalize_commit_message(message_text, comment_char=comment_char)
478
+ result = validate_commit_message(normalized, profile=selected, comment_char=comment_char)
479
+ if result.valid:
480
+ return
481
+
482
+ report_invalid_commit_message(
483
+ normalized,
484
+ context="hook",
485
+ file=sys.stderr,
486
+ profile=selected,
487
+ result=result,
488
+ )
489
+ raise typer.Exit(code=1)
490
+
491
+
492
+ def _git_comment_char(cwd: Path) -> str:
493
+ result = subprocess.run(
494
+ ["git", "config", "--get", "core.commentChar"],
495
+ cwd=cwd,
496
+ check=False,
497
+ capture_output=True,
498
+ text=True,
499
+ )
500
+ value = result.stdout.strip()
501
+ return value if len(value) == 1 else "#"
502
+
503
+
504
+ def ask() -> ZendevAnswers:
505
+ """Interactively prompt the user for commit details."""
506
+ choices = [
507
+ questionary.Choice(title=f"{emoji} {name}: {_DESCRIPTIONS[name]}", value=name)
508
+ for name in TYPE_DISPLAY_ORDER
509
+ for emoji in (EMOJI_MAP[name],)
510
+ ]
511
+
512
+ prefix = questionary.select("Select the type of change you are committing", choices=choices).ask()
513
+ if prefix is None:
514
+ raise KeyboardInterrupt
515
+
516
+ scope_raw = questionary.text("Scope (press enter to skip):").ask()
517
+ if scope_raw is None:
518
+ raise KeyboardInterrupt
519
+ scope = _parse_scope(scope_raw)
520
+
521
+ subject_raw = questionary.text("Short imperative summary:").ask()
522
+ if subject_raw is None:
523
+ raise KeyboardInterrupt
524
+ subject = _parse_subject(subject_raw)
525
+
526
+ body = questionary.text("Body (press enter to skip):").ask()
527
+ if body is None:
528
+ raise KeyboardInterrupt
529
+
530
+ is_breaking_change = questionary.confirm("Is this a BREAKING CHANGE?", default=False).ask()
531
+ if is_breaking_change is None:
532
+ raise KeyboardInterrupt
533
+
534
+ footer = questionary.text("Footer (press enter to skip):").ask()
535
+ if footer is None:
536
+ raise KeyboardInterrupt
537
+
538
+ return ZendevAnswers(
539
+ prefix=prefix,
540
+ scope=scope,
541
+ subject=subject,
542
+ body=body,
543
+ footer=footer,
544
+ is_breaking_change=is_breaking_change,
545
+ )
546
+
547
+
548
+ @commit_app.command()
549
+ def create_commit() -> None:
550
+ """Prompt for a commit message and invoke Git."""
551
+
552
+ try:
553
+ answers = ask()
554
+ except KeyboardInterrupt:
555
+ print("\nAborted.")
556
+ raise typer.Exit(code=1) from None
557
+
558
+ msg = message(answers)
559
+ result = subprocess.run(["git", "commit", "-m", msg], check=False)
560
+ raise typer.Exit(code=result.returncode)
561
+
562
+
563
+ def main() -> None:
564
+ """Run the interactive commit CLI."""
565
+
566
+ commit_app(prog_name="zendev-commit")
567
+
568
+
569
+ def hook_main() -> None:
570
+ """Run the reusable commit-msg hook CLI."""
571
+
572
+ hook_app(prog_name="zendev-commit-msg")
zendev/commit/py.typed ADDED
File without changes