lgit-cli 3.7.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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/testing/fixture.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Fixture manifests, on-disk fixture loading, and golden-file I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
from lgit.models import AnalysisDetail, ChangelogCategory, ConventionalAnalysis
|
|
14
|
+
|
|
15
|
+
FIXTURES_DIR = "tests/fixtures"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FixtureEntry:
|
|
20
|
+
"""Manifest entry for one fixture."""
|
|
21
|
+
|
|
22
|
+
description: str
|
|
23
|
+
tags: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> Self:
|
|
27
|
+
return cls(description=str(data.get("description", "")), tags=_string_list(data.get("tags", [])))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class Manifest:
|
|
32
|
+
"""Fixture manifest loaded from ``manifest.toml``."""
|
|
33
|
+
|
|
34
|
+
fixtures: dict[str, FixtureEntry] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def load(cls, fixtures_dir: str | Path) -> Self:
|
|
38
|
+
path = Path(fixtures_dir) / "manifest.toml"
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return cls()
|
|
41
|
+
with path.open("rb") as handle:
|
|
42
|
+
data = tomllib.load(handle)
|
|
43
|
+
raw = data.get("fixtures", {})
|
|
44
|
+
if not isinstance(raw, Mapping):
|
|
45
|
+
raise ValueError(f"invalid fixture manifest: {path}")
|
|
46
|
+
return cls(
|
|
47
|
+
{
|
|
48
|
+
str(name): FixtureEntry.from_mapping(entry if isinstance(entry, Mapping) else {})
|
|
49
|
+
for name, entry in raw.items()
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def save(self, fixtures_dir: str | Path) -> None:
|
|
54
|
+
path = Path(fixtures_dir) / "manifest.toml"
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
lines: list[str] = []
|
|
57
|
+
for name in sorted(self.fixtures):
|
|
58
|
+
entry = self.fixtures[name]
|
|
59
|
+
lines.append(f"[fixtures.{name}]")
|
|
60
|
+
lines.append(f"description = {_toml_value(entry.description)}")
|
|
61
|
+
lines.append(f"tags = {_toml_value(entry.tags)}")
|
|
62
|
+
lines.append("")
|
|
63
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
def add(self, name: str, entry: FixtureEntry) -> None:
|
|
66
|
+
self.fixtures[name] = entry
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class FixtureMeta:
|
|
71
|
+
"""Metadata captured with a fixture."""
|
|
72
|
+
|
|
73
|
+
source_repo: str
|
|
74
|
+
source_commit: str
|
|
75
|
+
description: str
|
|
76
|
+
captured_at: str
|
|
77
|
+
tags: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> Self:
|
|
81
|
+
return cls(
|
|
82
|
+
source_repo=str(data.get("source_repo", "")),
|
|
83
|
+
source_commit=str(data.get("source_commit", "")),
|
|
84
|
+
description=str(data.get("description", "")),
|
|
85
|
+
captured_at=str(data.get("captured_at", "")),
|
|
86
|
+
tags=_string_list(data.get("tags", [])),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class FixtureContext:
|
|
92
|
+
"""Analysis context captured for deterministic fixture runs."""
|
|
93
|
+
|
|
94
|
+
recent_commits: str | None = None
|
|
95
|
+
common_scopes: str | None = None
|
|
96
|
+
project_context: str | None = None
|
|
97
|
+
user_context: str | None = None
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> Self:
|
|
101
|
+
return cls(
|
|
102
|
+
recent_commits=_optional_str(data.get("recent_commits")),
|
|
103
|
+
common_scopes=_optional_str(data.get("common_scopes")),
|
|
104
|
+
project_context=_optional_str(data.get("project_context")),
|
|
105
|
+
user_context=_optional_str(data.get("user_context")),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def to_mapping(self) -> dict[str, str]:
|
|
109
|
+
return {
|
|
110
|
+
key: value
|
|
111
|
+
for key, value in {
|
|
112
|
+
"recent_commits": self.recent_commits,
|
|
113
|
+
"common_scopes": self.common_scopes,
|
|
114
|
+
"project_context": self.project_context,
|
|
115
|
+
"user_context": self.user_context,
|
|
116
|
+
}.items()
|
|
117
|
+
if value is not None
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True)
|
|
122
|
+
class FixtureInput:
|
|
123
|
+
"""Frozen inputs used by the analysis harness."""
|
|
124
|
+
|
|
125
|
+
diff: str
|
|
126
|
+
stat: str
|
|
127
|
+
scope_candidates: str = ""
|
|
128
|
+
context: FixtureContext = field(default_factory=FixtureContext)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(slots=True)
|
|
132
|
+
class Golden:
|
|
133
|
+
"""Expected fixture output."""
|
|
134
|
+
|
|
135
|
+
analysis: ConventionalAnalysis
|
|
136
|
+
final_message: str = ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(slots=True)
|
|
140
|
+
class Fixture:
|
|
141
|
+
"""A complete fixture loaded from or saved to disk."""
|
|
142
|
+
|
|
143
|
+
name: str
|
|
144
|
+
meta: FixtureMeta
|
|
145
|
+
input: FixtureInput
|
|
146
|
+
golden: Golden | None = None
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def load(cls, fixtures_dir: str | Path, name: str) -> Self:
|
|
150
|
+
fixture_dir = Path(fixtures_dir) / name
|
|
151
|
+
if not fixture_dir.exists():
|
|
152
|
+
raise FileNotFoundError(f"fixture {name!r} not found at {fixture_dir}")
|
|
153
|
+
|
|
154
|
+
meta_path = fixture_dir / "meta.toml"
|
|
155
|
+
if not meta_path.exists():
|
|
156
|
+
raise FileNotFoundError(f"fixture {name!r} missing meta.toml")
|
|
157
|
+
meta = FixtureMeta.from_mapping(_read_toml(meta_path))
|
|
158
|
+
|
|
159
|
+
input_dir = fixture_dir / "input"
|
|
160
|
+
diff = _read_text(input_dir / "diff.patch")
|
|
161
|
+
stat = _read_text(input_dir / "stat.txt")
|
|
162
|
+
scope_candidates = _read_text(input_dir / "scope_candidates.txt", missing_ok=True)
|
|
163
|
+
context_path = input_dir / "context.toml"
|
|
164
|
+
context = FixtureContext.from_mapping(_read_toml(context_path)) if context_path.exists() else FixtureContext()
|
|
165
|
+
|
|
166
|
+
golden = None
|
|
167
|
+
golden_dir = fixture_dir / "golden"
|
|
168
|
+
analysis_path = golden_dir / "analysis.json"
|
|
169
|
+
if analysis_path.exists():
|
|
170
|
+
analysis = analysis_from_json(analysis_path.read_text(encoding="utf-8"))
|
|
171
|
+
final_message = _read_text(golden_dir / "final.txt", missing_ok=True)
|
|
172
|
+
golden = Golden(analysis=analysis, final_message=final_message)
|
|
173
|
+
|
|
174
|
+
return cls(name=name, meta=meta, input=FixtureInput(diff, stat, scope_candidates, context), golden=golden)
|
|
175
|
+
|
|
176
|
+
def save(self, fixtures_dir: str | Path) -> None:
|
|
177
|
+
fixture_dir = Path(fixtures_dir) / self.name
|
|
178
|
+
input_dir = fixture_dir / "input"
|
|
179
|
+
golden_dir = fixture_dir / "golden"
|
|
180
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
_write_toml(
|
|
183
|
+
fixture_dir / "meta.toml",
|
|
184
|
+
{
|
|
185
|
+
"source_repo": self.meta.source_repo,
|
|
186
|
+
"source_commit": self.meta.source_commit,
|
|
187
|
+
"description": self.meta.description,
|
|
188
|
+
"captured_at": self.meta.captured_at,
|
|
189
|
+
"tags": self.meta.tags,
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
(input_dir / "diff.patch").write_text(self.input.diff, encoding="utf-8")
|
|
193
|
+
(input_dir / "stat.txt").write_text(self.input.stat, encoding="utf-8")
|
|
194
|
+
(input_dir / "scope_candidates.txt").write_text(self.input.scope_candidates, encoding="utf-8")
|
|
195
|
+
_write_toml(input_dir / "context.toml", self.input.context.to_mapping())
|
|
196
|
+
|
|
197
|
+
if self.golden is not None:
|
|
198
|
+
golden_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
(golden_dir / "analysis.json").write_text(analysis_to_json(self.golden.analysis), encoding="utf-8")
|
|
200
|
+
(golden_dir / "final.txt").write_text(self.golden.final_message, encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
def update_golden(self, analysis: ConventionalAnalysis, final_message: str) -> None:
|
|
203
|
+
self.golden = Golden(analysis=analysis, final_message=final_message)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def add_fixture(
|
|
207
|
+
fixtures_dir: str | Path, commit_hash: str, name: str, repo_dir: str | Path = ".", config: Any | None = None
|
|
208
|
+
) -> Fixture:
|
|
209
|
+
"""Create a fixture from a commit and add it to the manifest."""
|
|
210
|
+
|
|
211
|
+
from lgit.analysis import extract_scope_candidates
|
|
212
|
+
from lgit.git import extract_style_patterns, get_common_scopes, get_git_diff, get_git_stat, get_recent_commits
|
|
213
|
+
from lgit.repo import RepoMetadata
|
|
214
|
+
|
|
215
|
+
diff = get_git_diff("commit", commit_hash, repo_dir, config)
|
|
216
|
+
stat = get_git_stat("commit", commit_hash, repo_dir, config)
|
|
217
|
+
scope_candidates, _ = extract_scope_candidates("commit", commit_hash, str(repo_dir), config)
|
|
218
|
+
|
|
219
|
+
recent_commits = None
|
|
220
|
+
common_scopes = None
|
|
221
|
+
try:
|
|
222
|
+
commits = get_recent_commits(repo_dir, 20)
|
|
223
|
+
patterns = extract_style_patterns(commits)
|
|
224
|
+
recent_commits = None if patterns is None else patterns.format_for_prompt()
|
|
225
|
+
except Exception:
|
|
226
|
+
recent_commits = None
|
|
227
|
+
try:
|
|
228
|
+
scopes = get_common_scopes(repo_dir, 100)
|
|
229
|
+
common_scopes = ", ".join(f"{scope} ({count})" for scope, count in scopes[:10]) or None
|
|
230
|
+
except Exception:
|
|
231
|
+
common_scopes = None
|
|
232
|
+
project_context = RepoMetadata.detect(repo_dir).format_for_prompt()
|
|
233
|
+
|
|
234
|
+
fixture = Fixture(
|
|
235
|
+
name=name,
|
|
236
|
+
meta=FixtureMeta(
|
|
237
|
+
source_repo=str(repo_dir),
|
|
238
|
+
source_commit=commit_hash,
|
|
239
|
+
description=f"Fixture from commit {commit_hash}",
|
|
240
|
+
captured_at=datetime.now(UTC).isoformat(),
|
|
241
|
+
tags=[],
|
|
242
|
+
),
|
|
243
|
+
input=FixtureInput(
|
|
244
|
+
diff=diff,
|
|
245
|
+
stat=stat,
|
|
246
|
+
scope_candidates=scope_candidates,
|
|
247
|
+
context=FixtureContext(
|
|
248
|
+
recent_commits=recent_commits, common_scopes=common_scopes, project_context=project_context
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
root = Path(fixtures_dir)
|
|
253
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
fixture.save(root)
|
|
255
|
+
manifest = Manifest.load(root)
|
|
256
|
+
manifest.add(name, FixtureEntry(description=f"From commit {commit_hash}", tags=[]))
|
|
257
|
+
manifest.save(root)
|
|
258
|
+
return fixture
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def discover_fixtures(fixtures_dir: str | Path) -> list[str]:
|
|
262
|
+
"""Return sorted fixture directory names under ``fixtures_dir``."""
|
|
263
|
+
|
|
264
|
+
root = Path(fixtures_dir)
|
|
265
|
+
if not root.exists():
|
|
266
|
+
return []
|
|
267
|
+
return sorted(path.name for path in root.iterdir() if path.is_dir() and (path / "meta.toml").exists())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_fixtures(fixtures_dir: str | Path, names: list[str] | None = None) -> list[Fixture]:
|
|
271
|
+
selected = discover_fixtures(fixtures_dir) if names is None else names
|
|
272
|
+
return [Fixture.load(fixtures_dir, name) for name in selected]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def analysis_from_json(content: str) -> ConventionalAnalysis:
|
|
276
|
+
data = json.loads(content)
|
|
277
|
+
if not isinstance(data, Mapping):
|
|
278
|
+
raise ValueError("analysis JSON must be an object")
|
|
279
|
+
details = tuple(_detail_from_json(item) for item in (data.get("details") or ()))
|
|
280
|
+
issue_refs = tuple(str(item) for item in (data.get("issue_refs") or ()))
|
|
281
|
+
return ConventionalAnalysis(
|
|
282
|
+
commit_type=str(data.get("type", data.get("commit_type", "chore"))),
|
|
283
|
+
scope=_optional_str(data.get("scope")),
|
|
284
|
+
summary=_optional_str(data.get("summary")),
|
|
285
|
+
details=details,
|
|
286
|
+
issue_refs=issue_refs,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def analysis_to_json(analysis: ConventionalAnalysis) -> str:
|
|
291
|
+
return json.dumps(analysis_to_mapping(analysis), indent=2, ensure_ascii=False) + "\n"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def analysis_to_mapping(analysis: ConventionalAnalysis) -> dict[str, Any]:
|
|
295
|
+
data: dict[str, Any] = {"type": str(analysis.commit_type)}
|
|
296
|
+
if analysis.scope is not None:
|
|
297
|
+
data["scope"] = str(analysis.scope)
|
|
298
|
+
if analysis.summary:
|
|
299
|
+
data["summary"] = analysis.summary
|
|
300
|
+
data["details"] = [_detail_to_json(detail) for detail in analysis.details]
|
|
301
|
+
data["issue_refs"] = list(analysis.issue_refs)
|
|
302
|
+
return data
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _detail_from_json(item: Any) -> AnalysisDetail:
|
|
306
|
+
if isinstance(item, Mapping):
|
|
307
|
+
raw_category = item.get("changelog_category")
|
|
308
|
+
category = ChangelogCategory.from_name(str(raw_category)) if raw_category not in (None, "") else None
|
|
309
|
+
return AnalysisDetail(
|
|
310
|
+
text=str(item.get("text", "")),
|
|
311
|
+
changelog_category=category,
|
|
312
|
+
user_visible=bool(item.get("user_visible", False)),
|
|
313
|
+
)
|
|
314
|
+
return AnalysisDetail.simple(str(item))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _detail_to_json(detail: AnalysisDetail) -> dict[str, Any]:
|
|
318
|
+
data: dict[str, Any] = {"text": detail.text}
|
|
319
|
+
if detail.changelog_category is not None:
|
|
320
|
+
data["changelog_category"] = detail.changelog_category.value
|
|
321
|
+
data["user_visible"] = bool(detail.user_visible)
|
|
322
|
+
return data
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
326
|
+
with path.open("rb") as handle:
|
|
327
|
+
data = tomllib.load(handle)
|
|
328
|
+
return dict(data)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _read_text(path: Path, *, missing_ok: bool = False) -> str:
|
|
332
|
+
if missing_ok and not path.exists():
|
|
333
|
+
return ""
|
|
334
|
+
return path.read_text(encoding="utf-8")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _write_toml(path: Path, data: Mapping[str, Any]) -> None:
|
|
338
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
lines = [f"{key} = {_toml_value(value)}" for key, value in data.items() if value is not None]
|
|
340
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _toml_value(value: Any) -> str:
|
|
344
|
+
if isinstance(value, bool):
|
|
345
|
+
return "true" if value else "false"
|
|
346
|
+
if isinstance(value, int | float):
|
|
347
|
+
return str(value)
|
|
348
|
+
if isinstance(value, list | tuple):
|
|
349
|
+
return "[" + ", ".join(_toml_value(item) for item in value) + "]"
|
|
350
|
+
text = str(value)
|
|
351
|
+
if "\n" in text:
|
|
352
|
+
return '"""' + text.replace('"""', '\\"\\"\\"') + '"""'
|
|
353
|
+
return json.dumps(text, ensure_ascii=False)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _optional_str(value: Any) -> str | None:
|
|
357
|
+
if value is None:
|
|
358
|
+
return None
|
|
359
|
+
text = str(value)
|
|
360
|
+
return text if text else None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _string_list(value: Any) -> list[str]:
|
|
364
|
+
if value is None:
|
|
365
|
+
return []
|
|
366
|
+
if isinstance(value, str):
|
|
367
|
+
return [value]
|
|
368
|
+
return [str(item) for item in value]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
__all__ = [
|
|
372
|
+
"FIXTURES_DIR",
|
|
373
|
+
"Fixture",
|
|
374
|
+
"FixtureContext",
|
|
375
|
+
"FixtureEntry",
|
|
376
|
+
"FixtureInput",
|
|
377
|
+
"FixtureMeta",
|
|
378
|
+
"Golden",
|
|
379
|
+
"Manifest",
|
|
380
|
+
"add_fixture",
|
|
381
|
+
"analysis_from_json",
|
|
382
|
+
"analysis_to_json",
|
|
383
|
+
"analysis_to_mapping",
|
|
384
|
+
"discover_fixtures",
|
|
385
|
+
"load_fixtures",
|
|
386
|
+
]
|
lgit/testing/report.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Dark HTML report generation for fixture test results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from html import escape
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .fixture import Fixture
|
|
11
|
+
from .runner import RunResult, TestSummary
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_html_report(results: Sequence[RunResult], fixtures: Sequence[Fixture], output_path: str | Path) -> Path:
|
|
15
|
+
"""Write a dark HTML fixture report and return the output path."""
|
|
16
|
+
|
|
17
|
+
path = Path(output_path)
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
path.write_text(render_report(results, fixtures, TestSummary.from_results(list(results))), encoding="utf-8")
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_report(results: Sequence[RunResult], fixtures: Sequence[Fixture], summary: TestSummary | None = None) -> str:
|
|
24
|
+
summary = TestSummary.from_results(list(results)) if summary is None else summary
|
|
25
|
+
fixture_by_name = {fixture.name: fixture for fixture in fixtures}
|
|
26
|
+
rows = "\n".join(_render_fixture_result(result, fixture_by_name.get(result.name)) for result in results)
|
|
27
|
+
generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
28
|
+
return f"""<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="UTF-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
33
|
+
<title>lgit Fixture Test Report</title>
|
|
34
|
+
<style>
|
|
35
|
+
:root {{
|
|
36
|
+
--bg: #0d1117;
|
|
37
|
+
--fg: #c9d1d9;
|
|
38
|
+
--muted: #8b949e;
|
|
39
|
+
--border: #30363d;
|
|
40
|
+
--card: #161b22;
|
|
41
|
+
--green: #3fb950;
|
|
42
|
+
--red: #f85149;
|
|
43
|
+
--yellow: #d29922;
|
|
44
|
+
--blue: #58a6ff;
|
|
45
|
+
--purple: #a371f7;
|
|
46
|
+
}}
|
|
47
|
+
* {{ box-sizing: border-box; }}
|
|
48
|
+
body {{ margin: 0; padding: 2rem; background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
|
|
49
|
+
.container {{ max-width: 1400px; margin: 0 auto; }}
|
|
50
|
+
h1 {{ margin: 0 0 .5rem; font-size: 1.8rem; }}
|
|
51
|
+
.timestamp {{ color: var(--muted); margin: 0 0 1.5rem; }}
|
|
52
|
+
.summary {{ display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; }}
|
|
53
|
+
.stat {{ min-width: 120px; padding: 1rem 1.25rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; }}
|
|
54
|
+
.stat-value {{ font-size: 2rem; font-weight: 700; }}
|
|
55
|
+
.stat-label {{ color: var(--muted); font-size: .85rem; }}
|
|
56
|
+
.passed .stat-value, .match {{ color: var(--green); }}
|
|
57
|
+
.failed .stat-value, .mismatch, .error-color {{ color: var(--red); }}
|
|
58
|
+
.no-golden .stat-value, .warn {{ color: var(--yellow); }}
|
|
59
|
+
.fixture {{ margin-bottom: 1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }}
|
|
60
|
+
.fixture-header {{ display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1rem 1.25rem; cursor: pointer; border-bottom: 1px solid var(--border); }}
|
|
61
|
+
.fixture-header:hover {{ background: rgba(255, 255, 255, .035); }}
|
|
62
|
+
.fixture-name {{ font-weight: 650; word-break: break-word; }}
|
|
63
|
+
.badge {{ white-space: nowrap; padding: .25rem .7rem; border-radius: 999px; font-size: .8rem; font-weight: 600; }}
|
|
64
|
+
.badge.passed {{ color: var(--green); background: rgba(63, 185, 80, .13); }}
|
|
65
|
+
.badge.failed, .badge.error {{ color: var(--red); background: rgba(248, 81, 73, .13); }}
|
|
66
|
+
.badge.no-golden {{ color: var(--yellow); background: rgba(210, 153, 34, .13); }}
|
|
67
|
+
.fixture-content {{ display: none; padding: 1.25rem; }}
|
|
68
|
+
.fixture.expanded .fixture-content {{ display: block; }}
|
|
69
|
+
.diff-row {{ display: flex; gap: 1rem; margin-bottom: .45rem; }}
|
|
70
|
+
.diff-label {{ width: 6rem; min-width: 6rem; color: var(--muted); font-weight: 600; }}
|
|
71
|
+
.comparison {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1.25rem; }}
|
|
72
|
+
@media (max-width: 900px) {{ .comparison {{ grid-template-columns: 1fr; }} }}
|
|
73
|
+
h3 {{ margin: 0 0 .5rem; text-transform: uppercase; letter-spacing: .05em; font-size: .78rem; }}
|
|
74
|
+
h3.golden {{ color: var(--purple); }}
|
|
75
|
+
h3.actual {{ color: var(--blue); }}
|
|
76
|
+
.message-box, .error-message {{ white-space: pre-wrap; word-break: break-word; padding: 1rem; border-radius: 8px; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
|
|
77
|
+
.message-box {{ background: var(--bg); border: 1px solid var(--border); }}
|
|
78
|
+
.error-message {{ color: var(--red); background: rgba(248, 81, 73, .08); border: 1px solid rgba(248, 81, 73, .5); }}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<div class="container">
|
|
83
|
+
<h1>lgit Fixture Test Report</h1>
|
|
84
|
+
<p class="timestamp">Generated: {escape(generated)}</p>
|
|
85
|
+
<div class="summary">
|
|
86
|
+
{_stat("Total", summary.total)}
|
|
87
|
+
{_stat("Passed", summary.passed, "passed")}
|
|
88
|
+
{_stat("Failed", summary.failed, "failed")}
|
|
89
|
+
{_stat("No Golden", summary.no_golden, "no-golden")}
|
|
90
|
+
{_stat("Errors", summary.errors, "failed")}
|
|
91
|
+
</div>
|
|
92
|
+
{rows}
|
|
93
|
+
</div>
|
|
94
|
+
<script>
|
|
95
|
+
document.querySelectorAll('.fixture-header').forEach((header) => {{
|
|
96
|
+
header.addEventListener('click', () => header.parentElement.classList.toggle('expanded'));
|
|
97
|
+
}});
|
|
98
|
+
document.querySelectorAll('.fixture.failed, .fixture.error').forEach((fixture) => fixture.classList.add('expanded'));
|
|
99
|
+
</script>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stat(label: str, value: int, css: str = "") -> str:
|
|
106
|
+
return f'<div class="stat {css}"><div class="stat-value">{value}</div><div class="stat-label">{escape(label)}</div></div>'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_fixture_result(result: RunResult, fixture: Fixture | None) -> str:
|
|
110
|
+
status_class, status_text = _status(result)
|
|
111
|
+
body = _render_error(result.error) if result.error else _render_success_body(result, fixture)
|
|
112
|
+
return f"""
|
|
113
|
+
<section class="fixture {status_class}">
|
|
114
|
+
<div class="fixture-header">
|
|
115
|
+
<span class="fixture-name">{escape(result.name)}</span>
|
|
116
|
+
<span class="badge {status_class}">{status_text}</span>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="fixture-content">{body}</div>
|
|
119
|
+
</section>"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_success_body(result: RunResult, fixture: Fixture | None) -> str:
|
|
123
|
+
if result.comparison is None:
|
|
124
|
+
return _render_actual_only(result)
|
|
125
|
+
cmp = result.comparison
|
|
126
|
+
golden = fixture.golden if fixture is not None else None
|
|
127
|
+
type_row = ""
|
|
128
|
+
golden_message = ""
|
|
129
|
+
if golden is not None:
|
|
130
|
+
type_row = _diff_row(
|
|
131
|
+
"Type",
|
|
132
|
+
f"{escape(str(golden.analysis.commit_type))} → {escape(str(result.analysis.commit_type))}",
|
|
133
|
+
"match" if cmp.type_match else "mismatch",
|
|
134
|
+
)
|
|
135
|
+
golden_message = f"""
|
|
136
|
+
<div>
|
|
137
|
+
<h3 class="golden">Golden (Expected)</h3>
|
|
138
|
+
<div class="message-box">{escape(golden.final_message or _analysis_summary(golden.analysis))}</div>
|
|
139
|
+
</div>"""
|
|
140
|
+
scope_value = escape(cmp.scope_diff or _scope_text(result.analysis, none="(none)"))
|
|
141
|
+
details_value = f"{cmp.golden_detail_count} golden → {cmp.actual_detail_count} actual"
|
|
142
|
+
return f"""
|
|
143
|
+
<div>
|
|
144
|
+
{type_row}
|
|
145
|
+
{_diff_row("Scope", scope_value, "match" if cmp.scope_match else "mismatch")}
|
|
146
|
+
{_diff_row("Details", details_value)}
|
|
147
|
+
</div>
|
|
148
|
+
<div class="comparison">
|
|
149
|
+
{golden_message}
|
|
150
|
+
<div>
|
|
151
|
+
<h3 class="actual">Actual (Current)</h3>
|
|
152
|
+
<div class="message-box">{escape(result.final_message)}</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>"""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _render_actual_only(result: RunResult) -> str:
|
|
158
|
+
return f"""
|
|
159
|
+
<div>
|
|
160
|
+
{_diff_row("Type", escape(str(result.analysis.commit_type)))}
|
|
161
|
+
{_diff_row("Scope", escape(_scope_text(result.analysis, none="(none)")))}
|
|
162
|
+
{_diff_row("Details", f"{len(result.analysis.details)} points")}
|
|
163
|
+
<h3 class="actual" style="margin-top: 1rem;">Generated Message</h3>
|
|
164
|
+
<div class="message-box">{escape(result.final_message)}</div>
|
|
165
|
+
</div>"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_error(error: str | None) -> str:
|
|
169
|
+
return f'<div class="error-message">{escape(error or "")}</div>'
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _diff_row(label: str, value: str, css: str = "") -> str:
|
|
173
|
+
return f'<div class="diff-row"><span class="diff-label">{escape(label)}:</span><span class="{css}">{value}</span></div>'
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _status(result: RunResult) -> tuple[str, str]:
|
|
177
|
+
if result.error is not None:
|
|
178
|
+
return "error", "Error"
|
|
179
|
+
if result.comparison is None:
|
|
180
|
+
return "no-golden", "No Golden"
|
|
181
|
+
return ("passed", "Passed") if result.comparison.passed else ("failed", "Failed")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _scope_text(analysis: object, *, none: str = "null") -> str:
|
|
185
|
+
scope = getattr(analysis, "scope", None)
|
|
186
|
+
return none if scope is None else str(scope)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _analysis_summary(analysis: object) -> str:
|
|
190
|
+
commit_type = str(getattr(analysis, "commit_type", ""))
|
|
191
|
+
scope = getattr(analysis, "scope", None)
|
|
192
|
+
summary = getattr(analysis, "summary", None)
|
|
193
|
+
details = getattr(analysis, "details", ())
|
|
194
|
+
header = commit_type + (f"({scope})" if scope else "")
|
|
195
|
+
if summary:
|
|
196
|
+
header += f": {summary}"
|
|
197
|
+
detail_lines = "\n".join(f"- {getattr(detail, 'text', detail)}" for detail in details)
|
|
198
|
+
return header + ("\n\n" + detail_lines if detail_lines else "")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["generate_html_report", "render_report"]
|