devgen-cli 0.3.0__py3-none-any.whl → 0.3.1__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.
devgen/ai.py CHANGED
@@ -1,37 +1,51 @@
1
+ """Provider registry and dispatch for AI generation."""
2
+
1
3
  from importlib import import_module
4
+ from typing import Any
5
+
6
+ from devgen.providers.base import BaseProvider
7
+
8
+ # Provider name -> (module, class). Keeping the registry explicit makes
9
+ # adding a provider a one-line change and avoids guessing class names.
10
+ PROVIDERS: dict[str, tuple[str, str]] = {
11
+ "gemini": ("devgen.providers.gemini", "GeminiProvider"),
12
+ "openai": ("devgen.providers.openai", "OpenaiProvider"),
13
+ "anthropic": ("devgen.providers.anthropic", "AnthropicProvider"),
14
+ "openrouter": ("devgen.providers.openrouter", "OpenrouterProvider"),
15
+ "huggingface": ("devgen.providers.huggingface", "HuggingfaceProvider"),
16
+ "ollama": ("devgen.providers.ollama", "OllamaProvider"),
17
+ }
18
+
19
+
20
+ def _provider_class(name: str) -> type[BaseProvider]:
21
+ try:
22
+ module_name, class_name = PROVIDERS[name.lower()]
23
+ except KeyError as error:
24
+ available = ", ".join(sorted(PROVIDERS))
25
+ raise ValueError(
26
+ f"Unknown provider `{name}`. Available providers: {available}."
27
+ ) from error
28
+
29
+ return getattr(import_module(module_name), class_name)
2
30
 
3
31
 
