esys-gitlog 0.1.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,3 @@
1
+ """esys_gitlog — installable package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m esys_gitlog`` and PyInstaller entry."""
2
+
3
+ from esys_gitlog.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
esys_gitlog/app.py ADDED
@@ -0,0 +1,215 @@
1
+ """Application service. Callable without Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import TextIO
8
+
9
+ from rich.console import Console
10
+
11
+ from esys_gitlog.config import AnalysisConfig, DisplayMode, QaMode, resolve_config
12
+ from esys_gitlog.git_adapter import GitError, iter_commits, list_commits
13
+ from esys_gitlog.links import resolve_commit_url
14
+ from esys_gitlog.model import CommitResult
15
+ from esys_gitlog.pipeline import analyze_commit
16
+ from esys_gitlog.render import render_history
17
+ from esys_gitlog.report import (
18
+ build_report,
19
+ report_exit_code,
20
+ stream_commit_event,
21
+ stream_end_event,
22
+ stream_exit_code,
23
+ stream_start_event,
24
+ write_report,
25
+ write_stream_event,
26
+ )
27
+
28
+
29
+ def analyze_history(
30
+ cwd: str | Path = ".",
31
+ *,
32
+ profile: str | None = None,
33
+ qa: QaMode | None = None,
34
+ display: DisplayMode | None = None,
35
+ config_path: Path | None = None,
36
+ preferences_path: Path | None = None,
37
+ git_log_args: Sequence[str] | None = None,
38
+ ) -> tuple[list[tuple[str, CommitResult, str]], AnalysisConfig]:
39
+ """Load Git history and run the semantic pipeline.
40
+
41
+ Returns ``(rows, config)`` where each row is
42
+ ``(commit_id, result, subject)`` in ``git log`` order.
43
+ """
44
+ root = Path(cwd)
45
+ resolved = resolve_config(
46
+ profile=profile,
47
+ qa=qa,
48
+ display=display,
49
+ config_path=config_path,
50
+ preferences_path=preferences_path,
51
+ cwd=root,
52
+ )
53
+ analyzed: list[tuple[str, CommitResult, str]] = []
54
+ for raw in list_commits(root, git_log_args=git_log_args):
55
+ result = analyze_commit(raw.subject, raw.body, config=resolved)
56
+ result.message = raw.message or "\n\n".join(
57
+ part for part in (raw.subject, raw.body) if part
58
+ )
59
+ result.author_name = raw.author_name or None
60
+ result.author_email = raw.author_email or None
61
+ result.authored_date = raw.authored_date or None
62
+ result.commit_url = resolve_commit_url(
63
+ raw.id,
64
+ resolved.profile,
65
+ cwd=root,
66
+ )
67
+ analyzed.append((raw.id, result, raw.subject))
68
+ return analyzed, resolved
69
+
70
+
71
+ def run_json(
72
+ stdout: TextIO[str],
73
+ *,
74
+ cwd: str | Path = ".",
75
+ profile: str | None = None,
76
+ qa: QaMode | None = None,
77
+ display: DisplayMode | None = None,
78
+ config_path: Path | None = None,
79
+ preferences_path: Path | None = None,
80
+ git_log_args: Sequence[str] | None = None,
81
+ ) -> int:
82
+ """Write the QA report to ``stdout`` only. Return the process exit code."""
83
+ analyzed, _config = analyze_history(
84
+ cwd,
85
+ profile=profile,
86
+ qa=qa,
87
+ display=display,
88
+ config_path=config_path,
89
+ preferences_path=preferences_path,
90
+ git_log_args=git_log_args,
91
+ )
92
+ pairs = [(commit_id, result) for commit_id, result, _subject in analyzed]
93
+ document = build_report(pairs)
94
+ write_report(stdout, document)
95
+ return report_exit_code(document, walk_finished=True)
96
+
97
+
98
+ def run_json_stream(
99
+ stdout: TextIO[str],
100
+ *,
101
+ cwd: str | Path = ".",
102
+ profile: str | None = None,
103
+ qa: QaMode | None = None,
104
+ display: DisplayMode | None = None,
105
+ config_path: Path | None = None,
106
+ preferences_path: Path | None = None,
107
+ git_log_args: Sequence[str] | None = None,
108
+ ) -> int:
109
+ """Write NDJSON stream events to ``stdout`` as each commit is analyzed."""
110
+ root = Path(cwd)
111
+ resolved = resolve_config(
112
+ profile=profile,
113
+ qa=qa,
114
+ display=display,
115
+ config_path=config_path,
116
+ preferences_path=preferences_path,
117
+ cwd=root,
118
+ )
119
+ write_stream_event(stdout, stream_start_event())
120
+ commits = 0
121
+ warnings = 0
122
+ errors = 0
123
+ walk_finished = True
124
+ try:
125
+ for raw in iter_commits(root, git_log_args=git_log_args):
126
+ result = analyze_commit(raw.subject, raw.body, config=resolved)
127
+ result.message = raw.message or "\n\n".join(
128
+ part for part in (raw.subject, raw.body) if part
129
+ )
130
+ result.author_name = raw.author_name or None
131
+ result.author_email = raw.author_email or None
132
+ result.authored_date = raw.authored_date or None
133
+ result.commit_url = resolve_commit_url(
134
+ raw.id,
135
+ resolved.profile,
136
+ cwd=root,
137
+ )
138
+ for diagnostic in result.diagnostics:
139
+ if diagnostic.severity == "warning":
140
+ warnings += 1
141
+ elif diagnostic.severity == "error":
142
+ errors += 1
143
+ commits += 1
144
+ write_stream_event(stdout, stream_commit_event(raw.id, result))
145
+ except BrokenPipeError:
146
+ walk_finished = False
147
+ return stream_exit_code(errors=errors, walk_finished=False)
148
+ write_stream_event(
149
+ stdout,
150
+ stream_end_event(
151
+ commits=commits,
152
+ warnings=warnings,
153
+ errors=errors,
154
+ walk_finished=walk_finished,
155
+ ),
156
+ )
157
+ return stream_exit_code(errors=errors, walk_finished=walk_finished)
158
+
159
+
160
+ def run_human(
161
+ *,
162
+ cwd: str | Path = ".",
163
+ profile: str | None = None,
164
+ qa: QaMode | None = None,
165
+ display: DisplayMode | None = None,
166
+ config_path: Path | None = None,
167
+ preferences_path: Path | None = None,
168
+ console: Console | None = None,
169
+ show_author: bool | None = None,
170
+ show_date: bool | None = None,
171
+ show_body: bool | None = None,
172
+ show_id: bool | None = None,
173
+ show_conventional: bool | None = None,
174
+ use_pager: bool | None = None,
175
+ git_log_args: Sequence[str] | None = None,
176
+ ) -> int:
177
+ """Render history for a person. Return the process exit code.
178
+
179
+ Explicit ``show_*`` CLI values override preference defaults; ``None`` keeps
180
+ the resolved preference. ``use_pager`` defaults to on when stdout is a TTY.
181
+ """
182
+ analyzed, config = analyze_history(
183
+ cwd,
184
+ profile=profile,
185
+ qa=qa,
186
+ display=display,
187
+ config_path=config_path,
188
+ preferences_path=preferences_path,
189
+ git_log_args=git_log_args,
190
+ )
191
+ render_history(
192
+ analyzed,
193
+ console=console,
194
+ ref_id_width=config.ref_id_width,
195
+ type_width=config.type_width,
196
+ show_author=(config.show_author if show_author is None else show_author),
197
+ show_date=config.show_date if show_date is None else show_date,
198
+ show_body=config.show_body if show_body is None else show_body,
199
+ show_id=config.show_id if show_id is None else show_id,
200
+ show_conventional=(
201
+ config.show_conventional if show_conventional is None else show_conventional
202
+ ),
203
+ use_pager=use_pager,
204
+ )
205
+ pairs = [(commit_id, result) for commit_id, result, _subject in analyzed]
206
+ return report_exit_code(build_report(pairs), walk_finished=True)
207
+
208
+
209
+ __all__ = [
210
+ "GitError",
211
+ "analyze_history",
212
+ "run_human",
213
+ "run_json",
214
+ "run_json_stream",
215
+ ]
esys_gitlog/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ """Thin Typer command boundary for ``git-elog`` / ``git elog``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from typer.core import TyperGroup
11
+
12
+ from esys_gitlog import app as application
13
+ from esys_gitlog.config import DisplayMode, QaMode
14
+ from esys_gitlog.detection import detect_links, dump_detection_debug
15
+ from esys_gitlog.git_adapter import GitError, filter_git_log_args
16
+ from esys_gitlog.preferences import (
17
+ fill_link_gaps,
18
+ load_layered_preferences,
19
+ merge_missing_links_into_user_file,
20
+ preferences_to_object,
21
+ )
22
+ from esys_gitlog.tool_paths import user_preferences_path
23
+
24
+ # Subcommands must not be treated as git log revisions.
25
+ _SUBCOMMANDS = frozenset({"preferences"})
26
+
27
+ # elog options that consume the next argv token as a value.
28
+ _OPTIONS_WITH_VALUE = frozenset(
29
+ {
30
+ "--profile",
31
+ "--qa",
32
+ "--display",
33
+ "--config",
34
+ "--preferences",
35
+ }
36
+ )
37
+
38
+
39
+ class _ElogGroup(TyperGroup):
40
+ """Group that only resolves known subcommands; other tokens stay as extra args."""
41
+
42
+ def resolve_command(
43
+ self, ctx: object, args: list[str]
44
+ ) -> tuple[str | None, object | None, list[str]]:
45
+ if args and args[0] in self.commands:
46
+ return super().resolve_command(ctx, args) # type: ignore[arg-type]
47
+ return None, None, args
48
+
49
+ def invoke(self, ctx: object) -> object:
50
+ # Typer asserts cmd is not None when leftovers look like a subcommand.
51
+ # Unknown leftovers are git log args — fold them into ctx.args and run
52
+ # the group callback. Known names (``preferences``) keep normal dispatch.
53
+ protected = list(getattr(ctx, "_protected_args", None) or [])
54
+ extra = list(getattr(ctx, "args", None) or [])
55
+ combined = [*protected, *extra]
56
+ if combined and combined[0] not in self.commands:
57
+ ctx._protected_args = [] # type: ignore[attr-defined]
58
+ ctx.args = combined # type: ignore[attr-defined]
59
+ ctx.invoked_subcommand = None # type: ignore[attr-defined]
60
+ return super().invoke(ctx) # type: ignore[arg-type]
61
+
62
+
63
+ app = typer.Typer(
64
+ cls=_ElogGroup,
65
+ add_completion=False,
66
+ context_settings={
67
+ "help_option_names": ["-h", "--help"],
68
+ "allow_extra_args": True,
69
+ # Unknown tokens (``-n``, ``--since``, …) become ctx.args for git log.
70
+ # Known elog flags still bind first; use ``--`` when a token is both.
71
+ "ignore_unknown_options": True,
72
+ },
73
+ )
74
+ preferences_app = typer.Typer(
75
+ add_completion=False,
76
+ help="Manage layered tool preferences (SADR-0009 / CADR-0012).",
77
+ )
78
+ app.add_typer(preferences_app, name="preferences")
79
+
80
+
81
+ def prepare_argv(argv: list[str]) -> list[str]:
82
+ """Insert ``--`` before git-log positionals so Typer does not treat them as commands.
83
+
84
+ Bare revisions/pathspecs (``HEAD~5``, ``main``) are accepted without an
85
+ explicit separator. Pure integers after short options (e.g. ``-n 1``) are
86
+ left in place so they stay with the preceding flag. Unknown git log options
87
+ (``--since``, ``-n``, …) rely on ``ignore_unknown_options``.
88
+ """
89
+ if "--" in argv:
90
+ return argv
91
+ index = 0
92
+ while index < len(argv):
93
+ token = argv[index]
94
+ if token in _OPTIONS_WITH_VALUE:
95
+ index += 2
96
+ continue
97
+ if token.startswith("--") and "=" in token:
98
+ index += 1
99
+ continue
100
+ if token.startswith("-"):
101
+ index += 1
102
+ continue
103
+ # Likely a value for a preceding short option such as ``-n 1``.
104
+ if token.isdigit():
105
+ index += 1
106
+ continue
107
+ # Positional: subcommand or git log revision/path.
108
+ if token in _SUBCOMMANDS:
109
+ return argv
110
+ return [*argv[:index], "--", *argv[index:]]
111
+ return argv
112
+
113
+
114
+ @app.callback(invoke_without_command=True)
115
+ def cli(
116
+ ctx: typer.Context,
117
+ json_output: bool = typer.Option(
118
+ False,
119
+ "--json",
120
+ help="Write the QA report as one JSON document to standard output.",
121
+ ),
122
+ json_stream: bool = typer.Option(
123
+ False,
124
+ "--json-stream",
125
+ help="Write NDJSON stream events (start/commit/end) to standard output.",
126
+ ),
127
+ profile: str | None = typer.Option(
128
+ None,
129
+ "--profile",
130
+ help="Profile name: default, trailer, or profiles/<name>.json under tool roots.",
131
+ ),
132
+ qa: str | None = typer.Option(
133
+ None,
134
+ "--qa",
135
+ help="QA mode: strict or permissive (overrides preference defaults).",
136
+ ),
137
+ display: str | None = typer.Option(
138
+ None,
139
+ "--display",
140
+ help="Display precedence: subject-precedence or trailer-precedence.",
141
+ ),
142
+ config: Path | None = typer.Option(
143
+ None,
144
+ "--config",
145
+ help="Path to a custom profile JSON object for this run.",
146
+ exists=True,
147
+ dir_okay=False,
148
+ readable=True,
149
+ ),
150
+ preferences: Path | None = typer.Option(
151
+ None,
152
+ "--preferences",
153
+ help="Path to a preference JSON overlay (highest preference layer).",
154
+ exists=True,
155
+ dir_okay=False,
156
+ readable=True,
157
+ ),
158
+ show_author: bool | None = typer.Option(
159
+ None,
160
+ "--author/--no-a",
161
+ "-a",
162
+ help=(
163
+ "Show author on an indented line under each commit (human output). "
164
+ "Overrides defaults.showAuthor when set. Short flags combine (e.g. -abd)."
165
+ ),
166
+ ),
167
+ show_date: bool | None = typer.Option(
168
+ None,
169
+ "--date/--no-d",
170
+ "-d",
171
+ help=(
172
+ "Show authored date on an indented line under each commit (human output). "
173
+ "Overrides defaults.showDate when set. Short flags combine (e.g. -abd)."
174
+ ),
175
+ ),
176
+ show_body: bool | None = typer.Option(
177
+ None,
178
+ "--body/--no-b",
179
+ "-b",
180
+ help=(
181
+ "Show commit body on indented lines under each commit (human output). "
182
+ "Overrides defaults.showBody when set. Short flags combine (e.g. -abd)."
183
+ ),
184
+ ),
185
+ show_id: bool | None = typer.Option(
186
+ None,
187
+ "--id/--no-i",
188
+ "-i",
189
+ help=(
190
+ "Show ticket / Jira reference ids on the summary line (human output). "
191
+ "Overrides defaults.showId when set (default on). Not the commit SHA."
192
+ ),
193
+ ),
194
+ show_conventional: bool | None = typer.Option(
195
+ None,
196
+ "--conventional/--no-c",
197
+ "-c",
198
+ help=(
199
+ "Show Type(Scope)[!] on the summary line (human output). "
200
+ "Overrides defaults.showConventional when set (default on)."
201
+ ),
202
+ ),
203
+ use_pager: bool | None = typer.Option(
204
+ None,
205
+ "--pager/--no-p",
206
+ "-p",
207
+ help=(
208
+ "Page human output one screen at a time (default: on when stdout is "
209
+ "a TTY). Enter/Space = next page, q = quit."
210
+ ),
211
+ ),
212
+ ) -> None:
213
+ """Read Git history with the shared esys-gitlog contract.
214
+
215
+ Extra arguments after ``--`` (and unknown options / bare revisions) are
216
+ forwarded to ``git log``. Pretty/diff format options such as ``--oneline``
217
+ are ignored with a warning; elog owns framing.
218
+ """
219
+ if ctx.invoked_subcommand is not None:
220
+ return
221
+ if qa is not None and qa not in ("strict", "permissive"):
222
+ typer.echo(f"unknown qa mode: {qa}", err=True)
223
+ raise typer.Exit(code=2)
224
+ if display is not None and display not in (
225
+ "subject-precedence",
226
+ "trailer-precedence",
227
+ ):
228
+ typer.echo(f"unknown display mode: {display}", err=True)
229
+ raise typer.Exit(code=2)
230
+
231
+ cwd = Path.cwd()
232
+ qa_mode: QaMode | None = qa # type: ignore[assignment]
233
+ display_mode: DisplayMode | None = display # type: ignore[assignment]
234
+ if json_output and json_stream:
235
+ typer.echo("use only one of --json or --json-stream", err=True)
236
+ raise typer.Exit(code=2)
237
+
238
+ git_log_args, git_log_warnings = filter_git_log_args(list(ctx.args))
239
+ for message in git_log_warnings:
240
+ typer.echo(message, err=True)
241
+
242
+ try:
243
+ if json_stream:
244
+ code = application.run_json_stream(
245
+ sys.stdout,
246
+ cwd=cwd,
247
+ profile=profile,
248
+ qa=qa_mode,
249
+ display=display_mode,
250
+ config_path=config,
251
+ preferences_path=preferences,
252
+ git_log_args=git_log_args,
253
+ )
254
+ elif json_output:
255
+ code = application.run_json(
256
+ sys.stdout,
257
+ cwd=cwd,
258
+ profile=profile,
259
+ qa=qa_mode,
260
+ display=display_mode,
261
+ config_path=config,
262
+ preferences_path=preferences,
263
+ git_log_args=git_log_args,
264
+ )
265
+ else:
266
+ code = application.run_human(
267
+ cwd=cwd,
268
+ profile=profile,
269
+ qa=qa_mode,
270
+ display=display_mode,
271
+ config_path=config,
272
+ preferences_path=preferences,
273
+ show_author=show_author,
274
+ show_date=show_date,
275
+ show_body=show_body,
276
+ show_id=show_id,
277
+ show_conventional=show_conventional,
278
+ use_pager=use_pager,
279
+ git_log_args=git_log_args,
280
+ )
281
+ except GitError as exc:
282
+ typer.echo(str(exc), err=True)
283
+ raise typer.Exit(code=2) from exc
284
+ except ValueError as exc:
285
+ typer.echo(str(exc), err=True)
286
+ raise typer.Exit(code=2) from exc
287
+ raise typer.Exit(code=code)
288
+
289
+
290
+ @preferences_app.command("detect")
291
+ def preferences_detect(
292
+ write: bool = typer.Option(
293
+ True,
294
+ "--write/--no-write",
295
+ help="Merge discovered link bases into the user config.json (missing keys only).",
296
+ ),
297
+ show_debug: bool = typer.Option(
298
+ False,
299
+ "--debug",
300
+ help="Print which vendor CLIs are on PATH.",
301
+ ),
302
+ ) -> None:
303
+ """Probe local Jira/GitHub/GitLab CLIs and git remote; optionally persist."""
304
+ cwd = Path.cwd()
305
+ prefs = load_layered_preferences(cwd)
306
+ discovered = detect_links(cwd, detection=prefs.detection)
307
+ if show_debug:
308
+ typer.echo(dump_detection_debug())
309
+ merged = fill_link_gaps(prefs, discovered)
310
+ typer.echo(json.dumps(preferences_to_object(merged), indent=2))
311
+ if write:
312
+ path = user_preferences_path()
313
+ merge_missing_links_into_user_file(discovered=discovered, user_path=path)
314
+ typer.echo(f"wrote missing keys to {path}", err=True)
315
+
316
+
317
+ def main() -> None:
318
+ """Console script entry point."""
319
+ app(args=prepare_argv(sys.argv[1:]), prog_name="git-elog")
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()