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/runner.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Async fixture runner for lgit test mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Self
|
|
9
|
+
|
|
10
|
+
from lgit.api import generate_analysis_with_map_reduce, generate_summary_from_analysis, summary_from_holistic_analysis
|
|
11
|
+
from lgit.errors import ValidationFailure
|
|
12
|
+
from lgit.markdown_output import fallback_summary
|
|
13
|
+
from lgit.models import ConventionalAnalysis, ConventionalCommit
|
|
14
|
+
from lgit.normalization import format_commit_message, post_process_commit_message
|
|
15
|
+
|
|
16
|
+
from .compare import CompareResult, compare_analysis
|
|
17
|
+
from .fixture import Fixture, add_fixture, discover_fixtures, load_fixtures
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class RunResult:
|
|
22
|
+
"""Result of running one fixture."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
comparison: CompareResult | None
|
|
26
|
+
analysis: ConventionalAnalysis
|
|
27
|
+
final_message: str
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class TestSummary:
|
|
33
|
+
"""Aggregate fixture test counts."""
|
|
34
|
+
|
|
35
|
+
total: int = 0
|
|
36
|
+
passed: int = 0
|
|
37
|
+
failed: int = 0
|
|
38
|
+
no_golden: int = 0
|
|
39
|
+
errors: int = 0
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_results(cls, results: list[RunResult]) -> Self:
|
|
43
|
+
summary = cls(total=len(results))
|
|
44
|
+
for result in results:
|
|
45
|
+
if result.error is not None:
|
|
46
|
+
summary.errors += 1
|
|
47
|
+
elif result.comparison is None:
|
|
48
|
+
summary.no_golden += 1
|
|
49
|
+
elif result.comparison.passed:
|
|
50
|
+
summary.passed += 1
|
|
51
|
+
else:
|
|
52
|
+
summary.failed += 1
|
|
53
|
+
return summary
|
|
54
|
+
|
|
55
|
+
def all_passed(self) -> bool:
|
|
56
|
+
return self.failed == 0 and self.errors == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestRunner:
|
|
60
|
+
"""Run lgit fixture tests and update golden files."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, fixtures_dir: str | Path, config: Any) -> None:
|
|
63
|
+
self.fixtures_dir = Path(fixtures_dir)
|
|
64
|
+
self.config = config
|
|
65
|
+
self.filter: str | None = None
|
|
66
|
+
|
|
67
|
+
def with_filter(self, filter: str | None) -> Self:
|
|
68
|
+
self.filter = filter or None
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def fixture_names(self) -> list[str]:
|
|
72
|
+
names = discover_fixtures(self.fixtures_dir)
|
|
73
|
+
if self.filter:
|
|
74
|
+
names = [name for name in names if self.filter in name]
|
|
75
|
+
return names
|
|
76
|
+
|
|
77
|
+
async def run_all(self) -> list[RunResult]:
|
|
78
|
+
results: list[RunResult] = []
|
|
79
|
+
for name in self.fixture_names():
|
|
80
|
+
results.append(await self.run_fixture(name))
|
|
81
|
+
return results
|
|
82
|
+
|
|
83
|
+
async def run_fixture(self, name: str) -> RunResult:
|
|
84
|
+
try:
|
|
85
|
+
return await self._run_fixture_inner(name)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
return RunResult(
|
|
88
|
+
name=name,
|
|
89
|
+
comparison=None,
|
|
90
|
+
analysis=ConventionalAnalysis(commit_type="chore"),
|
|
91
|
+
final_message="",
|
|
92
|
+
error=str(exc),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def _run_fixture_inner(self, name: str) -> RunResult:
|
|
96
|
+
fixture = Fixture.load(self.fixtures_dir, name)
|
|
97
|
+
context = fixture.input.context
|
|
98
|
+
debug_output = _fixture_debug_dir(name)
|
|
99
|
+
|
|
100
|
+
analysis = await generate_analysis_with_map_reduce(
|
|
101
|
+
self.config,
|
|
102
|
+
fixture.input.stat,
|
|
103
|
+
fixture.input.diff,
|
|
104
|
+
fixture.input.scope_candidates,
|
|
105
|
+
user_context=context.user_context,
|
|
106
|
+
recent_commits=context.recent_commits,
|
|
107
|
+
common_scopes=context.common_scopes,
|
|
108
|
+
project_context=context.project_context,
|
|
109
|
+
debug_output=debug_output,
|
|
110
|
+
debug_prefix=None,
|
|
111
|
+
)
|
|
112
|
+
final_message = await self._final_message(fixture, analysis)
|
|
113
|
+
comparison = None if fixture.golden is None else compare_analysis(fixture.golden.analysis, analysis)
|
|
114
|
+
return RunResult(name=name, comparison=comparison, analysis=analysis, final_message=final_message)
|
|
115
|
+
|
|
116
|
+
async def _final_message(self, fixture: Fixture, analysis: ConventionalAnalysis) -> str:
|
|
117
|
+
limit = int(getattr(self.config, "summary_hard_limit", 128))
|
|
118
|
+
details = analysis.body_texts()
|
|
119
|
+
|
|
120
|
+
def build_fallback_summary() -> str:
|
|
121
|
+
return fallback_summary(stat=fixture.input.stat, details=details, limit=limit)
|
|
122
|
+
|
|
123
|
+
if analysis.summary:
|
|
124
|
+
summary = analysis.summary
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
summary = summary_from_holistic_analysis(analysis, self.config, fixture.input.stat)
|
|
128
|
+
except ValidationFailure:
|
|
129
|
+
summary = build_fallback_summary()
|
|
130
|
+
else:
|
|
131
|
+
if summary is None:
|
|
132
|
+
try:
|
|
133
|
+
summary = await generate_summary_from_analysis(
|
|
134
|
+
self.config,
|
|
135
|
+
analysis,
|
|
136
|
+
fixture.input.stat,
|
|
137
|
+
user_context=fixture.input.context.user_context,
|
|
138
|
+
debug_output=_fixture_debug_dir(fixture.name),
|
|
139
|
+
debug_prefix=None,
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
summary = build_fallback_summary()
|
|
143
|
+
summary = summary[:limit].rstrip(" .")
|
|
144
|
+
commit = ConventionalCommit.from_raw(
|
|
145
|
+
commit_type=str(analysis.commit_type),
|
|
146
|
+
scope=None if analysis.scope is None else str(analysis.scope),
|
|
147
|
+
summary=summary,
|
|
148
|
+
body=details,
|
|
149
|
+
footers=(),
|
|
150
|
+
summary_max_length=limit,
|
|
151
|
+
)
|
|
152
|
+
normalized = post_process_commit_message(commit, self.config)
|
|
153
|
+
return format_commit_message(normalized)
|
|
154
|
+
|
|
155
|
+
async def update_all(self) -> list[str]:
|
|
156
|
+
updated: list[str] = []
|
|
157
|
+
for name in self.fixture_names():
|
|
158
|
+
await self.update_fixture(name)
|
|
159
|
+
updated.append(name)
|
|
160
|
+
return updated
|
|
161
|
+
|
|
162
|
+
async def update_fixture(self, name: str) -> None:
|
|
163
|
+
result = await self.run_fixture(name)
|
|
164
|
+
if result.error is not None:
|
|
165
|
+
raise RuntimeError(f"failed to run fixture {name!r}: {result.error}")
|
|
166
|
+
fixture = Fixture.load(self.fixtures_dir, name)
|
|
167
|
+
fixture.update_golden(result.analysis, result.final_message)
|
|
168
|
+
fixture.save(self.fixtures_dir)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def run_test_mode(args: Any, config: Any) -> TestSummary:
|
|
172
|
+
"""CLI entry point for ``--test`` fixture mode."""
|
|
173
|
+
|
|
174
|
+
from . import fixtures_dir as default_fixtures_dir
|
|
175
|
+
from . import list_fixtures as package_list_fixtures
|
|
176
|
+
from .report import generate_html_report
|
|
177
|
+
|
|
178
|
+
root = Path(_arg(args, "fixtures_dir") or default_fixtures_dir())
|
|
179
|
+
|
|
180
|
+
if bool(_arg(args, "test_list", False)):
|
|
181
|
+
names = package_list_fixtures(root)
|
|
182
|
+
if names:
|
|
183
|
+
print(f"Available fixtures ({len(names)}):")
|
|
184
|
+
for name in names:
|
|
185
|
+
print(f" {name}")
|
|
186
|
+
else:
|
|
187
|
+
print(f"No fixtures found in {root}")
|
|
188
|
+
return TestSummary(total=len(names), no_golden=len(names))
|
|
189
|
+
|
|
190
|
+
commit_hash = _arg(args, "test_add")
|
|
191
|
+
if commit_hash:
|
|
192
|
+
name = _arg(args, "test_name")
|
|
193
|
+
if not name:
|
|
194
|
+
raise ValueError("--test-name is required with --test-add")
|
|
195
|
+
print(f"Creating fixture '{name}' from commit {commit_hash}...")
|
|
196
|
+
await add_fixture(root, str(commit_hash), str(name), _arg(args, "dir", "."), config)
|
|
197
|
+
print(f"Created fixture at {root / str(name)}")
|
|
198
|
+
print("Run with --test-update to generate golden files")
|
|
199
|
+
return TestSummary(total=1, no_golden=1)
|
|
200
|
+
|
|
201
|
+
runner = TestRunner(root, config).with_filter(_arg(args, "test_filter"))
|
|
202
|
+
|
|
203
|
+
if bool(_arg(args, "test_update", False)):
|
|
204
|
+
print("Updating golden files...")
|
|
205
|
+
updated = await runner.update_all()
|
|
206
|
+
print(f"Updated {len(updated)} fixtures:")
|
|
207
|
+
for name in updated:
|
|
208
|
+
print(f" {name}")
|
|
209
|
+
return TestSummary(total=len(updated), passed=len(updated))
|
|
210
|
+
|
|
211
|
+
print(f"Running fixture tests from {root}...\n")
|
|
212
|
+
results = await runner.run_all()
|
|
213
|
+
if not results:
|
|
214
|
+
print("No fixtures found.")
|
|
215
|
+
return TestSummary()
|
|
216
|
+
|
|
217
|
+
for result in results:
|
|
218
|
+
if result.error is not None:
|
|
219
|
+
print(f"FAIL {result.name} - ERROR: {result.error}")
|
|
220
|
+
elif result.comparison is None:
|
|
221
|
+
print(f"NO GOLDEN {result.name} - no golden file")
|
|
222
|
+
else:
|
|
223
|
+
status = "PASS" if result.comparison.passed else "FAIL"
|
|
224
|
+
print(f"{status} {result.name} - {result.comparison.summary}")
|
|
225
|
+
|
|
226
|
+
summary = TestSummary.from_results(results)
|
|
227
|
+
print("\n-------------------------------------")
|
|
228
|
+
print(
|
|
229
|
+
f"Total: {summary.total} | Passed: {summary.passed} | Failed: {summary.failed} | No golden: {summary.no_golden} | Errors: {summary.errors}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
report_path = _arg(args, "test_report")
|
|
233
|
+
if report_path:
|
|
234
|
+
fixtures = load_fixtures(root, runner.fixture_names())
|
|
235
|
+
generate_html_report(results, fixtures, report_path)
|
|
236
|
+
print(f"\nHTML report generated: {Path(report_path)}")
|
|
237
|
+
|
|
238
|
+
if not summary.all_passed():
|
|
239
|
+
raise RuntimeError("Some tests failed")
|
|
240
|
+
return summary
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _fixture_debug_dir(name: str) -> Path | None:
|
|
244
|
+
root = os.environ.get("LLM_GIT_TEST_DEBUG_DIR")
|
|
245
|
+
if not root:
|
|
246
|
+
return None
|
|
247
|
+
path = Path(root) / name
|
|
248
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
return path
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _arg(args: Any, name: str, default: Any = None) -> Any:
|
|
253
|
+
return getattr(args, name, default)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
__all__ = ["RunResult", "TestRunner", "TestSummary", "run_test_mode"]
|
lgit/tokens.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Token counting with an async API attempt and character fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class TokenCounter:
|
|
14
|
+
"""Count prompt tokens through an Anthropic-compatible endpoint when possible."""
|
|
15
|
+
|
|
16
|
+
api_base_url: str
|
|
17
|
+
api_key: str | None
|
|
18
|
+
model: str
|
|
19
|
+
timeout: float = 10.0
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def new(cls, api_base_url: str, api_key: str | None, model: str) -> TokenCounter:
|
|
23
|
+
"""Create a token counter from raw API settings."""
|
|
24
|
+
|
|
25
|
+
return cls(api_base_url=api_base_url, api_key=api_key, model=model)
|
|
26
|
+
|
|
27
|
+
async def count(self, text: str) -> int:
|
|
28
|
+
"""Count tokens asynchronously, falling back to a 4-character estimate."""
|
|
29
|
+
|
|
30
|
+
api_count = await self._try_api_count(text)
|
|
31
|
+
return api_count if api_count is not None else self.count_sync(text)
|
|
32
|
+
|
|
33
|
+
def count_sync(self, text: str) -> int:
|
|
34
|
+
"""Return a deterministic local estimate of tokens in ``text``."""
|
|
35
|
+
|
|
36
|
+
return len(text) // 4
|
|
37
|
+
|
|
38
|
+
async def _try_api_count(self, text: str) -> int | None:
|
|
39
|
+
if not self.api_key or _is_openai_base_url(self.api_base_url):
|
|
40
|
+
return None
|
|
41
|
+
url = urljoin(self.api_base_url.rstrip("/") + "/", "messages/count_tokens")
|
|
42
|
+
headers = {
|
|
43
|
+
"x-api-key": self.api_key,
|
|
44
|
+
"anthropic-version": "2023-06-01",
|
|
45
|
+
"content-type": "application/json",
|
|
46
|
+
"authorization": f"Bearer {self.api_key}",
|
|
47
|
+
}
|
|
48
|
+
payload = {"model": self.model, "messages": [{"role": "user", "content": text}]}
|
|
49
|
+
try:
|
|
50
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
51
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
body = response.json()
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
return _extract_token_count(body)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_token_counter(config: object) -> TokenCounter:
|
|
60
|
+
"""Create a ``TokenCounter`` from lgit configuration fields."""
|
|
61
|
+
|
|
62
|
+
return TokenCounter(
|
|
63
|
+
api_base_url=str(getattr(config, "api_base_url", "http://localhost:4000")),
|
|
64
|
+
api_key=getattr(config, "api_key", None),
|
|
65
|
+
model=str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5"))),
|
|
66
|
+
timeout=float(getattr(config, "connect_timeout_secs", 10) or 10),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_openai_base_url(url: str) -> bool:
|
|
71
|
+
lowered = url.lower()
|
|
72
|
+
return "openai.com" in lowered or "api.openai" in lowered
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_token_count(body: Any) -> int | None:
|
|
76
|
+
if not isinstance(body, dict):
|
|
77
|
+
return None
|
|
78
|
+
for key in ("input_tokens", "tokens", "token_count"):
|
|
79
|
+
value = body.get(key)
|
|
80
|
+
if isinstance(value, int) and value >= 0:
|
|
81
|
+
return value
|
|
82
|
+
usage = body.get("usage")
|
|
83
|
+
if isinstance(usage, dict):
|
|
84
|
+
value = usage.get("input_tokens") or usage.get("prompt_tokens")
|
|
85
|
+
if isinstance(value, int) and value >= 0:
|
|
86
|
+
return value
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["TokenCounter", "create_token_counter"]
|