git-commit-message 0.6.0__py3-none-any.whl → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  """Command-line interface entry point.
2
2
 
3
- Collect staged changes from the repository and call an OpenAI GPT model
3
+ Collect staged changes from the repository and call an LLM provider
4
4
  to generate a commit message, or create a commit straight away.
5
5
  """
6
6
 
@@ -19,13 +19,46 @@ from ._git import (
19
19
  get_staged_diff,
20
20
  has_staged_changes,
21
21
  )
22
- from ._gpt import (
22
+ from ._llm import (
23
+ CommitMessageResult,
24
+ UnsupportedProviderError,
23
25
  generate_commit_message,
24
26
  generate_commit_message_with_info,
25
- CommitMessageResult,
26
27
  )
27
28
 
28
29
 
30
+ class CliArgs(Namespace):
31
+ __slots__ = (
32
+ "description",
33
+ "commit",
34
+ "edit",
35
+ "provider",
36
+ "model",
37
+ "language",
38
+ "debug",
39
+ "one_line",
40
+ "max_length",
41
+ "chunk_tokens",
42
+ "host",
43
+ )
44
+
45
+ def __init__(
46
+ self,
47
+ /,
48
+ ) -> None:
49
+ self.description: str | None = None
50
+ self.commit: bool = False
51
+ self.edit: bool = False
52
+ self.provider: str | None = None
53
+ self.model: str | None = None
54
+ self.language: str | None = None
55
+ self.debug: bool = False
56
+ self.one_line: bool = False
57
+ self.max_length: int | None = None
58
+ self.chunk_tokens: int | None = None
59
+ self.host: str | None = None
60
+
61
+
29
62
  def _env_chunk_tokens_default() -> int | None:
30
63
  """Return chunk token default from env if valid, else None."""
31
64
 
@@ -50,7 +83,7 @@ def _build_parser() -> ArgumentParser:
50
83
  parser: ArgumentParser = ArgumentParser(
51
84
  prog="git-commit-message",
52
85
  description=(
53
- "Generate a commit message with OpenAI GPT based on the staged changes."
86
+ "Generate a commit message based on the staged changes."
54
87
  ),
55
88
  )
56
89
 
@@ -72,11 +105,21 @@ def _build_parser() -> ArgumentParser:
72
105
  help="Open an editor to amend the message before committing. Use with '--commit'.",
73
106
  )
74
107
 
108
+ parser.add_argument(
109
+ "--provider",
110
+ default=None,
111
+ help=(
112
+ "LLM provider to use (default: openai). "
113
+ "You may also set GIT_COMMIT_MESSAGE_PROVIDER. "
114
+ "The CLI flag overrides the environment variable."
115
+ ),
116
+ )
117
+
75
118
  parser.add_argument(
76
119
  "--model",
77
120
  default=None,
78
121
  help=(
79
- "OpenAI model name to use. If unspecified, uses the environment variables (GIT_COMMIT_MESSAGE_MODEL, OPENAI_MODEL) or 'gpt-5-mini'."
122
+ "Model name to use. If unspecified, uses GIT_COMMIT_MESSAGE_MODEL or a provider-specific default (openai: gpt-5-mini; google: gemini-2.5-flash; ollama: gpt-oss:20b)."
80
123
  ),
81
124
  )
82
125
 
@@ -123,11 +166,21 @@ def _build_parser() -> ArgumentParser:
123
166
  ),
124
167
  )
125
168
 
169
+ parser.add_argument(
170
+ "--host",
171
+ dest="host",
172
+ default=None,
173
+ help=(
174
+ "Host URL for API providers like Ollama (default: http://localhost:11434). "
175
+ "You may also set OLLAMA_HOST for Ollama."
176
+ ),
177
+ )
178
+
126
179
  return parser
127
180
 
128
181
 
129
182
  def _run(
130
- args: Namespace,
183
+ args: CliArgs,
131
184
  /,
132
185
  ) -> int:
133
186
  """Main execution logic.
@@ -166,10 +219,12 @@ def _run(
166
219
  diff_text,
167
220
  hint,
168
221
  args.model,
169
- getattr(args, "one_line", False),
170
- getattr(args, "max_length", None),
171
- getattr(args, "language", None),
222
+ args.one_line,
223
+ args.max_length,
224
+ args.language,
172
225
  chunk_tokens,
226
+ args.provider,
227
+ args.host,
173
228
  )
174
229
  message = result.message
175
230
  else:
@@ -177,17 +232,22 @@ def _run(
177
232
  diff_text,
178
233
  hint,
179
234
  args.model,
180
- getattr(args, "one_line", False),
181
- getattr(args, "max_length", None),
182
- getattr(args, "language", None),
235
+ args.one_line,
236
+ args.max_length,
237
+ args.language,
183
238
  chunk_tokens,
239
+ args.provider,
240
+ args.host,
184
241
  )
242
+ except UnsupportedProviderError as exc:
243
+ print(str(exc), file=stderr)
244
+ return 3
185
245
  except Exception as exc: # noqa: BLE001 - to preserve standard output messaging
186
246
  print(f"Failed to generate commit message: {exc}", file=stderr)
187
247
  return 3
188
248
 
189
249
  # Option: force single-line message
190
- if getattr(args, "one_line", False):
250
+ if args.one_line:
191
251
  # Use the first non-empty line only
192
252
  for line in (ln.strip() for ln in message.splitlines()):
193
253
  if line:
@@ -199,9 +259,10 @@ def _run(
199
259
  if not args.commit:
200
260
  if args.debug and result is not None:
201
261
  # Print debug information
202
- print("==== OpenAI Usage ====")
262
+ print(f"==== {result.provider} Usage ====")
263
+ print(f"provider: {result.provider}")
203
264
  print(f"model: {result.model}")
204
- print(f"response_id: {getattr(result, 'response_id', '(n/a)')}")
265
+ print(f"response_id: {result.response_id or '(n/a)'}")
205
266
  if result.total_tokens is not None:
206
267
  print(
207
268
  f"tokens: prompt={result.prompt_tokens} completion={result.completion_tokens} total={result.total_tokens}"
@@ -220,9 +281,10 @@ def _run(
220
281
 
221
282
  if args.debug and result is not None:
222
283
  # Also print debug info before commit
223
- print("==== OpenAI Usage ====")
284
+ print(f"==== {result.provider} Usage ====")
285
+ print(f"provider: {result.provider}")
224
286
  print(f"model: {result.model}")
225
- print(f"response_id: {getattr(result, 'response_id', '(n/a)')}")
287
+ print(f"response_id: {result.response_id or '(n/a)'}")
226
288
  if result.total_tokens is not None:
227
289
  print(
228
290
  f"tokens: prompt={result.prompt_tokens} completion={result.completion_tokens} total={result.total_tokens}"
@@ -251,7 +313,8 @@ def main() -> None:
251
313
  """
