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/config.py ADDED
@@ -0,0 +1,437 @@
1
+ """Configuration loading for the llm-git Python runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import tomllib
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Self
12
+
13
+ from .errors import ConfigError
14
+ from .models import (
15
+ ApiMode,
16
+ CategoryConfig,
17
+ CategoryMatch,
18
+ ResolvedApiMode,
19
+ TypeConfig,
20
+ default_categories,
21
+ default_classifier_hint,
22
+ default_types,
23
+ )
24
+
25
+ DEFAULT_API_BASE_URL = "http://localhost:4000"
26
+ DEFAULT_ANALYSIS_MODEL = "claude-opus-4.5"
27
+ DEFAULT_SUMMARY_MODEL = "claude-haiku-4-5"
28
+ DEFAULT_CONFIG_SUBPATH = Path(".config/llm-git/config.toml")
29
+
30
+ DEFAULT_EXCLUDED_FILES = [
31
+ "Cargo.lock",
32
+ "package-lock.json",
33
+ "npm-shrinkwrap.json",
34
+ "yarn.lock",
35
+ "pnpm-lock.yaml",
36
+ "shrinkwrap.yaml",
37
+ "bun.lock",
38
+ "bun.lockb",
39
+ "deno.lock",
40
+ "composer.lock",
41
+ "Gemfile.lock",
42
+ "poetry.lock",
43
+ "Pipfile.lock",
44
+ "pdm.lock",
45
+ "uv.lock",
46
+ "go.sum",
47
+ "flake.lock",
48
+ "pubspec.lock",
49
+ "Podfile.lock",
50
+ "Packages.resolved",
51
+ "mix.lock",
52
+ "packages.lock.json",
53
+ "gradle.lockfile",
54
+ ]
55
+
56
+ DEFAULT_LOW_PRIORITY_EXTENSIONS = [
57
+ ".lock",
58
+ ".snap",
59
+ ".sum",
60
+ ".toml",
61
+ ".yaml",
62
+ ".yml",
63
+ ".json",
64
+ ".md",
65
+ ".txt",
66
+ ".log",
67
+ ".tmp",
68
+ ".bak",
69
+ ]
70
+
71
+ _TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
72
+ _FALSE_VALUES = frozenset({"0", "false", "no", "off"})
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class CommitConfig:
77
+ """Runtime configuration loaded from defaults, TOML, and environment."""
78
+
79
+ api_base_url: str = DEFAULT_API_BASE_URL
80
+ api_mode: ApiMode = ApiMode.AUTO
81
+ api_key: str | None = None
82
+ request_timeout_secs: int = 120
83
+ connect_timeout_secs: int = 30
84
+ disable_git_background_features: bool = True
85
+ compose_max_rounds: int = 5
86
+ summary_guideline: int = 72
87
+ summary_soft_limit: int = 96
88
+ summary_hard_limit: int = 128
89
+ max_retries: int = 3
90
+ initial_backoff_ms: int = 1000
91
+ auto_fast_threshold_lines: int = 200
92
+ max_diff_length: int = 100000
93
+ max_diff_tokens: int = 25000
94
+ wide_change_threshold: float = 0.50
95
+ analysis_model: str = DEFAULT_ANALYSIS_MODEL
96
+ summary_model: str = DEFAULT_SUMMARY_MODEL
97
+ legacy_model: str | None = None
98
+ excluded_files: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDED_FILES))
99
+ low_priority_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_LOW_PRIORITY_EXTENSIONS))
100
+ max_detail_tokens: int = 200
101
+ analysis_prompt_variant: str = "default"
102
+ summary_prompt_variant: str = "default"
103
+ wide_change_abstract: bool = True
104
+ markdown_output: bool = True
105
+ exclude_old_message: bool = True
106
+ gpg_sign: bool = False
107
+ signoff: bool = False
108
+ types: dict[str, TypeConfig] = field(default_factory=default_types)
109
+ classifier_hint: str = field(default_factory=default_classifier_hint)
110
+ categories: list[CategoryConfig] = field(default_factory=default_categories)
111
+ changelog_enabled: bool = True
112
+ map_reduce_enabled: bool = True
113
+ map_reduce_threshold: int = 5000
114
+ map_batch_token_budget: int = 16000
115
+ cache_enabled: bool = True
116
+ cache_ttl_days: int = 14
117
+ cache_dir: str | None = None
118
+ analysis_prompt: str = ""
119
+ summary_prompt: str = ""
120
+
121
+ @classmethod
122
+ def load(cls, path: str | os.PathLike[str] | None = None) -> Self:
123
+ """Load configuration from the default path or ``LLM_GIT_CONFIG``."""
124
+ config_path = _selected_config_path(path)
125
+ if config_path is not None and config_path.exists():
126
+ return cls.from_file(config_path)
127
+ config = cls()
128
+ config._finalize()
129
+ return config
130
+
131
+ @classmethod
132
+ def from_file(cls, path: str | os.PathLike[str]) -> Self:
133
+ """Load configuration from a TOML file, then apply environment overrides."""
134
+ config_path = Path(path).expanduser()
135
+ try:
136
+ contents = config_path.read_text(encoding="utf-8")
137
+ except OSError as exc:
138
+ raise ConfigError(f"Failed to read config {config_path}: {exc}") from exc
139
+ try:
140
+ data = tomllib.loads(contents)
141
+ except tomllib.TOMLDecodeError as exc:
142
+ raise ConfigError(f"Failed to parse config {config_path}: {exc}") from exc
143
+ config = cls.from_mapping(data)
144
+ config._finalize()
145
+ return config
146
+
147
+ @classmethod
148
+ def from_mapping(cls, data: Mapping[str, Any]) -> Self:
149
+ """Build configuration from a TOML-compatible mapping."""
150
+ kwargs: dict[str, Any] = {}
151
+ for raw_key, value in data.items():
152
+ key = _normalize_config_key(str(raw_key))
153
+ if key == "model":
154
+ kwargs["legacy_model"] = None if value is None else str(value)
155
+ elif key == "api_mode":
156
+ kwargs[key] = ApiMode.from_raw(str(value))
157
+ elif key == "types":
158
+ kwargs[key] = _parse_types(value)
159
+ elif key == "categories":
160
+ kwargs[key] = _parse_categories(value)
161
+ elif key in _FIELD_COERCERS:
162
+ kwargs[key] = _FIELD_COERCERS[key](value)
163
+ return cls(**kwargs)
164
+
165
+ @property
166
+ def resolved_api_mode(self) -> ResolvedApiMode:
167
+ """Return the concrete API protocol selected for this configuration."""
168
+ return ResolvedApiMode.from_api_mode(self.api_mode, self.api_base_url)
169
+
170
+ def resolve_api_mode(self, model_name: str | None = None) -> ResolvedApiMode:
171
+ """Return the concrete API protocol; ``model_name`` is accepted for compatibility."""
172
+ return self.resolved_api_mode
173
+
174
+ def _finalize(self) -> None:
175
+ _apply_env_overrides(self)
176
+ self._normalize_models()
177
+ if self.api_key is not None:
178
+ self.api_key = _resolve_config_value(self.api_key)
179
+ self._load_prompts()
180
+
181
+ def _normalize_models(self) -> None:
182
+ if self.legacy_model:
183
+ model = self.legacy_model
184
+ self.analysis_model = model
185
+ if self.summary_model == DEFAULT_SUMMARY_MODEL:
186
+ self.summary_model = model
187
+
188
+ def _load_prompts(self) -> None:
189
+ from .templates import ensure_prompts_dir
190
+
191
+ ensure_prompts_dir()
192
+ self.analysis_prompt = ""
193
+ self.summary_prompt = ""
194
+
195
+
196
+ def default_config_path() -> Path:
197
+ """Return the default llm-git TOML config path for the current user."""
198
+ home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
199
+ if not home:
200
+ raise ConfigError("No home directory found (tried HOME and USERPROFILE)")
201
+ return Path(home).joinpath(DEFAULT_CONFIG_SUBPATH)
202
+
203
+
204
+ def _selected_config_path(path: str | os.PathLike[str] | None) -> Path | None:
205
+ if path is not None:
206
+ return Path(path).expanduser()
207
+ if custom_path := os.environ.get("LLM_GIT_CONFIG"):
208
+ return Path(custom_path).expanduser()
209
+ try:
210
+ return default_config_path()
211
+ except ConfigError:
212
+ return None
213
+
214
+
215
+ def _apply_env_overrides(config: CommitConfig) -> None:
216
+ if "LLM_GIT_API_URL" in os.environ:
217
+ config.api_base_url = os.environ["LLM_GIT_API_URL"]
218
+ if "LLM_GIT_API_KEY" in os.environ:
219
+ config.api_key = os.environ["LLM_GIT_API_KEY"]
220
+ if "LLM_GIT_API_MODE" in os.environ:
221
+ config.api_mode = _parse_api_mode(os.environ["LLM_GIT_API_MODE"])
222
+ if value := os.environ.get("LLM_GIT_DISABLE_GIT_BACKGROUND_FEATURES"):
223
+ parsed = _parse_env_bool(value)
224
+ if parsed is not None:
225
+ config.disable_git_background_features = parsed
226
+ if value := os.environ.get("LLM_GIT_CACHE_DISABLED"):
227
+ parsed = _parse_env_bool(value)
228
+ if parsed is not None:
229
+ config.cache_enabled = not parsed
230
+ if value := os.environ.get("LLM_GIT_CACHE_TTL_DAYS"):
231
+ try:
232
+ config.cache_ttl_days = int(value.strip())
233
+ except ValueError:
234
+ pass
235
+ if "LLM_GIT_CACHE_DIR" in os.environ:
236
+ value = os.environ["LLM_GIT_CACHE_DIR"].strip()
237
+ config.cache_dir = value or None
238
+
239
+
240
+ def _parse_env_bool(value: str) -> bool | None:
241
+ normalized = value.strip().lower()
242
+ if normalized in _TRUE_VALUES:
243
+ return True
244
+ if normalized in _FALSE_VALUES:
245
+ return False
246
+ return None
247
+
248
+
249
+ def _parse_api_mode(value: str) -> ApiMode:
250
+ match value.strip().lower().replace("_", "-"):
251
+ case "chat" | "chat-completions":
252
+ return ApiMode.CHAT_COMPLETIONS
253
+ case "anthropic" | "messages" | "anthropic-messages":
254
+ return ApiMode.ANTHROPIC_MESSAGES
255
+ case _:
256
+ return ApiMode.AUTO
257
+
258
+
259
+ def _resolve_config_value(raw: str) -> str:
260
+ value = raw.strip()
261
+ if not value.startswith("!"):
262
+ return raw
263
+ command = value[1:].strip()
264
+ if not command:
265
+ raise ConfigError("api_key command is empty")
266
+ if command == "cat" or command.startswith("cat "):
267
+ path_text = command[3:].strip()
268
+ if not path_text:
269
+ raise ConfigError("api_key `!cat` command requires a path")
270
+ path = Path(path_text).expanduser()
271
+ try:
272
+ return path.read_text(encoding="utf-8").strip()
273
+ except OSError as exc:
274
+ raise ConfigError(f"api_key `!cat` failed to read {path}: {exc}") from exc
275
+ try:
276
+ output = subprocess.run(
277
+ ["/bin/sh", "-c", command],
278
+ check=False,
279
+ capture_output=True,
280
+ stdin=subprocess.DEVNULL,
281
+ text=True,
282
+ )
283
+ except OSError as exc:
284
+ raise ConfigError(f"api_key `!{command}` failed to spawn: {exc}") from exc
285
+ if output.returncode != 0:
286
+ stderr = output.stderr.strip()
287
+ raise ConfigError(f"api_key `!{command}` exited with status {output.returncode}: {stderr}")
288
+ return output.stdout.strip()
289
+
290
+
291
+ def _normalize_config_key(key: str) -> str:
292
+ return key.strip().replace("-", "_")
293
+
294
+
295
+ def _to_bool(value: Any) -> bool:
296
+ if isinstance(value, bool):
297
+ return value
298
+ if isinstance(value, str):
299
+ parsed = _parse_env_bool(value)
300
+ if parsed is not None:
301
+ return parsed
302
+ return bool(value)
303
+
304
+
305
+ def _to_int(value: Any) -> int:
306
+ return int(value)
307
+
308
+
309
+ def _to_float(value: Any) -> float:
310
+ return float(value)
311
+
312
+
313
+ def _to_str(value: Any) -> str:
314
+ return str(value)
315
+
316
+
317
+ def _to_optional_str(value: Any) -> str | None:
318
+ if value is None:
319
+ return None
320
+ text = str(value).strip()
321
+ return text or None
322
+
323
+
324
+ def _to_str_list(value: Any) -> list[str]:
325
+ if value is None:
326
+ return []
327
+ if isinstance(value, str):
328
+ return [value]
329
+ return [str(item) for item in value]
330
+
331
+
332
+ def _to_str_tuple(value: Any) -> tuple[str, ...]:
333
+ return tuple(_to_str_list(value))
334
+
335
+
336
+ def _parse_types(value: Any) -> dict[str, TypeConfig]:
337
+ if value is None:
338
+ return {}
339
+ if isinstance(value, Mapping):
340
+ return {str(name).strip().lower(): _parse_type_config(config) for name, config in value.items()}
341
+ types: dict[str, TypeConfig] = {}
342
+ for item in value:
343
+ if not isinstance(item, Mapping) or "name" not in item:
344
+ raise ConfigError("types entries must be tables with a name")
345
+ name = str(item["name"]).strip().lower()
346
+ types[name] = _parse_type_config(item)
347
+ return types
348
+
349
+
350
+ def _parse_type_config(value: Any) -> TypeConfig:
351
+ if isinstance(value, str):
352
+ return TypeConfig(description=value)
353
+ if not isinstance(value, Mapping):
354
+ raise ConfigError("type config entries must be tables or strings")
355
+ return TypeConfig(
356
+ description=str(value.get("description", "")),
357
+ diff_indicators=_to_str_tuple(value.get("diff_indicators", ())),
358
+ file_patterns=_to_str_tuple(value.get("file_patterns", ())),
359
+ examples=_to_str_tuple(value.get("examples", ())),
360
+ hint=str(value.get("hint", "")),
361
+ aliases=_to_str_tuple(value.get("aliases", ())),
362
+ )
363
+
364
+
365
+ def _parse_categories(value: Any) -> list[CategoryConfig]:
366
+ if value is None:
367
+ return []
368
+ return [_parse_category_config(item) for item in value]
369
+
370
+
371
+ def _parse_category_config(value: Any) -> CategoryConfig:
372
+ if isinstance(value, str):
373
+ return CategoryConfig(name=value)
374
+ if not isinstance(value, Mapping):
375
+ raise ConfigError("category entries must be tables or strings")
376
+ match_data = value.get("match", {})
377
+ if not isinstance(match_data, Mapping):
378
+ raise ConfigError("category match entries must be tables")
379
+ return CategoryConfig(
380
+ name=str(value.get("name", "")),
381
+ header=_to_optional_str(value.get("header")),
382
+ match=CategoryMatch(
383
+ types=_to_str_tuple(match_data.get("types", ())),
384
+ body_contains=_to_str_tuple(match_data.get("body_contains", ())),
385
+ ),
386
+ default=_to_bool(value.get("default", False)),
387
+ )
388
+
389
+
390
+ _FIELD_COERCERS = {
391
+ "api_base_url": _to_str,
392
+ "api_key": _to_optional_str,
393
+ "request_timeout_secs": _to_int,
394
+ "connect_timeout_secs": _to_int,
395
+ "disable_git_background_features": _to_bool,
396
+ "compose_max_rounds": _to_int,
397
+ "summary_guideline": _to_int,
398
+ "summary_soft_limit": _to_int,
399
+ "summary_hard_limit": _to_int,
400
+ "max_retries": _to_int,
401
+ "initial_backoff_ms": _to_int,
402
+ "auto_fast_threshold_lines": _to_int,
403
+ "max_diff_length": _to_int,
404
+ "max_diff_tokens": _to_int,
405
+ "wide_change_threshold": _to_float,
406
+ "analysis_model": _to_str,
407
+ "summary_model": _to_str,
408
+ "legacy_model": _to_optional_str,
409
+ "excluded_files": _to_str_list,
410
+ "low_priority_extensions": _to_str_list,
411
+ "max_detail_tokens": _to_int,
412
+ "analysis_prompt_variant": _to_str,
413
+ "summary_prompt_variant": _to_str,
414
+ "wide_change_abstract": _to_bool,
415
+ "markdown_output": _to_bool,
416
+ "exclude_old_message": _to_bool,
417
+ "gpg_sign": _to_bool,
418
+ "signoff": _to_bool,
419
+ "classifier_hint": _to_str,
420
+ "changelog_enabled": _to_bool,
421
+ "map_reduce_enabled": _to_bool,
422
+ "map_reduce_threshold": _to_int,
423
+ "map_batch_token_budget": _to_int,
424
+ "cache_enabled": _to_bool,
425
+ "cache_ttl_days": _to_int,
426
+ "cache_dir": _to_optional_str,
427
+ }
428
+
429
+ __all__ = [
430
+ "CommitConfig",
431
+ "DEFAULT_API_BASE_URL",
432
+ "DEFAULT_ANALYSIS_MODEL",
433
+ "DEFAULT_SUMMARY_MODEL",
434
+ "DEFAULT_EXCLUDED_FILES",
435
+ "DEFAULT_LOW_PRIORITY_EXTENSIONS",
436
+ "default_config_path",
437
+ ]