gac 3.10.3__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.

Potentially problematic release.


This version of gac might be problematic. Click here for more details.

Files changed (67) hide show
  1. gac/__init__.py +15 -0
  2. gac/__version__.py +3 -0
  3. gac/ai.py +109 -0
  4. gac/ai_utils.py +246 -0
  5. gac/auth_cli.py +214 -0
  6. gac/cli.py +218 -0
  7. gac/commit_executor.py +62 -0
  8. gac/config.py +125 -0
  9. gac/config_cli.py +95 -0
  10. gac/constants.py +328 -0
  11. gac/diff_cli.py +159 -0
  12. gac/errors.py +231 -0
  13. gac/git.py +372 -0
  14. gac/git_state_validator.py +184 -0
  15. gac/grouped_commit_workflow.py +423 -0
  16. gac/init_cli.py +70 -0
  17. gac/interactive_mode.py +182 -0
  18. gac/language_cli.py +377 -0
  19. gac/main.py +476 -0
  20. gac/model_cli.py +430 -0
  21. gac/oauth/__init__.py +27 -0
  22. gac/oauth/claude_code.py +464 -0
  23. gac/oauth/qwen_oauth.py +327 -0
  24. gac/oauth/token_store.py +81 -0
  25. gac/preprocess.py +511 -0
  26. gac/prompt.py +878 -0
  27. gac/prompt_builder.py +88 -0
  28. gac/providers/README.md +437 -0
  29. gac/providers/__init__.py +80 -0
  30. gac/providers/anthropic.py +17 -0
  31. gac/providers/azure_openai.py +57 -0
  32. gac/providers/base.py +329 -0
  33. gac/providers/cerebras.py +15 -0
  34. gac/providers/chutes.py +25 -0
  35. gac/providers/claude_code.py +79 -0
  36. gac/providers/custom_anthropic.py +103 -0
  37. gac/providers/custom_openai.py +44 -0
  38. gac/providers/deepseek.py +15 -0
  39. gac/providers/error_handler.py +139 -0
  40. gac/providers/fireworks.py +15 -0
  41. gac/providers/gemini.py +90 -0
  42. gac/providers/groq.py +15 -0
  43. gac/providers/kimi_coding.py +27 -0
  44. gac/providers/lmstudio.py +80 -0
  45. gac/providers/minimax.py +15 -0
  46. gac/providers/mistral.py +15 -0
  47. gac/providers/moonshot.py +15 -0
  48. gac/providers/ollama.py +73 -0
  49. gac/providers/openai.py +32 -0
  50. gac/providers/openrouter.py +21 -0
  51. gac/providers/protocol.py +71 -0
  52. gac/providers/qwen.py +64 -0
  53. gac/providers/registry.py +58 -0
  54. gac/providers/replicate.py +156 -0
  55. gac/providers/streamlake.py +31 -0
  56. gac/providers/synthetic.py +40 -0
  57. gac/providers/together.py +15 -0
  58. gac/providers/zai.py +31 -0
  59. gac/py.typed +0 -0
  60. gac/security.py +293 -0
  61. gac/utils.py +401 -0
  62. gac/workflow_utils.py +217 -0
  63. gac-3.10.3.dist-info/METADATA +283 -0
  64. gac-3.10.3.dist-info/RECORD +67 -0
  65. gac-3.10.3.dist-info/WHEEL +4 -0
  66. gac-3.10.3.dist-info/entry_points.txt +2 -0
  67. gac-3.10.3.dist-info/licenses/LICENSE +16 -0
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Interactive mode handling for gac."""
3
+
4
+ import logging
5
+ import re
6
+
7
+ from rich.console import Console
8
+
9
+ from gac.ai import generate_commit_message
10
+ from gac.config import GACConfig
11
+ from gac.git_state_validator import GitState
12
+ from gac.workflow_utils import (
13
+ collect_interactive_answers,
14
+ format_answers_for_prompt,
15
+ handle_confirmation_loop,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+ console = Console()
20
+
21
+
22
+ class InteractiveMode:
23
+ """Handles interactive question generation and user interaction flows."""
24
+
25
+ def __init__(self, config: GACConfig):
26
+ self.config = config
27
+
28
+ def generate_contextual_questions(
29
+ self,
30
+ model: str,
31
+ git_state: GitState,
32
+ hint: str,
33
+ temperature: float,
34
+ max_tokens: int,
35
+ max_retries: int,
36
+ quiet: bool = False,
37
+ ) -> list[str]:
38
+ """Generate contextual questions about staged changes."""
39
+ from gac.prompt import build_question_generation_prompt
40
+
41
+ status = git_state.status
42
+ diff = git_state.processed_diff
43
+ diff_stat = git_state.diff_stat
44
+
45
+ try:
46
+ # Build prompts for question generation
47
+ system_prompt, question_prompt = build_question_generation_prompt(
48
+ status=status,
49
+ processed_diff=diff,
50
+ diff_stat=diff_stat,
51
+ hint=hint,
52
+ )
53
+
54
+ # Generate questions using existing infrastructure
55
+ logger.info("Generating contextual questions about staged changes...")
56
+ questions_text = generate_commit_message(
57
+ model=model,
58
+ prompt=(system_prompt, question_prompt),
59
+ temperature=temperature,
60
+ max_tokens=max_tokens,
61
+ max_retries=max_retries,
62
+ quiet=quiet,
63
+ skip_success_message=True, # Don't show "Generated commit message" for questions
64
+ task_description="contextual questions",
65
+ )
66
+
67
+ # Parse the response to extract individual questions
68
+ questions = self._parse_questions_from_response(questions_text)
69
+
70
+ logger.info(f"Generated {len(questions)} contextual questions")
71
+ return questions
72
+
73
+ except Exception as e:
74
+ logger.warning(f"Failed to generate contextual questions, proceeding without them: {e}")
75
+ if not quiet:
76
+ console.print("[yellow]⚠️ Could not generate contextual questions, proceeding normally[/yellow]\n")
77
+ return []
78
+
79
+ def _parse_questions_from_response(self, response: str) -> list[str]:
80
+ """Parse the AI response to extract individual questions from a numbered list.
81
+
82
+ Args:
83
+ response: The raw response from the AI model
84
+
85
+ Returns:
86
+ A list of cleaned questions
87
+ """
88
+ questions = []
89
+ lines = response.strip().split("\n")
90
+
91
+ for line in lines:
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+
96
+ # Match numbered list format (e.g., "1. Question text?" or "1) Question text?")
97
+ match = re.match(r"^\d+\.\s+(.+)$", line)
98
+ if not match:
99
+ match = re.match(r"^\d+\)\s+(.+)$", line)
100
+
101
+ if match:
102
+ question = match.group(1).strip()
103
+ # Remove any leading symbols like •, -, *
104
+ question = re.sub(r"^[•\-*]\s+", "", question)
105
+ if question and question.endswith("?"):
106
+ questions.append(question)
107
+ elif line.endswith("?") and len(line) > 5: # Fallback for non-numbered questions
108
+ questions.append(line)
109
+
110
+ return questions
111
+
112
+ def handle_interactive_flow(
113
+ self,
114
+ model: str,
115
+ user_prompt: str,
116
+ git_state: GitState,
117
+ hint: str,
118
+ conversation_messages: list[dict[str, str]],
119
+ temperature: float,
120
+ max_tokens: int,
121
+ max_retries: int,
122
+ quiet: bool = False,
123
+ ) -> None:
124
+ """Handle the complete interactive flow for collecting user context."""
125
+ try:
126
+ questions = self.generate_contextual_questions(
127
+ model=model,
128
+ git_state=git_state,
129
+ hint=hint,
130
+ temperature=temperature,
131
+ max_tokens=max_tokens,
132
+ max_retries=max_retries,
133
+ quiet=quiet,
134
+ )
135
+
136
+ if not questions:
137
+ return
138
+
139
+ # Collect answers interactively
140
+ answers = collect_interactive_answers(questions)
141
+
142
+ if answers is None:
143
+ # User aborted interactive mode
144
+ if not quiet:
145
+ console.print("[yellow]Proceeding with commit without additional context[/yellow]\n")
146
+ elif answers:
147
+ # User provided some answers, format them for the prompt
148
+ answers_context = format_answers_for_prompt(answers)
149
+ enhanced_user_prompt = user_prompt + answers_context
150
+
151
+ # Update the conversation messages with the enhanced prompt
152
+ if conversation_messages and conversation_messages[-1]["role"] == "user":
153
+ conversation_messages[-1]["content"] = enhanced_user_prompt
154
+
155
+ logger.info(f"Collected answers for {len(answers)} questions")
156
+ else:
157
+ # User skipped all questions
158
+ if not quiet:
159
+ console.print("[dim]No answers provided, proceeding with original context[/dim]\n")
160
+
161
+ except Exception as e:
162
+ logger.warning(f"Failed to generate contextual questions, proceeding without them: {e}")
163
+ if not quiet:
164
+ console.print("[yellow]⚠️ Could not generate contextual questions, proceeding normally[/yellow]\n")
165
+
166
+ def handle_single_commit_confirmation(
167
+ self,
168
+ model: str,
169
+ commit_message: str,
170
+ conversation_messages: list[dict[str, str]],
171
+ quiet: bool = False,
172
+ ) -> tuple[str, str]:
173
+ """Handle confirmation loop for single commit. Returns (final_message, decision).
174
+
175
+ Decision is one of: "yes", "no", "regenerate"
176
+ """
177
+ if not self.config.get("require_confirmation", True):
178
+ return commit_message, "yes"
179
+
180
+ decision, final_message, _ = handle_confirmation_loop(commit_message, conversation_messages, quiet, model)
181
+
182
+ return final_message, decision
gac/language_cli.py ADDED
@@ -0,0 +1,377 @@
1
+ """CLI for selecting commit message language interactively."""
2
+
3
+ import os
4
+ import unicodedata
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import questionary
9
+ from dotenv import load_dotenv, set_key
10
+
11
+ from gac.constants import Languages
12
+
13
+ GAC_ENV_PATH = Path.home() / ".gac.env"
14
+
15
+
16
+ def configure_language_init_workflow(existing_env_path: Path | str) -> bool:
17
+ """Configure language as part of init workflow.
18
+
19
+ This is used by init_cli.py to handle language configuration
20
+ when the init command is run.
21
+
22
+ Args:
23
+ existing_env_path: Path to the environment file
24
+
25
+ Returns:
26
+ True if language configuration succeeded, False if cancelled
27
+ """
28
+ try:
29
+ # Use the provided path instead of our default GAC_ENV_PATH
30
+ temp_env_path = Path(existing_env_path) if isinstance(existing_env_path, str) else existing_env_path
31
+
32
+ # If no env file, create it and proceed directly to language selection
33
+ if not temp_env_path.exists():
34
+ language_value = _run_language_selection_flow(temp_env_path)
35
+ return language_value is not None
36
+
37
+ # Clear any existing environ state to avoid cross-test contamination
38
+ env_keys_to_clear = [k for k in os.environ.keys() if k.startswith("GAC_")]
39
+ for key in env_keys_to_clear:
40
+ del os.environ[key]
41
+
42
+ # File exists - check for existing language
43
+ load_dotenv(temp_env_path)
44
+ existing_language = os.getenv("GAC_LANGUAGE")
45
+
46
+ if existing_language:
47
+ # Language already exists - ask what to do
48
+ preserve_action = questionary.select(
49
+ f"Found existing language: {existing_language}. How would you like to proceed?",
50
+ choices=[
51
+ f"Keep existing language ({existing_language})",
52
+ "Select new language",
53
+ ],
54
+ use_shortcuts=True,
55
+ use_arrow_keys=True,
56
+ use_jk_keys=False,
57
+ ).ask()
58
+
59
+ if not preserve_action:
60
+ click.echo("Language configuration cancelled. Proceeding with init...")
61
+ return True # Continue with init, just skip language part
62
+
63
+ if preserve_action.startswith("Keep existing language"):
64
+ click.echo(f"Keeping existing language: {existing_language}")
65
+ return True
66
+
67
+ # User wants to select new language
68
+ language_value = _run_language_selection_flow(temp_env_path)
69
+ if language_value is None:
70
+ click.echo("Language selection cancelled. Proceeding with init...")
71
+ return True # Continue with init, just skip language part
72
+ return True
73
+ else:
74
+ # No existing language
75
+ language_value = _run_language_selection_flow(temp_env_path)
76
+ return language_value is not None
77
+
78
+ except Exception as e:
79
+ click.echo(f"Language configuration error: {e}")
80
+ return False
81
+
82
+
83
+ def _run_language_selection_flow(env_path: Path) -> str | None:
84
+ """Run the language selection flow and return the selected language.
85
+
86
+ Args:
87
+ env_path: Path to the environment file
88
+
89
+ Returns:
90
+ Selected language value, or None if cancelled
91
+ """
92
+ display_names = [lang[0] for lang in Languages.LANGUAGES]
93
+ language_selection = questionary.select(
94
+ "Select a language for commit messages:",
95
+ choices=display_names,
96
+ use_shortcuts=True,
97
+ use_arrow_keys=True,
98
+ use_jk_keys=False,
99
+ ).ask()
100
+
101
+ if not language_selection:
102
+ return None
103
+
104
+ # Handle English - set explicitly
105
+ if language_selection == "English":
106
+ set_key(str(env_path), "GAC_LANGUAGE", "English")
107
+ set_key(str(env_path), "GAC_TRANSLATE_PREFIXES", "false")
108
+ click.echo("Set GAC_LANGUAGE='English'")
109
+ click.echo("Set GAC_TRANSLATE_PREFIXES='false'")
110
+ return "English"
111
+
112
+ # Handle custom input
113
+ if language_selection == "Custom":
114
+ language_value = _handle_custom_language_input()
115
+ else:
116
+ # Find the English name for the selected language
117
+ language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == language_selection)
118
+
119
+ if language_value is None:
120
+ return None
121
+
122
+ # Check if language is RTL and handle warning
123
+ if is_rtl_text(language_value):
124
+ if not should_show_rtl_warning():
125
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
126
+ else:
127
+ if not show_rtl_warning(language_value, env_path):
128
+ return None # User cancelled
129
+
130
+ # Ask about prefix translation
131
+ translate_prefixes = _ask_about_prefix_translation(language_value)
132
+ if translate_prefixes is None:
133
+ return None # User cancelled
134
+
135
+ # Set the language and prefix translation preference
136
+ set_key(str(env_path), "GAC_LANGUAGE", language_value)
137
+ set_key(str(env_path), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
138
+ click.echo(f"Set GAC_LANGUAGE='{language_value}'")
139
+ click.echo(f"Set GAC_TRANSLATE_PREFIXES={'true' if translate_prefixes else 'false'}")
140
+
141
+ return language_value
142
+
143
+
144
+ def _handle_custom_language_input() -> str | None:
145
+ """Handle custom language input from user.
146
+
147
+ Returns:
148
+ Custom language value, or None if cancelled/empty
149
+ """
150
+ custom_language: str | None = questionary.text(
151
+ "Enter the language name (e.g., 'Spanish', 'Français', '日本語'):",
152
+ use_shortcuts=True,
153
+ use_arrow_keys=True,
154
+ use_jk_keys=False,
155
+ ).ask()
156
+
157
+ if not custom_language or not custom_language.strip():
158
+ return None
159
+ return custom_language.strip()
160
+
161
+
162
+ def _ask_about_prefix_translation(language_value: str) -> bool | None:
163
+ """Ask user about prefix translation preference.
164
+
165
+ Args:
166
+ language_value: The selected language
167
+
168
+ Returns:
169
+ True if translate prefixes, False if keep English, None if cancelled
170
+ """
171
+ prefix_choice: str | None = questionary.select(
172
+ "How should conventional commit prefixes be handled?",
173
+ choices=[
174
+ "Keep prefixes in English (feat:, fix:, etc.)",
175
+ f"Translate prefixes into {language_value}",
176
+ ],
177
+ use_shortcuts=True,
178
+ use_arrow_keys=True,
179
+ use_jk_keys=False,
180
+ ).ask()
181
+
182
+ if not prefix_choice:
183
+ return None
184
+ return prefix_choice.startswith("Translate prefixes")
185
+
186
+
187
+ def should_show_rtl_warning() -> bool:
188
+ """Check if RTL warning should be shown based on saved preference.
189
+
190
+ Returns:
191
+ True if warning should be shown, False if user previously confirmed
192
+ """
193
+ # Load the current config to check RTL confirmation
194
+ if GAC_ENV_PATH.exists():
195
+ load_dotenv(GAC_ENV_PATH)
196
+ rtl_confirmed = os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on")
197
+ return not rtl_confirmed
198
+ return True # Show warning if no config exists
199
+
200
+
201
+ def is_rtl_text(text: str) -> bool:
202
+ """Detect if text contains RTL characters or is a known RTL language.
203
+
204
+ Args:
205
+ text: Text to analyze
206
+
207
+ Returns:
208
+ True if text contains RTL script characters or is RTL language
209
+ """
210
+ # Known RTL language names (case insensitive)
211
+ rtl_languages = {
212
+ "arabic",
213
+ "ar",
214
+ "العربية",
215
+ "hebrew",
216
+ "he",
217
+ "עברית",
218
+ "persian",
219
+ "farsi",
220
+ "fa",
221
+ "urdu",
222
+ "ur",
223
+ "اردو",
224
+ "pashto",
225
+ "ps",
226
+ "kurdish",
227
+ "ku",
228
+ "کوردی",
229
+ "yiddish",
230
+ "yi",
231
+ "ייִדיש",
232
+ }
233
+
234
+ # Check if it's a known RTL language name or code (case insensitive)
235
+ if text.lower().strip() in rtl_languages:
236
+ return True
237
+
238
+ rtl_scripts = {"Arabic", "Hebrew", "Thaana", "Nko", "Syriac", "Mandeic", "Samaritan", "Mongolian", "Phags-Pa"}
239
+
240
+ for char in text:
241
+ if unicodedata.name(char, "").startswith(("ARABIC", "HEBREW")):
242
+ return True
243
+ script = unicodedata.name(char, "").split()[0] if unicodedata.name(char, "") else ""
244
+ if script in rtl_scripts:
245
+ return True
246
+ return False
247
+
248
+
249
+ def center_text(text: str, width: int = 80) -> str:
250
+ """Center text within specified width, handling display width properly.
251
+
252
+ Args:
253
+ text: Text to center
254
+ width: Terminal width to center within (default 80)
255
+
256
+ Returns:
257
+ Centered text with proper padding
258
+ """
259
+
260
+ def get_display_width(s: str) -> int:
261
+ """Get the display width of a string, accounting for wide characters."""
262
+ width = 0
263
+ for char in s:
264
+ # East Asian characters are typically 2 columns wide
265
+ if unicodedata.east_asian_width(char) in ("W", "F"):
266
+ width += 2
267
+ else:
268
+ width += 1
269
+ return width
270
+
271
+ # Handle multi-line text
272
+ lines = text.split("\n")
273
+ centered_lines = []
274
+
275
+ for line in lines:
276
+ # Strip existing whitespace to avoid double padding
277
+ stripped_line = line.strip()
278
+ if stripped_line:
279
+ # Calculate padding using display width for accurate centering
280
+ display_width = get_display_width(stripped_line)
281
+ padding = max(0, (width - display_width) // 2)
282
+ centered_line = " " * padding + stripped_line
283
+ centered_lines.append(centered_line)
284
+ else:
285
+ centered_lines.append("")
286
+
287
+ return "\n".join(centered_lines)
288
+
289
+
290
+ def get_terminal_width() -> int:
291
+ """Get the current terminal width.
292
+
293
+ Returns:
294
+ Terminal width in characters, or default if can't be determined
295
+ """
296
+ try:
297
+ import shutil
298
+
299
+ return shutil.get_terminal_size().columns
300
+ except (OSError, AttributeError):
301
+ return 80 # Fallback to 80 columns
302
+
303
+
304
+ def show_rtl_warning(language_name: str, env_path: Path | None = None) -> bool:
305
+ """Show RTL language warning and ask for confirmation.
306
+
307
+ Args:
308
+ language_name: Name of the RTL language
309
+ env_path: Path to environment file (defaults to GAC_ENV_PATH)
310
+
311
+ Returns:
312
+ True if user wants to proceed, False if they cancel
313
+ """
314
+ if env_path is None:
315
+ env_path = GAC_ENV_PATH
316
+ terminal_width = get_terminal_width()
317
+
318
+ # Center just the title
319
+ title = center_text("⚠️ RTL Language Detected", terminal_width)
320
+
321
+ click.echo()
322
+ click.echo(click.style(title, fg="yellow", bold=True))
323
+ click.echo()
324
+ click.echo("Right-to-left (RTL) languages may not display correctly in gac due to terminal limitations.")
325
+ click.echo("However, the commit messages will work fine and should be readable in Git clients")
326
+ click.echo("that properly support RTL text (like most web interfaces and modern tools).\n")
327
+
328
+ proceed = questionary.confirm("Do you want to proceed anyway?").ask()
329
+ if proceed:
330
+ # Remember that user has confirmed RTL acceptance
331
+ set_key(str(env_path), "GAC_RTL_CONFIRMED", "true")
332
+ click.echo("✓ RTL preference saved - you won't see this warning again")
333
+ return proceed if proceed is not None else False
334
+
335
+
336
+ @click.command()
337
+ def language() -> None:
338
+ """Set the language for commit messages interactively."""
339
+ click.echo("Select a language for your commit messages:\n")
340
+
341
+ # Ensure .gac.env exists
342
+ if not GAC_ENV_PATH.exists():
343
+ GAC_ENV_PATH.touch()
344
+ click.echo(f"Created {GAC_ENV_PATH}")
345
+
346
+ language_value = _run_language_selection_flow(GAC_ENV_PATH)
347
+
348
+ if language_value is None:
349
+ click.echo("Language selection cancelled.")
350
+ return
351
+
352
+ # Find the display name for output
353
+ try:
354
+ display_name = next(lang[0] for lang in Languages.LANGUAGES if lang[1] == language_value)
355
+ except StopIteration:
356
+ display_name = language_value # Custom language
357
+
358
+ # If custom language, check if it appears to be RTL for display purposes
359
+ if display_name == language_value and is_rtl_text(language_value):
360
+ # This is a custom RTL language that was handled in _run_language_selection_flow
361
+ if not should_show_rtl_warning():
362
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
363
+
364
+ click.echo(f"\n✓ Set language to {display_name}")
365
+ click.echo(f" GAC_LANGUAGE={language_value}")
366
+
367
+ # Check prefix translation setting
368
+ load_dotenv(GAC_ENV_PATH)
369
+ translate_prefixes = os.getenv("GAC_TRANSLATE_PREFIXES", "false").lower() in ("true", "1", "yes", "on")
370
+ if translate_prefixes:
371
+ click.echo(" GAC_TRANSLATE_PREFIXES=true")
372
+ click.echo("\n Prefixes will be translated (e.g., 'corrección:' instead of 'fix:')")
373
+ else:
374
+ click.echo(" GAC_TRANSLATE_PREFIXES=false")
375
+ click.echo(f"\n Prefixes will remain in English (e.g., 'fix: <{language_value} description>')")
376
+
377
+ click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")