252
314
 
253
315
  parser: Final[ArgumentParser] = _build_parser()
254
- args: Namespace = parser.parse_args()
316
+ args = CliArgs()
317
+ parser.parse_args(namespace=args)
255
318
 
256
319
  if args.edit and not args.commit:
257
320
  print("'--edit' must be used together with '--commit'.", file=stderr)
@@ -0,0 +1,127 @@
1
+ """Google (Gemini) provider implementation.
2
+
3
+ This module contains only Google GenAI-specific API calls and token counting.
4
+ Provider-agnostic orchestration/prompt logic lives in `_llm.py`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from os import environ
10
+ from typing import ClassVar
11
+
12
+ from google import genai
13
+ from google.genai import types
14
+
15
+ from ._llm import LLMTextResult, LLMUsage
16
+
17
+
18
+ class GoogleGenAIProvider:
19
+ __slots__ = (
20
+ "_client",
21
+ )
22
+
23
+ name: ClassVar[str] = "google"
24
+
25
+ def __init__(
26
+ self,
27
+ /,
28
+ *,
29
+ api_key: str | None = None,
30
+ ) -> None:
31
+ key = api_key or environ.get("GOOGLE_API_KEY")
32
+ if not key:
33
+ raise RuntimeError("The GOOGLE_API_KEY environment variable is required.")
34
+ self._client = genai.Client(api_key=key)
35
+
36
+ def count_tokens(
37
+ self,
38
+ /,
39
+ *,
40
+ model: str,
41
+ text: str,
42
+ ) -> int:
43
+ try:
44
+ resp = self._client.models.count_tokens(
45
+ model=model,
46
+ contents=text,
47
+ )
48
+ except Exception as exc:
49
+ raise RuntimeError(
50
+ "Token counting failed for the Google provider. "
51
+ "Try `--chunk-tokens 0` (default) or `--chunk-tokens -1` to disable summarisation."
52
+ ) from exc
53
+
54
+ total = getattr(resp, "total_tokens", None)
55
+ if not isinstance(total, int):
56
+ raise RuntimeError(
57
+ "Token counting returned an unexpected response from the Google provider. "
58
+ "Try `--chunk-tokens 0` (default) or `--chunk-tokens -1` to disable summarisation."
59
+ )
60
+
61
+ return total
62
+
63
+ def generate_text(
64
+ self,
65
+ /,
66
+ *,
67
+ model: str,
68
+ instructions: str,
69
+ user_text: str,
70
+ ) -> LLMTextResult:
71
+ config = types.GenerateContentConfig(
72
+ system_instruction=instructions,
73
+ )
74
+
75
+ resp = self._client.models.generate_content(
76
+ model=model,
77
+ contents=user_text,
78
+ config=config,
79
+ )
80
+
81
+ text = self._extract_text(resp)
82
+ if not text:
83
+ raise RuntimeError("An empty response text was generated by the provider.")
84
+
85
+ usage = self._extract_usage(resp)
86
+
87
+ return LLMTextResult(
88
+ text=text,
89
+ response_id=getattr(resp, "response_id", None),
90
+ usage=usage,
91
+ )
92
+
93
+ @staticmethod
94
+ def _extract_text(
95
+ resp: types.GenerateContentResponse,
96
+ /,
97
+ ) -> str:
98
+ candidates = getattr(resp, "candidates", None)
99
+ if not candidates:
100
+ return ""
101
+
102
+ parts = getattr(candidates[0].content, "parts", None) if candidates[0].content else None
103
+ if not parts:
104
+ return ""
105
+
106
+ texts: list[str] = []
107
+ for part in parts:
108
+ t = getattr(part, "text", None)
109
+ if isinstance(t, str) and t.strip():
110
+ texts.append(t)
111
+
112
+ return "\n".join(texts).strip()
113
+
114
+ @staticmethod
115
+ def _extract_usage(
116
+ resp: types.GenerateContentResponse,
117
+ /,
118
+ ) -> LLMUsage | None:
119
+ metadata = getattr(resp, "usage_metadata", None)
120
+ if metadata is None:
121
+ return None
122
+
123
+ return LLMUsage(
124
+ prompt_tokens=getattr(metadata, "prompt_token_count", None),
125
+ completion_tokens=getattr(metadata, "candidates_token_count", None),
126
+ total_tokens=getattr(metadata, "total_token_count", None),
127
+ )