4
32
  def generate_with_ai(
5
- prompt, provider="gemini", api_key=None, model="gemini-2.5-flash", **kwargs
6
- ):
7
- """Generates AI-based content such as commit messages using the specified provider and model.
8
- This function dynamically loads and initializes the provider class, sets the API key, and invokes the provider's generate method with the provided prompt and additional parameters.
9
-
10
- Args:
11
- prompt (str): The input prompt used to generate content.
12
- provider (str): The name of the provider module and class to use; defaults to "gemini".
13
- api_key (str, optional): The API key for authenticating with the provider. Should be provided via config file or kwargs.
14
- model (str): The name of the model to use with the provider; defaults to "gemini".
15
- **kwargs: Additional keyword arguments to pass to the provider's generate method.
16
-
17
- Returns:
18
- str: The generated content produced by the AI provider.
33
+ prompt: str,
34
+ provider: str = "gemini",
35
+ api_key: str | None = None,
36
+ model: str | None = None,
37
+ **kwargs: Any,
38
+ ) -> str:
39
+ """Generate text with the requested provider.
40
+
41
+ ``model=None`` lets the provider pick its own default model.
19
42
  """
20
- try:
21
- provider_module = import_module(f"devgen.providers.{provider}")
22
- class_name = "".join([x.capitalize() for x in provider.split("_")]) + "Provider"
23
- provider_class = getattr(provider_module, class_name)
24
- except ModuleNotFoundError as e:
25
- raise ImportError(
26
- f"Provider `{provider}` is not a built-in module: {e}. "
27
- f"Available providers: gemini, openai, anthropic, huggingface, "
28
- f"openrouter, ollama."
29
- ) from e
30
- except AttributeError as e:
31
- raise ImportError(
32
- f"Provider `{provider}` exists but does not expose a "
33
- f"`{class_name}` class. This is a packaging bug."
34
- ) from e
35
-
36
- provider_instance = provider_class()
37
- return provider_instance.generate(prompt, api_key=api_key, model=model, **kwargs)
43
+ return _provider_class(provider)().generate(
44
+ prompt,
45
+ api_key=api_key,
46
+ model=model,
47
+ **kwargs,
48
+ )
49
+
50
+
51
+ __all__ = ["PROVIDERS", "generate_with_ai"]
devgen/cli/changelog.py CHANGED
@@ -1,5 +1,7 @@
1
+ import subprocess
2
+ from typing import Annotated
3
+
1
4
  import typer
2
- from typing_extensions import Annotated
3
5
 
4
6
  from devgen.modules.changelog_generator import ChangelogGenerator
5
7
 
@@ -30,9 +32,8 @@ def generate_changelog(
30
32
  ),
31
33
  ] = "",
32
34
  ) -> None:
33
- generator = ChangelogGenerator()
34
35
  try:
35
- generator.run(output_file=output, from_ref=from_ref)
36
- except Exception as e:
37
- typer.secho(f"Error generating changelog: {e}", fg=typer.colors.RED)
36
+ ChangelogGenerator().run(output_file=output, from_ref=from_ref)
37
+ except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
38
+ typer.secho(f"Error generating changelog: {error}", fg=typer.colors.RED)
38
39
  raise typer.Exit(code=1)
devgen/cli/commit.py CHANGED
@@ -1,3 +1,4 @@
1
+ import subprocess
1
2
  from typing import Annotated
2
3
 
3
4
  import questionary
@@ -11,6 +12,7 @@ from devgen.utils import (
11
12
  get_git_staged_files,
12
13
  get_main_log_path,
13
14
  read_file_content,
15
+ run_git_command,
14
16
  )
15
17
 
16
18
  app = typer.Typer(
@@ -78,8 +80,9 @@ def run_commit(
78
80
  logger = configure_logger("devgen.cli.commit", log_file, console=debug)
79
81
  logger.info(f"Log file: {log_file}")
80
82
  logger.info(
81
- f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, "
82
- f"check={check}, max_groups={max_groups}, max_diff_size={max_diff_size}"
83
+ f"Options: dry_run={dry_run}, push={push}, debug={debug}, "
84
+ f"force={force_rebuild}, check={check}, max_groups={max_groups}, "
85
+ f"max_diff_size={max_diff_size}"
83
86
  )
84
87
 
85
88
  run_commit_engine(
@@ -117,32 +120,34 @@ def validate() -> None:
117
120
  staged = get_git_staged_files()
118
121
  if staged:
119
122
  typer.secho(f"{len(staged)} staged file(s):", fg=typer.colors.GREEN)
120
- for f in staged:
121
- typer.echo(f"- {f}")
123
+ for file in staged:
124
+ typer.echo(f"- {file}")
122
125
  else:
123
126
  typer.secho("[i] No staged files.", fg=typer.colors.YELLOW)
124
127
 
125
128
 
126
129
  @app.command("undo")
127
130
  def undo_commit() -> None:
128
- """Undoes the last commit but keeps changes staged."""
129
- from devgen.utils import run_git_command
130
-
131
+ """Undo the last commit but keep the changes staged."""
131
132
  try:
132
- # Check if there's at least one commit
133
133
  run_git_command(["git", "rev-parse", "HEAD"])
134
- except Exception:
134
+ except (subprocess.CalledProcessError, FileNotFoundError):
135
135
  typer.secho("No commits found to undo.", fg=typer.colors.RED)
136
136
  return
137
137
 
138
- if questionary.confirm(
138
+ if not questionary.confirm(
139
139
  "Are you sure you want to undo the last commit? (Changes will remain staged)",
140
140
  default=False,
141
141
  ).ask():
142
- try:
143
- run_git_command(["git", "reset", "--soft", "HEAD~1"])
144
- typer.secho(
145
- "Last commit undone. Changes are still staged.", fg=typer.colors.GREEN
146
- )
147
- except Exception as e:
148
- typer.secho(f"Failed to undo commit: {e}", fg=typer.colors.RED)
142
+ return
143
+
144
+ try:
145
+ run_git_command(["git", "reset", "--soft", "HEAD~1"])
146
+ except (subprocess.CalledProcessError, FileNotFoundError) as error:
147
+ typer.secho(f"Failed to undo commit: {error}", fg=typer.colors.RED)
148
+ return
149
+
150
+ typer.secho(
151
+ "Last commit undone. Changes are still staged.",
152
+ fg=typer.colors.GREEN,
153
+ )
devgen/cli/config.py CHANGED
@@ -1,12 +1,12 @@
1
- from pathlib import Path
2
- from typing import Any, Optional
1
+ from typing import Annotated, Any
3
2
 
4
3
  import questionary
5
4
  import typer
6
5
  import yaml
7
- from typing_extensions import Annotated
8
6
 
9
- from devgen.utils import get_questionary_style, load_config
7
+ from devgen.ai import PROVIDERS
8
+ from devgen.cli.wizard import ask, run_config_wizard
9
+ from devgen.utils import get_questionary_style, load_config, save_config
10
10
 
11
11
  app = typer.Typer(
12
12
  name="config",
@@ -15,190 +15,88 @@ app = typer.Typer(
15
15
  rich_markup_mode="markdown",
16
16
  )
17
17
 
18
+ _TRUE_VALUES = {"true", "yes", "1"}
19
+ _FALSE_VALUES = {"false", "no", "0"}
18
20
 
19
- def _save_config(config: dict[str, Any]) -> None:
20
- config_path = Path.home() / ".devgen.yaml"
21
- try:
22
- with config_path.open("w", encoding="utf-8") as f:
23
- yaml.dump(config, f, default_flow_style=False)
24
- except Exception as e:
25
- typer.secho(f"❌ Failed to save config: {e}", fg=typer.colors.RED)
26
- raise typer.Exit(code=1)
21
+
22
+ def _coerce(value: str) -> Any:
23
+ """Convert common string values to bool/int; leave everything else alone."""
24
+ normalized = value.strip().lower()
25
+ if normalized in _TRUE_VALUES:
26
+ return True
27
+ if normalized in _FALSE_VALUES:
28
+ return False
29
+ if normalized.lstrip("-").isdigit():
30
+ return int(normalized)
31
+ return value
27
32
 
28
33
 
29
34
  @app.command("list")
30
35
  def list_config() -> None:
31
36
  """Show all configuration values."""
32
- config = load_config()
33
- if not config:
34
- typer.echo("Configuration is empty.")
35
- return
36
-
37
- typer.echo(yaml.dump(config, default_flow_style=False))
37
+ typer.echo(yaml.safe_dump(load_config(), sort_keys=False))
38
38
 
39
39
 
40
40
  @app.command("edit")
41
41
  def edit_config(
42
- key: Annotated[Optional[str], typer.Argument(help="Configuration key.")] = None,
43
- value: Annotated[Optional[str], typer.Argument(help="New value.")] = None,
42
+ key: Annotated[str | None, typer.Argument(help="Configuration key.")] = None,
43
+ value: Annotated[str | None, typer.Argument(help="New value.")] = None,
44
44
  ) -> None:
45
- """Edit a specific configuration value."""
45
+ """Edit or add a single configuration value."""
46
46
  config = load_config()
47
47
  style = get_questionary_style()
48
48
 
49
49
  if not key:
50
- choices = list(config.keys())
51
- if not choices:
52
- typer.secho(
53
- "No configuration keys found. Run `devgen setup config` first.",
54
- fg=typer.colors.YELLOW,
50
+ key = ask(
51
+ questionary.select(
52
+ "Select key to update:",
53
+ choices=list(config),
54
+ style=style,
55
55
  )
56
- raise typer.Exit()
57
- key = questionary.select(
58
- "Select key to update:", choices=choices, style=style
59
- ).ask()
60
-
61
- if not key:
62
- raise typer.Exit()
56
+ )
63
57
 
64
58
  if value is None:
65
- # Check if existing value is boolean to offer select
66
- current_val = config.get(key)
67
-
59
+ current = config.get(key)
68
60
  if key == "provider":
69
- value = questionary.select(
70
- "Select AI Provider:",
71
- choices=[
72
- "gemini",
73
- "openai",
74
- "huggingface",
75
- "openrouter",
76
- "anthropic",
77
- "ollama",
78
- ],
79
- default=str(current_val) if current_val else "gemini",
80
- style=style,
81
- ).ask()
82
- if value is None:
83
- raise typer.Exit(code=130)
84
- elif isinstance(current_val, bool):
85
- val_choice = questionary.select(
86
- f"Select value for '{key}':",
87
- choices=["True", "False"],
88
- default=str(current_val),
89
- style=style,
90
- ).ask()
91
- if val_choice is None:
92
- raise typer.Exit(code=130)
93
- value = val_choice
94
- else:
95
- value = questionary.text(
96
- f"Enter value for '{key}':",
97
- default=str(current_val) if current_val is not None else "",
98
- style=style,
99
- ).ask()
100
-
101
- if value is None:
102
- raise typer.Exit(code=130)
103
-
104
- # Basic type inference
105
- if isinstance(value, str):
106
- if value.lower() == "true":
107
- val = True
108
- elif value.lower() == "false":
109
- val = False
110
- elif value.isdigit():
111
- val = int(value)
61
+ value = ask(
62
+ questionary.select(
63
+ "Select AI Provider:",
64
+ choices=sorted(PROVIDERS),
65
+ default=str(current or "gemini"),
66
+ style=style,
67
+ )
68
+ )
69
+ elif isinstance(current, bool):
70
+ value = ask(
71
+ questionary.select(
72
+ f"Select value for '{key}':",
73
+ choices=["True", "False"],
74
+ default=str(current),
75
+ style=style,
76
+ )
77
+ )
112
78
  else:
113
- val = value
114
- else:
115
- val = value
79
+ value = ask(
80
+ questionary.text(
81
+ f"Enter value for '{key}':",
82
+ default=str(current) if current is not None else "",
83
+ style=style,
84
+ )
85
+ )
116
86
 
117
- config[key] = val
118
- _save_config(config)
119
- typer.secho(f" Set '{key}' to '{val}'", fg=typer.colors.GREEN)
87
+ config[key] = _coerce(value)
88
+ try:
89
+ save_config(config)
90
+ except OSError as error:
91
+ typer.secho(f"❌ Failed to save config: {error}", fg=typer.colors.RED)
92
+ raise typer.Exit(code=1)
93
+ typer.secho(f" Set '{key}' to '{config[key]}'", fg=typer.colors.GREEN)
120
94
 
121
95
 
122
96
  @app.command("set")
123
97
  def set_config() -> None:
124
98
  """Run the interactive configuration wizard."""
125
- typer.secho("🛠️ Interactive Configuration Setup", fg=typer.colors.CYAN, bold=True)
126
-
127
- config = load_config()
128
- style = get_questionary_style()
129
-
130
- # Questions
131
- provider = questionary.select(
132
- "Select AI Provider:",
133
- choices=[
134
- "gemini",
135
- "openai",
136
- "huggingface",
137
- "openrouter",
138
- "anthropic",
139
- "ollama",
140
- ],
141
- default=config.get("provider", "gemini"),
142
- style=style,
143
- ).ask()
144
- if provider is None:
145
- raise typer.Exit(code=130)
146
-
147
- model_default = config.get("model", "gemini-2.5-flash")
148
-
149
- model = questionary.text(
150
- "Enter Model Name:", default=model_default, style=style
151
- ).ask()
152
- if model is None:
153
- raise typer.Exit(code=130)
154
-
155
- api_key = questionary.password(
156
- "Enter API Key (leave empty to keep existing or none):", style=style
157
- ).ask()
158
- if api_key is None:
159
- raise typer.Exit(code=130)
160
-
161
- if not api_key:
162
- api_key = config.get("api_key", "")
163
-
164
- emoji_choice = questionary.select(
165
- "Use Emojis in Commit Messages?",
166
- choices=["Yes", "No"],
167
- default="Yes" if config.get("emoji", True) else "No",
168
- style=style,
169
- ).ask()
170
- if emoji_choice is None:
171
- raise typer.Exit(code=130)
172
- emoji = emoji_choice == "Yes"
173
-
174
- ollama_host = config.get("ollama_host", "http://localhost:11434")
175
- if provider == "ollama":
176
- ollama_host_input = questionary.text(
177
- "Ollama server URL:",
178
- default=ollama_host,
179
- style=style,
180
- ).ask()
181
- if ollama_host_input is None:
182
- raise typer.Exit(code=130)
183
- ollama_host = ollama_host_input.strip() or ollama_host
184
-
185
- # Save Config
186
- new_config = {
187
- "provider": provider,
188
- "model": model,
189
- "api_key": api_key,
190
- "emoji": emoji,
191
- "ollama_host": ollama_host,
192
- }
193
-
194
- # Merge with existing config to preserve other keys?
195
- # The setup wizard usually overwrites or defines the core keys.
196
- # Let's preserve other keys.
197
- config.update(new_config)
198
-
199
- _save_config(config)
200
- typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
201
- typer.echo(yaml.dump(new_config, default_flow_style=False))
99
+ run_config_wizard()
202
100
 
203
101
 
204
102
  @app.command("info")
@@ -218,7 +116,8 @@ def config_info() -> None:
218
116
 
219
117
  console.print(table)
220
118
  console.print(
221
- "\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will likely include them regardless of the 'emoji' setting.",
119
+ "\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will "
120
+ "likely include them regardless of the 'emoji' setting.",
222
121
  style="yellow",
223
122
  )
224
123
  console.print("\n[bold]Example Template:[/bold]")
devgen/cli/gitignore.py CHANGED
@@ -1,10 +1,11 @@
1
- from typing import List, Optional
1
+ from typing import Annotated
2
2
 
3
3
  import questionary
4
4
  import typer
5
- from typing_extensions import Annotated
6
5
 
6
+ from devgen.cli.wizard import ask
7
7
  from devgen.modules.gitignore_generator import GitignoreGenerator
8
+ from devgen.utils import get_questionary_style
8
9
 
9
10
  app = typer.Typer(
10
11
  name="gitignore",
@@ -14,10 +15,52 @@ app = typer.Typer(
14
15
  )
15
16
 
16
17
 
18
+ def _select_templates(
19
+ generator: GitignoreGenerator,
20
+ offline: bool,
21
+ ) -> list[str]:
22
+ """Interactively build a list of templates to include."""
23
+ if offline:
24
+ available = generator.list_cached_templates()
25
+ else:
26
+ typer.secho("Fetching templates list...", fg=typer.colors.YELLOW)
27
+ available = generator.list_available_templates()
28
+
29
+ if not available:
30
+ typer.secho(" No templates available.", fg=typer.colors.RED)
31
+ raise typer.Exit(code=1)
32
+
33
+ selected: list[str] = []
34
+ style = get_questionary_style()
35
+
36
+ while True:
37
+ choices = [
38
+ "Done (Finish selection)",
39
+ *(name for name in available if name not in selected),
40
+ ]
41
+ choice = ask(
42
+ questionary.autocomplete(
43
+ "Search for a template (type to search, select 'Done' to finish):",
44
+ choices=choices,
45
+ ignore_case=True,
46
+ match_middle=True,
47
+ validate=lambda value, options=choices: value in options or value == "",
48
+ style=style,
49
+ )
50
+ )
51
+
52
+ if not choice or choice.startswith("Done"):
53
+ return selected
54
+
55
+ selected.append(choice)
56
+ typer.secho(f"➕ Added '{choice}'", fg=typer.colors.GREEN)
57
+
58
+
17
59
  @app.command("list")
18
60
  def list_templates(
19
61
  cached: Annotated[
20
- bool, typer.Option("--cached", "-c", help="List only cached templates.")
62
+ bool,
63
+ typer.Option("--cached", "-c", help="List only cached templates."),
21
64
  ] = False,
22
65
  ) -> None:
23
66
  """List available gitignore templates."""
@@ -30,25 +73,20 @@ def list_templates(
30
73
  typer.secho("Fetching available templates...", fg=typer.colors.YELLOW)
31
74
  templates = generator.list_available_templates()
32
75
  typer.secho(" Available Templates:", fg=typer.colors.CYAN, bold=True)
33
-
34
- if not templates:
35
- typer.echo("No templates found.")
36
- return
37
-
38
- # Print in columns or just list
39
- typer.echo(", ".join(templates))
40
-
41
- except Exception as e:
42
- typer.secho(f" Error: {e}", fg=typer.colors.RED)
76
+ except RuntimeError as error:
77
+ typer.secho(f" Error: {error}", fg=typer.colors.RED)
43
78
  raise typer.Exit(code=1)
44
79
 
80
+ typer.echo(", ".join(templates) if templates else "No templates found.")
81
+
45
82
 
46
83
  @app.command("generate")
47
84
  def generate_gitignore(
48
85
  templates: Annotated[
49
- Optional[List[str]],
86
+ list[str] | None,
50
87
  typer.Argument(
51
- help="Names of templates to include (e.g. Python Node). If empty, interactive mode is used."
88
+ help="Names of templates to include (e.g. Python Node). "
89
+ "If empty, interactive mode is used."
52
90
  ),
53
91
  ] = None,
54
92
  output: Annotated[
@@ -72,67 +110,28 @@ def generate_gitignore(
72
110
  generator = GitignoreGenerator()
73
111
 
74
112
  if not templates:
75
- # Interactive mode
76
113
  try:
77
- if offline:
78
- available = generator.list_cached_templates()
79
- else:
80
- typer.secho("Fetching templates list...", fg=typer.colors.YELLOW)
81
- available = generator.list_available_templates()
82
-
83
- if not available:
84
- typer.secho(" No templates available.", fg=typer.colors.RED)
85
- raise typer.Exit(code=1)
86
-
87
- from devgen.utils import get_questionary_style
88
-
89
- style = get_questionary_style()
90
-
91
- # Interactive search loop
92
- selected_templates = []
93
- while True:
94
- # Filter out already selected
95
- choices = [t for t in available if t not in selected_templates]
96
- # Add "Done" option at the top
97
- choices.insert(0, "Done (Finish selection)")
98
-
99
- choice = questionary.autocomplete(
100
- "Search for a template (type to search, select 'Done' to finish):",
101
- choices=choices,
102
- ignore_case=True,
103
- match_middle=True,
104
- validate=lambda x: x in choices or x == "",
105
- style=style,
106
- ).ask()
107
-
108
- if choice is None:
109
- raise typer.Exit(code=130)
110
-
111
- if not choice or choice.startswith("Done"):
112
- break
113
-
114
- selected_templates.append(choice)
115
- typer.secho(f"➕ Added '{choice}'", fg=typer.colors.GREEN)
116
-
117
- templates = selected_templates
118
-
119
- if not templates:
120
- typer.secho("No templates selected.", fg=typer.colors.YELLOW)
121
- raise typer.Exit()
122
-
114
+ templates = _select_templates(generator, offline)
123
115
  except typer.Exit:
124
116
  raise
125
117
  except KeyboardInterrupt:
126
118
  typer.secho("\nCancelled by user", fg=typer.colors.YELLOW)
127
119
  raise typer.Exit()
128
- except Exception as e:
129
- typer.secho(f"❌ Error fetching templates: {e}", fg=typer.colors.RED)
120
+ except (OSError, RuntimeError) as error:
121
+ typer.secho(f"❌ Error fetching templates: {error}", fg=typer.colors.RED)
130
122
  raise typer.Exit(code=1)
131
123
 
124
+ if not templates:
125
+ typer.secho("No templates selected.", fg=typer.colors.YELLOW)
126
+ raise typer.Exit()
127
+
132
128
  try:
133
129
  generator.generate(
134
- templates, output_file=output, append=append, offline=offline
130
+ templates,
131
+ output_file=output,
132
+ append=append,
133
+ offline=offline,
135
134
  )
136
- except Exception as e:
137
- typer.secho(f"❌ Error generating .gitignore: {e}", fg=typer.colors.RED)
135
+ except (OSError, RuntimeError, ValueError) as error:
136
+ typer.secho(f"❌ Error generating .gitignore: {error}", fg=typer.colors.RED)
138
137
  raise typer.Exit(code=1)