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/profile.py ADDED
@@ -0,0 +1,426 @@
1
+ """JSONL profiling, trace, progress, and timing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from types import TracebackType
15
+ from typing import Any, Self
16
+
17
+ TARGET = "lgit"
18
+
19
+ _TRACE_LOCK = threading.Lock()
20
+ _TRACE: TraceGuard | None = None
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class TraceGuard:
25
+ """Own an open JSONL trace file and flush events on close."""
26
+
27
+ path: Path
28
+ _file: Any
29
+ _closed: bool = False
30
+
31
+ def close(self) -> None:
32
+ """Flush and close the trace file."""
33
+
34
+ global _TRACE
35
+ if self._closed:
36
+ return
37
+ _write_event("trace_stopped", path=str(self.path), pid=os.getpid())
38
+ with _TRACE_LOCK:
39
+ self._file.flush()
40
+ self._file.close()
41
+ self._closed = True
42
+ if _TRACE is self:
43
+ _TRACE = None
44
+
45
+ def __enter__(self) -> Self:
46
+ """Return this active guard."""
47
+
48
+ return self
49
+
50
+ def __exit__(
51
+ self,
52
+ exc_type: type[BaseException] | None,
53
+ exc: BaseException | None,
54
+ tb: TracebackType | None,
55
+ ) -> None:
56
+ """Close the trace file when leaving a context."""
57
+
58
+ self.close()
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class TimingPhase:
63
+ """One named phase duration in a finalized timing report."""
64
+
65
+ phase: str
66
+ duration_ms: float
67
+ share_pct: float = 0.0
68
+
69
+ def to_dict(self) -> dict[str, float | str]:
70
+ """Return the Rust-compatible JSON shape for this phase."""
71
+
72
+ return {"phase": self.phase, "duration_ms": self.duration_ms, "share_pct": self.share_pct}
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class TimingReport:
77
+ """Final timing report matching the Rust ``TimingReport`` JSON shape."""
78
+
79
+ total_ms: float
80
+ phases: list[TimingPhase]
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Return the Rust-compatible JSON shape for this report."""
84
+
85
+ return {"total_ms": self.total_ms, "phases": [phase.to_dict() for phase in self.phases]}
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class TimingCollector:
90
+ """Collect named phase timings and finalize share percentages at the end."""
91
+
92
+ enabled: bool = True
93
+ _start: float = field(default_factory=time.perf_counter)
94
+ phases: list[TimingPhase] = field(default_factory=list)
95
+
96
+ def record(self, phase: str, seconds: float) -> None:
97
+ """Record one phase duration, preserving Rust rounding and trace events."""
98
+
99
+ record_timing(self, phase, seconds)
100
+
101
+ def finalize(self, total_seconds: float | None = None) -> TimingReport:
102
+ """Build a timing report, using elapsed collector lifetime by default."""
103
+
104
+ return finalize_timings(self, total_seconds)
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class ProfileSection:
109
+ """Synchronous and asynchronous context manager for a traced section."""
110
+
111
+ name: str
112
+ collector: TimingCollector | None = None
113
+ _start: float = 0.0
114
+
115
+ def __enter__(self) -> Self:
116
+ """Enter a synchronous profiling section."""
117
+
118
+ self._start = time.perf_counter()
119
+ _write_event("section_started", section=self.name)
120
+ return self
121
+
122
+ def __exit__(
123
+ self,
124
+ exc_type: type[BaseException] | None,
125
+ exc: BaseException | None,
126
+ tb: TracebackType | None,
127
+ ) -> None:
128
+ """Leave a synchronous profiling section and record elapsed time."""
129
+
130
+ self._finish(exc)
131
+
132
+ async def __aenter__(self) -> Self:
133
+ """Enter an asynchronous profiling section."""
134
+
135
+ self._start = time.perf_counter()
136
+ _write_event("section_started", section=self.name)
137
+ return self
138
+
139
+ async def __aexit__(
140
+ self,
141
+ exc_type: type[BaseException] | None,
142
+ exc: BaseException | None,
143
+ tb: TracebackType | None,
144
+ ) -> None:
145
+ """Leave an asynchronous profiling section and record elapsed time."""
146
+
147
+ self._finish(exc)
148
+
149
+ def _finish(self, exc: BaseException | None) -> None:
150
+ seconds = time.perf_counter() - self._start if self._start else 0.0
151
+ fields: dict[str, Any] = {
152
+ "section": self.name,
153
+ "elapsed_ms": seconds * 1000.0,
154
+ "elapsed_us": _duration_us(seconds),
155
+ }
156
+ if exc is not None:
157
+ fields["error"] = f"{type(exc).__name__}: {exc}"
158
+ _write_event("section_finished", **fields)
159
+ if self.collector is not None:
160
+ record_timing(self.collector, self.name, seconds)
161
+
162
+
163
+ def env_flag_value_enabled(value: str | None) -> bool:
164
+ """Return Rust-compatible truthiness for LLM_GIT_* boolean env values."""
165
+
166
+ if value is None:
167
+ return False
168
+ return value.strip().lower() not in {"", "0", "false", "no", "off"}
169
+
170
+
171
+ def env_flag_enabled(name: str) -> bool:
172
+ """Return true when environment variable ``name`` is set to an enabled value."""
173
+
174
+ return env_flag_value_enabled(os.environ.get(name))
175
+
176
+
177
+ def trace_enabled() -> bool:
178
+ """Return true when ``LLM_GIT_TRACE`` enables API trace logging."""
179
+
180
+ return env_flag_enabled("LLM_GIT_TRACE")
181
+
182
+
183
+ def progress_enabled() -> bool:
184
+ """Return true when LLM progress lines should be printed."""
185
+
186
+ return env_flag_enabled("LLM_GIT_PROGRESS") or trace_enabled()
187
+
188
+
189
+ def trace_file_path(args: Any | None = None) -> Path | None:
190
+ """Resolve CLI/env JSONL trace output path, preferring CLI args."""
191
+
192
+ if args is not None:
193
+ trace_output = getattr(args, "trace_output", None)
194
+ if trace_output is not None:
195
+ return Path(trace_output)
196
+ env_path = os.environ.get("LLM_GIT_TRACE_FILE")
197
+ return Path(env_path) if env_path is not None and env_path != "" else None
198
+
199
+
200
+ def timings_enabled(args: Any | None = None) -> bool:
201
+ """Return true when phase timing collection should be enabled."""
202
+
203
+ if args is not None and (
204
+ getattr(args, "debug_output", None) is not None or getattr(args, "trace_output", None) is not None
205
+ ):
206
+ return True
207
+ return trace_file_path() is not None or "LLM_GIT_TRACE" in os.environ
208
+
209
+
210
+ def init_file_tracing(path: str | os.PathLike[str]) -> TraceGuard:
211
+ """Initialize process-wide JSONL profiling to ``path``."""
212
+
213
+ global _TRACE
214
+ trace_path = Path(path)
215
+ if trace_path.parent and str(trace_path.parent) != ".":
216
+ trace_path.parent.mkdir(parents=True, exist_ok=True)
217
+ file = trace_path.open("a", encoding="utf-8", buffering=1)
218
+ guard = TraceGuard(path=trace_path, _file=file)
219
+ with _TRACE_LOCK:
220
+ _TRACE = guard
221
+ _write_event("trace_started", path=str(trace_path), pid=os.getpid())
222
+ return guard
223
+
224
+
225
+ def enabled() -> bool:
226
+ """Return true when file tracing is currently active."""
227
+
228
+ return _TRACE is not None and not _TRACE._closed
229
+
230
+
231
+ def section(name: str, collector: TimingCollector | None = None) -> ProfileSection:
232
+ """Create a profiling context manager for a logical section."""
233
+
234
+ return ProfileSection(name, collector)
235
+
236
+
237
+ def create_timing_collector(enabled: bool = True) -> TimingCollector:
238
+ """Create a phase-timing collector."""
239
+
240
+ return TimingCollector(enabled=enabled)
241
+
242
+
243
+ def record_timing(collector: TimingCollector | None, phase: str, seconds: float) -> None:
244
+ """Record a named phase duration and emit the Rust-compatible trace event."""
245
+
246
+ _write_event(
247
+ "timing_recorded",
248
+ section=phase,
249
+ elapsed_ms=seconds * 1000.0,
250
+ elapsed_us=_duration_us(seconds),
251
+ )
252
+ if collector is not None and collector.enabled:
253
+ collector.phases.append(TimingPhase(phase=phase, duration_ms=round_ms(seconds), share_pct=0.0))
254
+
255
+
256
+ def finalize_timings(
257
+ collector: TimingCollector | list[TimingPhase], total_seconds: float | None = None
258
+ ) -> TimingReport:
259
+ """Finalize collected timings by computing total milliseconds and shares."""
260
+
261
+ if isinstance(collector, TimingCollector):
262
+ phases = collector.phases
263
+ elapsed = time.perf_counter() - collector._start if total_seconds is None else total_seconds
264
+ else:
265
+ phases = collector
266
+ elapsed = 0.0 if total_seconds is None else total_seconds
267
+
268
+ total_ms = round_ms(elapsed)
269
+ finalized: list[TimingPhase] = []
270
+ for phase in phases:
271
+ share_pct = _round_one_decimal((phase.duration_ms / total_ms) * 100.0) if total_ms > 0.0 else 0.0
272
+ finalized.append(TimingPhase(phase=phase.phase, duration_ms=phase.duration_ms, share_pct=share_pct))
273
+ return TimingReport(total_ms=total_ms, phases=finalized)
274
+
275
+
276
+ def format_timing_report(timings: TimingCollector | TimingReport) -> str:
277
+ """Format the human ``[TIMING]`` report printed when ``LLM_GIT_TRACE`` is set."""
278
+
279
+ report = _coerce_report(timings)
280
+ lines = [f"[TIMING] total={report.total_ms:.1f}ms"]
281
+ lines.extend(
282
+ f"[TIMING] {phase.phase:>28} {phase.duration_ms:>8.1f}ms {phase.share_pct:>5.1f}%" for phase in report.phases
283
+ )
284
+ return "\n".join(lines)
285
+
286
+
287
+ def write_timings_json(path: str | os.PathLike[str], timings: TimingCollector | TimingReport) -> Path:
288
+ """Write a pretty ``timings.json`` debug artifact and return its path."""
289
+
290
+ output_path = _timings_json_path(path)
291
+ if output_path.parent and str(output_path.parent) != ".":
292
+ output_path.parent.mkdir(parents=True, exist_ok=True)
293
+ output_path.write_text(json.dumps(_coerce_report(timings).to_dict(), indent=2), encoding="utf-8")
294
+ return output_path
295
+
296
+
297
+ def emit_timing_report(args: Any, timings: TimingCollector | TimingReport) -> TimingReport:
298
+ """Write/debug-log/print a finalized timing report using Rust CLI rules."""
299
+
300
+ report = _coerce_report(timings)
301
+ debug_output = getattr(args, "debug_output", None)
302
+ if debug_output is not None:
303
+ write_timings_json(debug_output, report)
304
+
305
+ _write_event("timing_report_finished", total_ms=report.total_ms, phase_count=len(report.phases))
306
+
307
+ if "LLM_GIT_TRACE" in os.environ:
308
+ print(format_timing_report(report), file=sys.stderr)
309
+ return report
310
+
311
+
312
+ def print_llm_progress(message: str | Callable[[], str]) -> None:
313
+ """Print an LLM progress line when ``LLM_GIT_PROGRESS`` or trace is enabled."""
314
+
315
+ if not progress_enabled():
316
+ return
317
+ text = message() if callable(message) else message
318
+ try:
319
+ from . import style
320
+
321
+ style.print_info(text)
322
+ except Exception:
323
+ print(text, file=sys.stderr)
324
+
325
+
326
+ def print_trace(message: str) -> None:
327
+ """Print a low-level ``[TRACE]`` line when ``LLM_GIT_TRACE`` is enabled."""
328
+
329
+ if not trace_enabled():
330
+ return
331
+ if _stdout_is_status_stream():
332
+ print("\r\x1b[K", end="", file=sys.stdout, flush=True)
333
+ print(f"[TRACE] {message}", file=sys.stderr)
334
+
335
+
336
+ def trace_event(event: str, *, level: str = "INFO", **fields: Any) -> None:
337
+ """Emit one JSONL trace event if file tracing is active."""
338
+
339
+ _write_event(event, level=level, **fields)
340
+
341
+
342
+ def _coerce_report(timings: TimingCollector | TimingReport) -> TimingReport:
343
+ if isinstance(timings, TimingReport):
344
+ return timings
345
+ return timings.finalize()
346
+
347
+
348
+ def _timings_json_path(path: str | os.PathLike[str]) -> Path:
349
+ output_path = Path(path)
350
+ if output_path.exists() and output_path.is_dir():
351
+ return output_path / "timings.json"
352
+ if output_path.name != "timings.json" and output_path.suffix == "":
353
+ return output_path / "timings.json"
354
+ return output_path
355
+
356
+
357
+ def _round_one_decimal(value: float) -> float:
358
+ return math.floor(value * 10.0 + 0.5) / 10.0
359
+
360
+
361
+ def round_ms(seconds: float) -> float:
362
+ """Round seconds to milliseconds with Rust's one-decimal rounding rule."""
363
+
364
+ return _round_one_decimal(seconds * 1000.0)
365
+
366
+
367
+ def _duration_us(seconds: float) -> int:
368
+ return max(0, min(int(seconds * 1_000_000), (1 << 64) - 1))
369
+
370
+
371
+ def _stdout_is_status_stream() -> bool:
372
+ try:
373
+ from . import style
374
+
375
+ return not style.pipe_mode()
376
+ except Exception:
377
+ return sys.stdout.isatty()
378
+
379
+
380
+ def _write_event(event: str, *, level: str = "INFO", **fields: Any) -> None:
381
+ guard = _TRACE
382
+ if guard is None or guard._closed:
383
+ return
384
+ record = {
385
+ "ts": time.time(),
386
+ "target": TARGET,
387
+ "level": level,
388
+ "event": event,
389
+ **fields,
390
+ }
391
+ try:
392
+ line = json.dumps(record, sort_keys=True, separators=(",", ":"), default=str)
393
+ with _TRACE_LOCK:
394
+ if not guard._closed:
395
+ guard._file.write(line + "\n")
396
+ except Exception:
397
+ return
398
+
399
+
400
+ __all__ = [
401
+ "TARGET",
402
+ "ProfileSection",
403
+ "TimingCollector",
404
+ "TimingPhase",
405
+ "TimingReport",
406
+ "TraceGuard",
407
+ "create_timing_collector",
408
+ "emit_timing_report",
409
+ "enabled",
410
+ "env_flag_enabled",
411
+ "env_flag_value_enabled",
412
+ "finalize_timings",
413
+ "format_timing_report",
414
+ "init_file_tracing",
415
+ "print_llm_progress",
416
+ "print_trace",
417
+ "progress_enabled",
418
+ "record_timing",
419
+ "round_ms",
420
+ "section",
421
+ "timings_enabled",
422
+ "trace_enabled",
423
+ "trace_event",
424
+ "trace_file_path",
425
+ "write_timings_json",
426
+ ]
lgit/py.typed ADDED
File without changes