devgen-cli 0.2.8__tar.gz → 0.3.0__tar.gz
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_cli-0.2.8/devgen_cli.egg-info → devgen_cli-0.3.0}/PKG-INFO +7 -8
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/commit.py +1 -1
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/config.py +6 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/setup.py +10 -7
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/changelog_generator.py +8 -4
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/commit_generator.py +30 -32
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/diff_builder.py +14 -16
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/git_ops.py +6 -5
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/gitignore_generator.py +9 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/anthropic.py +2 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/base.py +43 -20
- devgen_cli-0.3.0/devgen/providers/gemini.py +25 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/huggingface.py +3 -1
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/ollama.py +13 -9
- devgen_cli-0.3.0/devgen/providers/openai.py +25 -0
- devgen_cli-0.3.0/devgen/providers/openrouter.py +30 -0
- devgen_cli-0.3.0/devgen/templates/commit/commit_message.tpl +29 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/utils.py +83 -26
- {devgen_cli-0.2.8 → devgen_cli-0.3.0/devgen_cli.egg-info}/PKG-INFO +7 -8
- devgen_cli-0.3.0/devgen_cli.egg-info/requires.txt +9 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/pyproject.toml +9 -10
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/setup.py +1 -1
- devgen_cli-0.2.8/devgen/providers/gemini.py +0 -63
- devgen_cli-0.2.8/devgen/providers/openai.py +0 -20
- devgen_cli-0.2.8/devgen/providers/openrouter.py +0 -25
- devgen_cli-0.2.8/devgen/templates/commit/commit_message.tpl +0 -14
- devgen_cli-0.2.8/devgen_cli.egg-info/requires.txt +0 -10
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/LICENSE +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/MANIFEST.in +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/README.md +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/__init__.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/ai.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/__init__.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/changelog.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/gitignore.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/license.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/main.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/release.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/__init__.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/changelog_sections.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/license_generator.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/release_note_generator.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/__init__.py +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/agpl-3.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/apache-2.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/bsd-2-clause.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/bsd-3-clause.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/bsl-1.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/cc0-1.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/epl-2.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/gpl-2.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/gpl-3.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/lgpl-2.1.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/mit.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/mpl-2.0.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/unlicense.json +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/SOURCES.txt +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/dependency_links.txt +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/entry_points.txt +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/top_level.txt +0 -0
- {devgen_cli-0.2.8 → devgen_cli-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devgen-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A collection of developer tools
|
|
5
5
|
Home-page: https://github.com/S4NKALP/devgen
|
|
6
6
|
Author: Sankalp Tharu
|
|
@@ -20,16 +20,15 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: anthropic>=0.
|
|
24
|
-
Requires-Dist: google-genai>=
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: openai>=2.11.0
|
|
23
|
+
Requires-Dist: anthropic>=0.116.0
|
|
24
|
+
Requires-Dist: google-genai>=2.10.0
|
|
25
|
+
Requires-Dist: openai>=2.44.0
|
|
27
26
|
Requires-Dist: pyyaml>=6.0.3
|
|
28
27
|
Requires-Dist: questionary>=2.1.1
|
|
29
|
-
Requires-Dist: requests>=2.
|
|
30
|
-
Requires-Dist: rich>=
|
|
28
|
+
Requires-Dist: requests>=2.34.2
|
|
29
|
+
Requires-Dist: rich>=15.0.0
|
|
31
30
|
Requires-Dist: toml>=0.10.2
|
|
32
|
-
Requires-Dist: typer>=0.
|
|
31
|
+
Requires-Dist: typer>=0.26.8
|
|
33
32
|
Dynamic: author
|
|
34
33
|
Dynamic: home-page
|
|
35
34
|
Dynamic: license-file
|
|
@@ -48,6 +48,12 @@ def edit_config(
|
|
|
48
48
|
|
|
49
49
|
if not key:
|
|
50
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,
|
|
55
|
+
)
|
|
56
|
+
raise typer.Exit()
|
|
51
57
|
key = questionary.select(
|
|
52
58
|
"Select key to update:", choices=choices, style=style
|
|
53
59
|
).ask()
|
|
@@ -86,13 +86,16 @@ def setup_config() -> None:
|
|
|
86
86
|
ollama_host = ollama_host_input.strip() or ollama_host
|
|
87
87
|
|
|
88
88
|
# Save Config — merge with existing to preserve extra keys
|
|
89
|
-
new_config = {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
new_config = {
|
|
90
|
+
**current_config,
|
|
91
|
+
**{
|
|
92
|
+
"provider": provider,
|
|
93
|
+
"model": model,
|
|
94
|
+
"api_key": api_key,
|
|
95
|
+
"emoji": emoji,
|
|
96
|
+
"ollama_host": ollama_host,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
96
99
|
|
|
97
100
|
try:
|
|
98
101
|
with config_path.open("w", encoding="utf-8") as f:
|
|
@@ -32,17 +32,21 @@ class ChangelogGenerator:
|
|
|
32
32
|
else:
|
|
33
33
|
cmd = self._resolve_range(to_ref)
|
|
34
34
|
try:
|
|
35
|
-
|
|
35
|
+
output = run_git_command(cmd)
|
|
36
|
+
# Use %x00 as separator to avoid splitting on newlines in commit bodies
|
|
37
|
+
# Then filter out empty strings
|
|
38
|
+
return [c for c in output.split("\0") if c.strip()]
|
|
36
39
|
except subprocess.CalledProcessError as e:
|
|
37
40
|
self.logger.error(f"Git command failed: {e}")
|
|
38
41
|
raise RuntimeError(f"Git command failed: {e}") from e
|
|
39
42
|
|
|
40
43
|
def _log_cmd(self, range_spec: str) -> List[str]:
|
|
41
|
-
# Format: hash|author|date|subject|body
|
|
44
|
+
# Format: hash|author|date|subject|body (separated by %x00 to avoid
|
|
45
|
+
# splitting on newlines within commit bodies)
|
|
42
46
|
return [
|
|
43
47
|
"git",
|
|
44
48
|
"log",
|
|
45
|
-
"--format=%H|%an|%ad|%s|%b",
|
|
49
|
+
"--format=%H|%an|%ad|%s|%b%x00",
|
|
46
50
|
"--date=short",
|
|
47
51
|
range_spec,
|
|
48
52
|
]
|
|
@@ -54,7 +58,7 @@ class ChangelogGenerator:
|
|
|
54
58
|
return self._log_cmd(f"{last_tag}..HEAD")
|
|
55
59
|
except (RuntimeError, subprocess.CalledProcessError):
|
|
56
60
|
self.logger.info("No tags found, generating for all commits.")
|
|
57
|
-
return ["git", "log", "--format=%H|%an|%ad|%s|%b", "--date=short"]
|
|
61
|
+
return ["git", "log", "--format=%H|%an|%ad|%s|%b%x00", "--date=short"]
|
|
58
62
|
|
|
59
63
|
# ----------------------------------------------------------------- parsing
|
|
60
64
|
|
|
@@ -33,8 +33,10 @@ from devgen.utils import (
|
|
|
33
33
|
extract_commit_messages,
|
|
34
34
|
get_commit_dry_run_path,
|
|
35
35
|
is_file_recent,
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
is_token_limit_error,
|
|
37
|
+
format_token_limit_error,
|
|
38
|
+
load_template,
|
|
39
|
+
render_template,
|
|
38
40
|
sanitize_ai_commit_message,
|
|
39
41
|
)
|
|
40
42
|
|
|
@@ -74,13 +76,13 @@ class CommitMessageBuilder:
|
|
|
74
76
|
|
|
75
77
|
def __init__(
|
|
76
78
|
self,
|
|
77
|
-
|
|
79
|
+
template_str: str,
|
|
78
80
|
config: Dict[str, Any],
|
|
79
81
|
provider: str,
|
|
80
82
|
model: str,
|
|
81
83
|
debug: bool = False,
|
|
82
84
|
) -> None:
|
|
83
|
-
self.
|
|
85
|
+
self.template_str = template_str
|
|
84
86
|
self.config = config
|
|
85
87
|
self.provider = provider
|
|
86
88
|
self.model = model
|
|
@@ -97,25 +99,14 @@ class CommitMessageBuilder:
|
|
|
97
99
|
use_emoji = self.config.get("emoji", True)
|
|
98
100
|
ollama_host = self.config.get("ollama_host")
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
else:
|
|
109
|
-
prompt = (
|
|
110
|
-
self.template_env.get_template("commit_message.tpl")
|
|
111
|
-
.render(
|
|
112
|
-
group_name=group,
|
|
113
|
-
diff_text=diff,
|
|
114
|
-
use_emoji=use_emoji,
|
|
115
|
-
context=manifest_context,
|
|
116
|
-
)
|
|
117
|
-
.strip()
|
|
118
|
-
)
|
|
102
|
+
template = custom_template or self.template_str
|
|
103
|
+
prompt = render_template(
|
|
104
|
+
template,
|
|
105
|
+
group_name=group,
|
|
106
|
+
diff_text=diff,
|
|
107
|
+
use_emoji=use_emoji,
|
|
108
|
+
context=manifest_context,
|
|
109
|
+
).strip()
|
|
119
110
|
|
|
120
111
|
provider = (
|
|
121
112
|
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
|
|
@@ -198,10 +189,10 @@ class CommitEngine:
|
|
|
198
189
|
),
|
|
199
190
|
logger=self.logger,
|
|
200
191
|
)
|
|
201
|
-
self.
|
|
192
|
+
self.template_str = load_template("commit", "commit_message.tpl")
|
|
202
193
|
self.cache = CacheManager(get_commit_dry_run_path())
|
|
203
194
|
self.message_builder = CommitMessageBuilder(
|
|
204
|
-
self.
|
|
195
|
+
self.template_str,
|
|
205
196
|
self.config,
|
|
206
197
|
provider=self.provider,
|
|
207
198
|
model=self.model,
|
|
@@ -252,9 +243,10 @@ class CommitEngine:
|
|
|
252
243
|
if not self._process_group(group, group_files, cache_map):
|
|
253
244
|
failed.append(group)
|
|
254
245
|
if len(groups) > 1 and i < len(groups) - 1:
|
|
255
|
-
|
|
246
|
+
if self.provider != "ollama":
|
|
247
|
+
import time
|
|
256
248
|
|
|
257
|
-
|
|
249
|
+
time.sleep(15) # Conservative 4 RPM guard for free-tier
|
|
258
250
|
return failed
|
|
259
251
|
|
|
260
252
|
def _process_group(
|
|
@@ -276,7 +268,7 @@ class CommitEngine:
|
|
|
276
268
|
return self._apply_message(group, cache[group], files)
|
|
277
269
|
|
|
278
270
|
try:
|
|
279
|
-
manifest_context = ManifestInspector.summary()
|
|
271
|
+
manifest_context = ManifestInspector.summary(cwd=self.git.cwd)
|
|
280
272
|
if manifest_context:
|
|
281
273
|
self.logger.info("Including manifest context in prompt")
|
|
282
274
|
with self.console.status(
|
|
@@ -290,10 +282,15 @@ class CommitEngine:
|
|
|
290
282
|
)
|
|
291
283
|
message = sanitize_ai_commit_message(raw)
|
|
292
284
|
except Exception as e:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
285
|
+
if is_token_limit_error(e):
|
|
286
|
+
msg = format_token_limit_error(self.provider, e, group=group)
|
|
287
|
+
self.console.print(f"[bold red]{msg}[/bold red]")
|
|
288
|
+
self.logger.error(msg)
|
|
289
|
+
else:
|
|
290
|
+
self.logger.error(
|
|
291
|
+
f"Failed to generate message for group {group!r}: {e}",
|
|
292
|
+
exc_info=True,
|
|
293
|
+
)
|
|
297
294
|
self.git.reset(files)
|
|
298
295
|
return False
|
|
299
296
|
|
|
@@ -388,6 +385,7 @@ def run_commit_engine(**kwargs: Any) -> None:
|
|
|
388
385
|
sys.exit(0)
|
|
389
386
|
except Exception as e:
|
|
390
387
|
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
|
388
|
+
sys.exit(1)
|
|
391
389
|
|
|
392
390
|
|
|
393
391
|
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
|
|
@@ -14,7 +14,7 @@ from collections import defaultdict
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Dict, List, Optional
|
|
16
16
|
|
|
17
|
-
from devgen.utils import run_git_command
|
|
17
|
+
from devgen.utils import get_git_root, run_git_command
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
# Lock files and other metadata blobs whose full diff would just bloat the
|
|
@@ -54,7 +54,7 @@ class DiffBuilder:
|
|
|
54
54
|
logger=None,
|
|
55
55
|
) -> None:
|
|
56
56
|
self.max_size = max_size
|
|
57
|
-
self.cwd = cwd
|
|
57
|
+
self.cwd = cwd or get_git_root()
|
|
58
58
|
self.logger = logger
|
|
59
59
|
|
|
60
60
|
def build(self, files: List[str]) -> str:
|
|
@@ -129,10 +129,7 @@ class FileGrouper:
|
|
|
129
129
|
parent_path = str(Path(deepest).parent)
|
|
130
130
|
new_key = "root" if parent_path == "." else parent_path
|
|
131
131
|
files = groups.pop(deepest)
|
|
132
|
-
|
|
133
|
-
groups[new_key].extend(files)
|
|
134
|
-
else:
|
|
135
|
-
groups["root"].extend(files)
|
|
132
|
+
groups.setdefault(new_key, []).extend(files)
|
|
136
133
|
return groups
|
|
137
134
|
|
|
138
135
|
|
|
@@ -146,13 +143,14 @@ class ManifestInspector:
|
|
|
146
143
|
"""
|
|
147
144
|
|
|
148
145
|
@staticmethod
|
|
149
|
-
def summary() -> str:
|
|
146
|
+
def summary(cwd: Optional[Path] = None) -> str:
|
|
147
|
+
cwd = cwd or get_git_root() or Path(".")
|
|
150
148
|
lines = [
|
|
151
149
|
s
|
|
152
150
|
for s in (
|
|
153
|
-
ManifestInspector._pyproject(),
|
|
154
|
-
ManifestInspector._package_json(),
|
|
155
|
-
ManifestInspector._requirements(),
|
|
151
|
+
ManifestInspector._pyproject(cwd),
|
|
152
|
+
ManifestInspector._package_json(cwd),
|
|
153
|
+
ManifestInspector._requirements(cwd),
|
|
156
154
|
)
|
|
157
155
|
if s
|
|
158
156
|
]
|
|
@@ -167,8 +165,8 @@ class ManifestInspector:
|
|
|
167
165
|
return re.split(r"[><=!~\[]", spec, 1)[0].strip()
|
|
168
166
|
|
|
169
167
|
@staticmethod
|
|
170
|
-
def _pyproject() -> Optional[str]:
|
|
171
|
-
path =
|
|
168
|
+
def _pyproject(cwd: Path) -> Optional[str]:
|
|
169
|
+
path = cwd / "pyproject.toml"
|
|
172
170
|
if not path.exists():
|
|
173
171
|
return None
|
|
174
172
|
try:
|
|
@@ -189,8 +187,8 @@ class ManifestInspector:
|
|
|
189
187
|
return f"[pyproject.toml] {' '.join(parts)}" if parts else None
|
|
190
188
|
|
|
191
189
|
@staticmethod
|
|
192
|
-
def _package_json() -> Optional[str]:
|
|
193
|
-
path =
|
|
190
|
+
def _package_json(cwd: Path) -> Optional[str]:
|
|
191
|
+
path = cwd / "package.json"
|
|
194
192
|
if not path.exists():
|
|
195
193
|
return None
|
|
196
194
|
try:
|
|
@@ -206,8 +204,8 @@ class ManifestInspector:
|
|
|
206
204
|
return f"[package.json] {' '.join(parts)}" if parts else None
|
|
207
205
|
|
|
208
206
|
@staticmethod
|
|
209
|
-
def _requirements() -> Optional[str]:
|
|
210
|
-
path =
|
|
207
|
+
def _requirements(cwd: Path) -> Optional[str]:
|
|
208
|
+
path = cwd / "requirements.txt"
|
|
211
209
|
if not path.exists():
|
|
212
210
|
return None
|
|
213
211
|
try:
|
|
@@ -10,7 +10,7 @@ import subprocess
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Optional
|
|
12
12
|
|
|
13
|
-
from devgen.utils import configure_logger, run_git_command
|
|
13
|
+
from devgen.utils import configure_logger, get_git_root, run_git_command
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class GitError(RuntimeError):
|
|
@@ -26,7 +26,7 @@ class GitOperator:
|
|
|
26
26
|
logger=None,
|
|
27
27
|
quiet_commands: bool = True,
|
|
28
28
|
) -> None:
|
|
29
|
-
self.cwd = cwd
|
|
29
|
+
self.cwd = cwd or get_git_root()
|
|
30
30
|
self.logger = logger or configure_logger("devgen.git")
|
|
31
31
|
self.quiet_commands = quiet_commands
|
|
32
32
|
|
|
@@ -91,15 +91,16 @@ class GitOperator:
|
|
|
91
91
|
[
|
|
92
92
|
"git",
|
|
93
93
|
"ls-files",
|
|
94
|
+
"-z",
|
|
94
95
|
"--deleted",
|
|
95
96
|
"--modified",
|
|
96
97
|
"--others",
|
|
97
98
|
"--exclude-standard",
|
|
98
99
|
]
|
|
99
100
|
)
|
|
100
|
-
files = {f
|
|
101
|
-
staged_out = self._run(["git", "diff", "--name-only", "--cached"])
|
|
102
|
-
files.update(f
|
|
101
|
+
files = {f for f in out.split("\0") if f}
|
|
102
|
+
staged_out = self._run(["git", "diff", "-z", "--name-only", "--cached"])
|
|
103
|
+
files.update(f for f in staged_out.split("\0") if f)
|
|
103
104
|
return sorted(files)
|
|
104
105
|
|
|
105
106
|
# ------------------------------------------------------------------ actions
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import re
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import List
|
|
3
4
|
|
|
@@ -5,6 +6,9 @@ import requests
|
|
|
5
6
|
|
|
6
7
|
from devgen.utils import configure_logger
|
|
7
8
|
|
|
9
|
+
# Only allow safe characters in template names to prevent path traversal
|
|
10
|
+
_TEMPLATE_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
class GitignoreGenerator:
|
|
10
14
|
"""Fetches and manages .gitignore templates from GitHub."""
|
|
@@ -41,6 +45,11 @@ class GitignoreGenerator:
|
|
|
41
45
|
self, name: str, use_cache: bool = True, offline: bool = False
|
|
42
46
|
) -> str:
|
|
43
47
|
"""Fetches template content from cache or GitHub."""
|
|
48
|
+
if not _TEMPLATE_NAME_RE.match(name):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Invalid template name '{name}'. "
|
|
51
|
+
"Names must contain only alphanumeric characters, hyphens, dots, or underscores."
|
|
52
|
+
)
|
|
44
53
|
cache_file = self.cache_dir / f"{name}.gitignore"
|
|
45
54
|
|
|
46
55
|
if offline:
|
|
@@ -30,33 +30,56 @@ class BaseProvider(ABC):
|
|
|
30
30
|
model: str | None = None,
|
|
31
31
|
**kwargs: Any,
|
|
32
32
|
) -> str:
|
|
33
|
-
"""Public entry point. Validates input, dispatches, and wraps errors.
|
|
33
|
+
"""Public entry point. Validates input, dispatches, and wraps errors."""
|
|
34
|
+
import time
|
|
34
35
|
|
|
35
|
-
Args:
|
|
36
|
-
prompt: The prompt to send to the model.
|
|
37
|
-
api_key: Provider API key. May be ``None`` for local providers
|
|
38
|
-
(see :attr:`REQUIRES_API_KEY`).
|
|
39
|
-
model: Model id. Falls back to :attr:`DEFAULT_MODEL` when ``None``.
|
|
40
|
-
**kwargs: Forwarded to :meth:`_generate`.
|
|
41
|
-
|
|
42
|
-
Returns:
|
|
43
|
-
The generated text, stripped.
|
|
44
|
-
|
|
45
|
-
Raises:
|
|
46
|
-
ValueError: If the provider requires an API key and none was given.
|
|
47
|
-
RuntimeError: For any underlying failure, with a friendly message.
|
|
48
|
-
"""
|
|
49
36
|
if self.REQUIRES_API_KEY and not api_key:
|
|
50
37
|
raise ValueError(
|
|
51
38
|
f"{self.DISPLAY_NAME} API key is missing. "
|
|
52
39
|
"Set it via `devgen setup config` or pass --api-key."
|
|
53
40
|
)
|
|
54
41
|
chosen_model = model or self.DEFAULT_MODEL
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
42
|
+
|
|
43
|
+
max_retries = kwargs.get("max_retries", 5)
|
|
44
|
+
base_delay = kwargs.get("retry_delay", 10)
|
|
45
|
+
|
|
46
|
+
for attempt in range(max_retries + 1):
|
|
47
|
+
try:
|
|
48
|
+
text = self._generate(prompt, api_key, chosen_model, **kwargs)
|
|
49
|
+
return (text or "").strip()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
if is_token_limit_error(e):
|
|
52
|
+
self._handle_error(e)
|
|
53
|
+
if self._is_quota_error(e) and attempt < max_retries:
|
|
54
|
+
delay = base_delay * (2**attempt)
|
|
55
|
+
print(
|
|
56
|
+
f"\n[{self.DISPLAY_NAME}] Quota/Rate limit hit (429). "
|
|
57
|
+
f"Waiting {delay}s and retrying... "
|
|
58
|
+
f"(Attempt {attempt + 1}/{max_retries})"
|
|
59
|
+
)
|
|
60
|
+
time.sleep(delay)
|
|
61
|
+
continue
|
|
62
|
+
if self._is_quota_error(e) and attempt >= max_retries:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
f"{self.DISPLAY_NAME} API quota exhausted (429) after multiple retries."
|
|
65
|
+
) from e
|
|
66
|
+
self._handle_error(e)
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _is_quota_error(error: Exception) -> bool:
|
|
71
|
+
text = str(error).upper()
|
|
72
|
+
return any(
|
|
73
|
+
marker in text
|
|
74
|
+
for marker in (
|
|
75
|
+
"429",
|
|
76
|
+
"RESOURCE_EXHAUSTED",
|
|
77
|
+
"QUOTA_EXCEEDED",
|
|
78
|
+
"THROTTLED",
|
|
79
|
+
"RATE_LIMIT_EXCEEDED",
|
|
80
|
+
"TOO_MANY_REQUESTS",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
60
83
|
|
|
61
84
|
def _handle_error(self, error: Exception) -> None:
|
|
62
85
|
"""Translate an internal exception into a user-facing RuntimeError.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from google import genai
|
|
2
|
+
from google.genai import types
|
|
3
|
+
|
|
4
|
+
from devgen.providers.base import BaseProvider
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GeminiProvider(BaseProvider):
|
|
8
|
+
"""Generates content using Google's Gemini models."""
|
|
9
|
+
|
|
10
|
+
DISPLAY_NAME = "Gemini"
|
|
11
|
+
DEFAULT_MODEL = "gemini-2.5-flash"
|
|
12
|
+
|
|
13
|
+
def _generate(self, prompt, api_key, model, **kwargs):
|
|
14
|
+
client = genai.Client(api_key=api_key)
|
|
15
|
+
response = client.models.generate_content(
|
|
16
|
+
model=model,
|
|
17
|
+
contents=prompt,
|
|
18
|
+
config=types.GenerateContentConfig(
|
|
19
|
+
temperature=kwargs.get("temperature", 0.7),
|
|
20
|
+
top_p=kwargs.get("top_p", 0.95),
|
|
21
|
+
top_k=kwargs.get("top_k", 40),
|
|
22
|
+
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
return response.text or ""
|
|
@@ -20,7 +20,9 @@ class HuggingfaceProvider(BaseProvider):
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
try:
|
|
23
|
-
response = requests.post(
|
|
23
|
+
response = requests.post(
|
|
24
|
+
api_url, headers=headers, json=payload, timeout=120
|
|
25
|
+
)
|
|
24
26
|
response.raise_for_status()
|
|
25
27
|
result = response.json()
|
|
26
28
|
except requests.HTTPError as e:
|
|
@@ -18,18 +18,22 @@ class OllamaProvider(BaseProvider):
|
|
|
18
18
|
DEFAULT_HOST = "http://localhost:11434"
|
|
19
19
|
DEFAULT_MODEL = "llama3.2"
|
|
20
20
|
|
|
21
|
+
# Keys consumed by devgen itself — never forwarded to the SDK.
|
|
22
|
+
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
|
23
|
+
|
|
21
24
|
def _generate(self, prompt, api_key, model, **kwargs):
|
|
22
25
|
host = kwargs.get("ollama_host") or self.DEFAULT_HOST
|
|
23
26
|
base_url = f"{host.rstrip('/')}/v1"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
|
|
28
|
+
# Only forward known OpenAI-compatible parameters
|
|
29
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
|
30
|
+
|
|
31
|
+
with OpenAI(base_url=base_url, api_key=api_key or "ollama") as client:
|
|
32
|
+
response = client.chat.completions.create(
|
|
33
|
+
model=model,
|
|
34
|
+
messages=[{"role": "user", "content": prompt}],
|
|
35
|
+
**safe_kwargs,
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
if not response.choices:
|
|
35
39
|
raise RuntimeError(
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
from devgen.providers.base import BaseProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenaiProvider(BaseProvider):
|
|
7
|
+
"""Generates content using OpenAI's Chat Completions API."""
|
|
8
|
+
|
|
9
|
+
DISPLAY_NAME = "OpenAI"
|
|
10
|
+
DEFAULT_MODEL = "gpt-4o"
|
|
11
|
+
|
|
12
|
+
# Keys consumed by devgen itself — never forwarded to the SDK.
|
|
13
|
+
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
|
14
|
+
|
|
15
|
+
def _generate(self, prompt, api_key, model, **kwargs):
|
|
16
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
|
17
|
+
with OpenAI(api_key=api_key) as client:
|
|
18
|
+
response = client.chat.completions.create(
|
|
19
|
+
model=model,
|
|
20
|
+
messages=[{"role": "user", "content": prompt}],
|
|
21
|
+
**safe_kwargs,
|
|
22
|
+
)
|
|
23
|
+
if not response.choices:
|
|
24
|
+
raise RuntimeError(f"OpenAI returned no choices for model {model!r}.")
|
|
25
|
+
return response.choices[0].message.content or ""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
from devgen.providers.base import BaseProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenrouterProvider(BaseProvider):
|
|
7
|
+
"""Generates content using OpenRouter (OpenAI-compatible API)."""
|
|
8
|
+
|
|
9
|
+
DISPLAY_NAME = "OpenRouter"
|
|
10
|
+
BASE_URL = "https://openrouter.ai/api/v1"
|
|
11
|
+
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
|
12
|
+
|
|
13
|
+
# Keys consumed by devgen itself — never forwarded to the SDK.
|
|
14
|
+
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
|
15
|
+
|
|
16
|
+
def _generate(self, prompt, api_key, model, **kwargs):
|
|
17
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
|
18
|
+
with OpenAI(base_url=self.BASE_URL, api_key=api_key) as client:
|
|
19
|
+
response = client.chat.completions.create(
|
|
20
|
+
model=model,
|
|
21
|
+
messages=[{"role": "user", "content": prompt}],
|
|
22
|
+
extra_headers={
|
|
23
|
+
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
|
24
|
+
"X-Title": "devgen CLI",
|
|
25
|
+
},
|
|
26
|
+
**safe_kwargs,
|
|
27
|
+
)
|
|
28
|
+
if not response.choices:
|
|
29
|
+
raise RuntimeError(f"OpenRouter returned no choices for model {model!r}.")
|
|
30
|
+
return response.choices[0].message.content or ""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
You write Conventional Commits. Output ONLY the message — no preamble, no labels, no quotes, no markdown.
|
|
2
|
+
|
|
3
|
+
Format:
|
|
4
|
+
<type>[optional scope][optional !]: <description>
|
|
5
|
+
[optional blank line]
|
|
6
|
+
[optional body]
|
|
7
|
+
[optional blank line]
|
|
8
|
+
[optional footer(s)]
|
|
9
|
+
|
|
10
|
+
types: feat fix refactor perf docs test build ci chore style
|
|
11
|
+
scope: {% if group_name and group_name != 'root' %}({{ group_name }}){% else %}optional{% endif %}{% if use_emoji %} (prefix description with one matching emoji){% endif %}
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
feat: add email notifications on new direct messages
|
|
15
|
+
feat(shopping cart): add the amazing button
|
|
16
|
+
feat!: remove ticket list endpoint
|
|
17
|
+
refers to JIRA-1337
|
|
18
|
+
BREAKING CHANGE: ticket endpoints no longer supports list all entities.
|
|
19
|
+
fix(shopping-cart): prevent order an empty shopping cart
|
|
20
|
+
fix(api): fix wrong calculation of request body checksum
|
|
21
|
+
fix: add missing parameter to service call
|
|
22
|
+
The error occurred due to <reasons>.
|
|
23
|
+
perf: decrease memory footprint for determine unique visitors by using HyperLogLog
|
|
24
|
+
build: update dependencies
|
|
25
|
+
build(release): bump version to 1.0.0
|
|
26
|
+
refactor: implement fibonacci number calculation as recursion
|
|
27
|
+
style: remove empty line
|
|
28
|
+
|
|
29
|
+
{{ diff_text }}{{ context }}
|
|
@@ -6,7 +6,6 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any, Dict, Optional
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
|
-
from jinja2 import Environment, FileSystemLoader
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
def ensure_log_directory() -> Path:
|
|
@@ -24,10 +23,10 @@ def get_commit_dry_run_path() -> Path:
|
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
try:
|
|
27
|
+
return (time.time() - Path(file_path).stat().st_mtime) <= max_age_minutes * 60
|
|
28
|
+
except FileNotFoundError:
|
|
29
29
|
return False
|
|
30
|
-
return (time.time() - path.stat().st_mtime) <= max_age_minutes * 60
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
def sanitize_ai_commit_message(raw_text: str) -> str:
|
|
@@ -47,7 +46,7 @@ def sanitize_ai_commit_message(raw_text: str) -> str:
|
|
|
47
46
|
# - **fix: bug fix**
|
|
48
47
|
# - chore(deps)!: breaking change
|
|
49
48
|
header_pattern = re.compile(
|
|
50
|
-
r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*",
|
|
49
|
+
r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci|perf|revert|deps|wip)(\(.*\))?!?: .*",
|
|
51
50
|
re.IGNORECASE,
|
|
52
51
|
)
|
|
53
52
|
|
|
@@ -76,6 +75,9 @@ def sanitize_ai_commit_message(raw_text: str) -> str:
|
|
|
76
75
|
# If we hit another header or a known separator, we stop
|
|
77
76
|
if "**Sponsor**" in line:
|
|
78
77
|
break
|
|
78
|
+
# Ignore trailing markdown code block markers
|
|
79
|
+
if line.strip() in ("```", "```md", "```markdown"):
|
|
80
|
+
continue
|
|
79
81
|
cleaned_lines.append(line)
|
|
80
82
|
|
|
81
83
|
if cleaned_lines:
|
|
@@ -142,6 +144,15 @@ def configure_logger(
|
|
|
142
144
|
return logger
|
|
143
145
|
|
|
144
146
|
|
|
147
|
+
def get_git_root(cwd: Optional[Path] = None) -> Optional[Path]:
|
|
148
|
+
"""Return the root directory of the current git repository, or None."""
|
|
149
|
+
try:
|
|
150
|
+
output = run_git_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
|
|
151
|
+
return Path(output) if output else None
|
|
152
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
145
156
|
def run_git_command(
|
|
146
157
|
args: list[str],
|
|
147
158
|
check: bool = True,
|
|
@@ -188,40 +199,79 @@ def get_git_staged_files() -> list[str]:
|
|
|
188
199
|
try:
|
|
189
200
|
output = run_git_command(["git", "diff", "--name-only", "--cached"])
|
|
190
201
|
return [f for f in output.splitlines() if f.strip()]
|
|
191
|
-
except subprocess.CalledProcessError:
|
|
202
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
192
203
|
return []
|
|
193
204
|
|
|
194
205
|
|
|
195
206
|
def read_file_content(filepath: Path | str) -> Optional[str]:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
try:
|
|
208
|
+
return Path(filepath).read_text(encoding="utf-8")
|
|
209
|
+
except FileNotFoundError:
|
|
210
|
+
return None
|
|
200
211
|
|
|
201
212
|
|
|
202
213
|
def delete_file(filepath: Path | str) -> bool:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
path.unlink()
|
|
214
|
+
try:
|
|
215
|
+
Path(filepath).unlink()
|
|
206
216
|
return True
|
|
207
|
-
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
return False
|
|
208
219
|
|
|
209
220
|
|
|
210
|
-
def
|
|
221
|
+
def load_template(sub_dir: str, name: str) -> str:
|
|
222
|
+
"""Load a template file from the templates directory."""
|
|
211
223
|
template_dir = Path(__file__).parent / "templates" / sub_dir
|
|
212
|
-
|
|
224
|
+
template_path = template_dir / name
|
|
225
|
+
if not template_path.is_file():
|
|
213
226
|
raise RuntimeError(
|
|
214
|
-
f"Template
|
|
215
|
-
"The installation may be corrupted."
|
|
227
|
+
f"Template not found: {template_path}. The installation may be corrupted."
|
|
216
228
|
)
|
|
217
|
-
return
|
|
229
|
+
return template_path.read_text(encoding="utf-8")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render_template(template_str: str, **context) -> str:
|
|
233
|
+
"""Render a simple template with variable substitution and conditionals.
|
|
234
|
+
|
|
235
|
+
Supports:
|
|
236
|
+
- {{ variable }} - variable substitution
|
|
237
|
+
- {% if condition %}...{% endif %} - conditional blocks
|
|
238
|
+
- {% if condition %}...{% else %}...{% endif %} - conditional with else
|
|
239
|
+
"""
|
|
240
|
+
result = template_str
|
|
241
|
+
|
|
242
|
+
# Process conditional blocks: {% if var %}...{% endif %}
|
|
243
|
+
# and {% if var %}...{% else %}...{% endif %}
|
|
244
|
+
if_pattern = re.compile(
|
|
245
|
+
r"\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}",
|
|
246
|
+
re.DOTALL,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def replace_if(match):
|
|
250
|
+
var_name = match.group(1)
|
|
251
|
+
content = match.group(2)
|
|
252
|
+
value = context.get(var_name)
|
|
253
|
+
if value:
|
|
254
|
+
# Check for else block
|
|
255
|
+
else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
|
|
256
|
+
parts = else_pattern.split(content, 1)
|
|
257
|
+
return parts[0]
|
|
258
|
+
else:
|
|
259
|
+
else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
|
|
260
|
+
parts = else_pattern.split(content, 1)
|
|
261
|
+
return parts[1] if len(parts) > 1 else ""
|
|
262
|
+
|
|
263
|
+
result = if_pattern.sub(replace_if, result)
|
|
264
|
+
|
|
265
|
+
# Process variable substitutions: {{ variable }}
|
|
266
|
+
var_pattern = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
267
|
+
|
|
268
|
+
def replace_var(match):
|
|
269
|
+
var_name = match.group(1)
|
|
270
|
+
return str(context.get(var_name, ""))
|
|
218
271
|
|
|
272
|
+
result = var_pattern.sub(replace_var, result)
|
|
219
273
|
|
|
220
|
-
|
|
221
|
-
"""Renders a Jinja2 template from a string."""
|
|
222
|
-
env = Environment()
|
|
223
|
-
template = env.from_string(template_str)
|
|
224
|
-
return template.render(**context)
|
|
274
|
+
return result
|
|
225
275
|
|
|
226
276
|
|
|
227
277
|
def load_config() -> Dict[str, Any]:
|
|
@@ -246,7 +296,13 @@ def load_config() -> Dict[str, Any]:
|
|
|
246
296
|
|
|
247
297
|
try:
|
|
248
298
|
with config_path.open("r", encoding="utf-8") as f:
|
|
249
|
-
|
|
299
|
+
data = yaml.safe_load(f)
|
|
300
|
+
if not isinstance(data, dict):
|
|
301
|
+
print(
|
|
302
|
+
f"Warning: Config at {config_path} is not a dict, using defaults."
|
|
303
|
+
)
|
|
304
|
+
return {}
|
|
305
|
+
return data
|
|
250
306
|
except Exception as e:
|
|
251
307
|
print(f"Warning: Failed to load config from {config_path}: {e}")
|
|
252
308
|
return {}
|
|
@@ -323,7 +379,8 @@ __all__ = [
|
|
|
323
379
|
"get_git_staged_files",
|
|
324
380
|
"read_file_content",
|
|
325
381
|
"delete_file",
|
|
326
|
-
"
|
|
382
|
+
"load_template",
|
|
383
|
+
"render_template",
|
|
327
384
|
"load_config",
|
|
328
385
|
"get_questionary_style",
|
|
329
386
|
"is_token_limit_error",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devgen-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A collection of developer tools
|
|
5
5
|
Home-page: https://github.com/S4NKALP/devgen
|
|
6
6
|
Author: Sankalp Tharu
|
|
@@ -20,16 +20,15 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: anthropic>=0.
|
|
24
|
-
Requires-Dist: google-genai>=
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: openai>=2.11.0
|
|
23
|
+
Requires-Dist: anthropic>=0.116.0
|
|
24
|
+
Requires-Dist: google-genai>=2.10.0
|
|
25
|
+
Requires-Dist: openai>=2.44.0
|
|
27
26
|
Requires-Dist: pyyaml>=6.0.3
|
|
28
27
|
Requires-Dist: questionary>=2.1.1
|
|
29
|
-
Requires-Dist: requests>=2.
|
|
30
|
-
Requires-Dist: rich>=
|
|
28
|
+
Requires-Dist: requests>=2.34.2
|
|
29
|
+
Requires-Dist: rich>=15.0.0
|
|
31
30
|
Requires-Dist: toml>=0.10.2
|
|
32
|
-
Requires-Dist: typer>=0.
|
|
31
|
+
Requires-Dist: typer>=0.26.8
|
|
33
32
|
Dynamic: author
|
|
34
33
|
Dynamic: home-page
|
|
35
34
|
Dynamic: license-file
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devgen-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "A collection of developer tools"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,16 +25,15 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python :: 3.10",
|
|
26
26
|
]
|
|
27
27
|
dependencies = [
|
|
28
|
-
"anthropic>=0.
|
|
29
|
-
"google-genai>=
|
|
30
|
-
"
|
|
31
|
-
"openai>=2.11.0",
|
|
28
|
+
"anthropic>=0.116.0",
|
|
29
|
+
"google-genai>=2.10.0",
|
|
30
|
+
"openai>=2.44.0",
|
|
32
31
|
"pyyaml>=6.0.3",
|
|
33
32
|
"questionary>=2.1.1",
|
|
34
|
-
"requests>=2.
|
|
35
|
-
"rich>=
|
|
33
|
+
"requests>=2.34.2",
|
|
34
|
+
"rich>=15.0.0",
|
|
36
35
|
"toml>=0.10.2",
|
|
37
|
-
"typer>=0.
|
|
36
|
+
"typer>=0.26.8",
|
|
38
37
|
]
|
|
39
38
|
[project.urls]
|
|
40
39
|
Homepage = "https://github.com/S4NKALP/devgen"
|
|
@@ -50,6 +49,6 @@ include = ["devgen*"]
|
|
|
50
49
|
|
|
51
50
|
[dependency-groups]
|
|
52
51
|
dev = [
|
|
53
|
-
"pre-commit>=4.
|
|
54
|
-
"ruff>=0.
|
|
52
|
+
"pre-commit>=4.6.0",
|
|
53
|
+
"ruff>=0.15.20",
|
|
55
54
|
]
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import time
|
|
2
|
-
|
|
3
|
-
from google import genai
|
|
4
|
-
from google.genai import types
|
|
5
|
-
|
|
6
|
-
from devgen.providers.base import BaseProvider
|
|
7
|
-
from devgen.utils import is_token_limit_error
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class GeminiProvider(BaseProvider):
|
|
11
|
-
"""Generates content using Google's Gemini models."""
|
|
12
|
-
|
|
13
|
-
DISPLAY_NAME = "Gemini"
|
|
14
|
-
DEFAULT_MODEL = "gemini-2.5-flash"
|
|
15
|
-
|
|
16
|
-
def _generate(self, prompt, api_key, model, **kwargs):
|
|
17
|
-
max_retries = kwargs.get("max_retries", 5)
|
|
18
|
-
base_delay = kwargs.get("retry_delay", 10) # 10s base for 429 backoff
|
|
19
|
-
client = genai.Client(api_key=api_key)
|
|
20
|
-
|
|
21
|
-
for attempt in range(max_retries + 1):
|
|
22
|
-
try:
|
|
23
|
-
response = client.models.generate_content(
|
|
24
|
-
model=model,
|
|
25
|
-
contents=prompt,
|
|
26
|
-
config=types.GenerateContentConfig(
|
|
27
|
-
temperature=kwargs.get("temperature", 0.7),
|
|
28
|
-
top_p=kwargs.get("top_p", 0.95),
|
|
29
|
-
top_k=kwargs.get("top_k", 40),
|
|
30
|
-
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
|
31
|
-
),
|
|
32
|
-
)
|
|
33
|
-
return response.text or ""
|
|
34
|
-
except Exception as e:
|
|
35
|
-
if is_token_limit_error(e):
|
|
36
|
-
raise # base class handles with friendly message
|
|
37
|
-
if self._is_quota_error(e) and attempt < max_retries:
|
|
38
|
-
delay = base_delay * (2**attempt)
|
|
39
|
-
print(
|
|
40
|
-
f"\n[Gemini] Quota limit hit (429). This is often a temporary "
|
|
41
|
-
f"rate limit (RPM). Waiting {delay}s and retrying... "
|
|
42
|
-
f"(Attempt {attempt + 1}/{max_retries})"
|
|
43
|
-
)
|
|
44
|
-
time.sleep(delay)
|
|
45
|
-
continue
|
|
46
|
-
if self._is_quota_error(e):
|
|
47
|
-
raise RuntimeError(
|
|
48
|
-
"Gemini API quota exhausted (429) after multiple retries.\n\n"
|
|
49
|
-
"POSSIBLE CAUSES:\n"
|
|
50
|
-
"1. Rate Limit (RPM/RPD): You are sending requests too fast for the Free Tier.\n"
|
|
51
|
-
"2. Daily Limit: You have reached the maximum free requests allowed for today (typically 1,500).\n"
|
|
52
|
-
"3. Credit/Billing: If on a paid plan, your credit may be exhausted.\n\n"
|
|
53
|
-
"Please check your usage metrics at: https://aistudio.google.com/app/usage"
|
|
54
|
-
) from e
|
|
55
|
-
raise
|
|
56
|
-
|
|
57
|
-
@staticmethod
|
|
58
|
-
def _is_quota_error(error: Exception) -> bool:
|
|
59
|
-
text = str(error).upper()
|
|
60
|
-
return any(
|
|
61
|
-
marker in text
|
|
62
|
-
for marker in ("429", "RESOURCE_EXHAUSTED", "QUOTA_EXCEEDED", "THROTTLED")
|
|
63
|
-
)
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
from openai import OpenAI
|
|
2
|
-
|
|
3
|
-
from devgen.providers.base import BaseProvider
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class OpenaiProvider(BaseProvider):
|
|
7
|
-
"""Generates content using OpenAI's Chat Completions API."""
|
|
8
|
-
|
|
9
|
-
DISPLAY_NAME = "OpenAI"
|
|
10
|
-
DEFAULT_MODEL = "gpt-4o"
|
|
11
|
-
|
|
12
|
-
def _generate(self, prompt, api_key, model, **kwargs):
|
|
13
|
-
kwargs.pop("debug", None)
|
|
14
|
-
client = OpenAI(api_key=api_key)
|
|
15
|
-
response = client.chat.completions.create(
|
|
16
|
-
model=model,
|
|
17
|
-
messages=[{"role": "user", "content": prompt}],
|
|
18
|
-
**kwargs,
|
|
19
|
-
)
|
|
20
|
-
return response.choices[0].message.content or ""
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
from openai import OpenAI
|
|
2
|
-
|
|
3
|
-
from devgen.providers.base import BaseProvider
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class OpenrouterProvider(BaseProvider):
|
|
7
|
-
"""Generates content using OpenRouter (OpenAI-compatible API)."""
|
|
8
|
-
|
|
9
|
-
DISPLAY_NAME = "OpenRouter"
|
|
10
|
-
BASE_URL = "https://openrouter.ai/api/v1"
|
|
11
|
-
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
|
12
|
-
|
|
13
|
-
def _generate(self, prompt, api_key, model, **kwargs):
|
|
14
|
-
kwargs.pop("debug", None)
|
|
15
|
-
client = OpenAI(base_url=self.BASE_URL, api_key=api_key)
|
|
16
|
-
response = client.chat.completions.create(
|
|
17
|
-
model=model,
|
|
18
|
-
messages=[{"role": "user", "content": prompt}],
|
|
19
|
-
extra_headers={
|
|
20
|
-
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
|
21
|
-
"X-Title": "devgen CLI",
|
|
22
|
-
},
|
|
23
|
-
**kwargs,
|
|
24
|
-
)
|
|
25
|
-
return response.choices[0].message.content
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
You write Conventional Commits. Output ONLY the message — no preamble, no labels, no quotes, no markdown.
|
|
2
|
-
|
|
3
|
-
Format:
|
|
4
|
-
<type>(<scope>): <summary>
|
|
5
|
-
[optional 1-2 line body on WHY]
|
|
6
|
-
|
|
7
|
-
types: feat fix refactor perf docs test build ci chore style
|
|
8
|
-
scope: {{ group_name }}{% if use_emoji %} (prefix summary with one matching emoji){% endif %}
|
|
9
|
-
|
|
10
|
-
Example:
|
|
11
|
-
feat(auth): add login rate limit
|
|
12
|
-
Prevents brute-force attacks.
|
|
13
|
-
|
|
14
|
-
{{ diff_text }}{{ context }}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|