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.
Files changed (54) hide show
  1. lgit/__init__.py +75 -0
  2. lgit/__main__.py +8 -0
  3. lgit/analysis.py +326 -0
  4. lgit/api.py +1077 -0
  5. lgit/cache.py +338 -0
  6. lgit/changelog.py +523 -0
  7. lgit/cli.py +1104 -0
  8. lgit/compose.py +2110 -0
  9. lgit/config.py +437 -0
  10. lgit/diffing.py +384 -0
  11. lgit/errors.py +137 -0
  12. lgit/git.py +852 -0
  13. lgit/map_reduce.py +508 -0
  14. lgit/markdown_output.py +709 -0
  15. lgit/models.py +924 -0
  16. lgit/normalization.py +411 -0
  17. lgit/patch.py +784 -0
  18. lgit/profile.py +426 -0
  19. lgit/py.typed +0 -0
  20. lgit/repo.py +287 -0
  21. lgit/resources/__init__.py +1 -0
  22. lgit/resources/commit_types.json +242 -0
  23. lgit/resources/prompts/analysis/default.md +237 -0
  24. lgit/resources/prompts/analysis/markdown.md +112 -0
  25. lgit/resources/prompts/changelog/default.md +89 -0
  26. lgit/resources/prompts/changelog/markdown.md +60 -0
  27. lgit/resources/prompts/compose-bind/default.md +40 -0
  28. lgit/resources/prompts/compose-bind/markdown.md +41 -0
  29. lgit/resources/prompts/compose-intent/default.md +63 -0
  30. lgit/resources/prompts/compose-intent/markdown.md +59 -0
  31. lgit/resources/prompts/fast/default.md +46 -0
  32. lgit/resources/prompts/fast/markdown.md +51 -0
  33. lgit/resources/prompts/map/default.md +67 -0
  34. lgit/resources/prompts/map/markdown.md +63 -0
  35. lgit/resources/prompts/reduce/default.md +81 -0
  36. lgit/resources/prompts/reduce/markdown.md +68 -0
  37. lgit/resources/prompts/summary/default.md +74 -0
  38. lgit/resources/prompts/summary/markdown.md +77 -0
  39. lgit/resources/validation_data.json +1 -0
  40. lgit/rewrite.py +392 -0
  41. lgit/style.py +295 -0
  42. lgit/templates.py +385 -0
  43. lgit/testing/__init__.py +62 -0
  44. lgit/testing/compare.py +57 -0
  45. lgit/testing/fixture.py +386 -0
  46. lgit/testing/report.py +201 -0
  47. lgit/testing/runner.py +256 -0
  48. lgit/tokens.py +90 -0
  49. lgit/validation.py +545 -0
  50. lgit_cli-3.7.0.dist-info/METADATA +288 -0
  51. lgit_cli-3.7.0.dist-info/RECORD +54 -0
  52. lgit_cli-3.7.0.dist-info/WHEEL +4 -0
  53. lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
  54. 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"]