devgen-cli 0.2.7__tar.gz → 0.2.9__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.
Files changed (58) hide show
  1. {devgen_cli-0.2.7/devgen_cli.egg-info → devgen_cli-0.2.9}/PKG-INFO +1 -2
  2. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/commit.py +1 -1
  3. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/config.py +6 -0
  4. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/setup.py +9 -6
  5. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/changelog_generator.py +9 -5
  6. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/commit_generator.py +17 -26
  7. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/diff_builder.py +5 -4
  8. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/git_ops.py +7 -4
  9. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/gitignore_generator.py +9 -0
  10. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/anthropic.py +3 -1
  11. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/base.py +2 -1
  12. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/gemini.py +1 -1
  13. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/huggingface.py +3 -1
  14. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/ollama.py +13 -9
  15. devgen_cli-0.2.9/devgen/providers/openai.py +25 -0
  16. devgen_cli-0.2.9/devgen/providers/openrouter.py +30 -0
  17. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/utils.py +80 -21
  18. {devgen_cli-0.2.7 → devgen_cli-0.2.9/devgen_cli.egg-info}/PKG-INFO +1 -2
  19. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/requires.txt +0 -1
  20. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/pyproject.toml +1 -2
  21. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/setup.py +1 -1
  22. devgen_cli-0.2.7/devgen/providers/openai.py +0 -20
  23. devgen_cli-0.2.7/devgen/providers/openrouter.py +0 -25
  24. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/LICENSE +0 -0
  25. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/MANIFEST.in +0 -0
  26. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/README.md +0 -0
  27. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/__init__.py +0 -0
  28. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/ai.py +0 -0
  29. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/__init__.py +0 -0
  30. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/changelog.py +0 -0
  31. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/gitignore.py +0 -0
  32. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/license.py +0 -0
  33. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/main.py +0 -0
  34. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/release.py +0 -0
  35. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/__init__.py +0 -0
  36. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/changelog_sections.py +0 -0
  37. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/license_generator.py +0 -0
  38. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/release_note_generator.py +0 -0
  39. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/__init__.py +0 -0
  40. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/commit/commit_message.tpl +0 -0
  41. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/agpl-3.0.json +0 -0
  42. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/apache-2.0.json +0 -0
  43. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/bsd-2-clause.json +0 -0
  44. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/bsd-3-clause.json +0 -0
  45. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/bsl-1.0.json +0 -0
  46. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/cc0-1.0.json +0 -0
  47. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/epl-2.0.json +0 -0
  48. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/gpl-2.0.json +0 -0
  49. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/gpl-3.0.json +0 -0
  50. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/lgpl-2.1.json +0 -0
  51. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/mit.json +0 -0
  52. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/mpl-2.0.json +0 -0
  53. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/unlicense.json +0 -0
  54. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/SOURCES.txt +0 -0
  55. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/dependency_links.txt +0 -0
  56. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/entry_points.txt +0 -0
  57. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/top_level.txt +0 -0
  58. {devgen_cli-0.2.7 → devgen_cli-0.2.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devgen-cli
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: A collection of developer tools
5
5
  Home-page: https://github.com/S4NKALP/devgen
6
6
  Author: Sankalp Tharu
@@ -22,7 +22,6 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: anthropic>=0.75.0
24
24
  Requires-Dist: google-genai>=0.1.0
25
- Requires-Dist: jinja2>=3.1.6
26
25
  Requires-Dist: openai>=2.11.0
27
26
  Requires-Dist: pyyaml>=6.0.3
28
27
  Requires-Dist: questionary>=2.1.1
@@ -120,7 +120,7 @@ def validate() -> None:
120
120
  for f in staged:
121
121
  typer.echo(f"- {f}")
122
122
  else:
123
- typer.secho("[i] No staged files.", fg=typer.colors.RED)
123
+ typer.secho("[i] No staged files.", fg=typer.colors.YELLOW)
124
124
 
125
125
 
126
126
  @app.command("undo")
@@ -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()
@@ -85,13 +85,16 @@ def setup_config() -> None:
85
85
  raise typer.Exit(code=130)
86
86
  ollama_host = ollama_host_input.strip() or ollama_host
87
87
 
88
- # Save Config
88
+ # Save Config — merge with existing to preserve extra keys
89
89
  new_config = {
90
- "provider": provider,
91
- "model": model,
92
- "api_key": api_key,
93
- "emoji": emoji,
94
- "ollama_host": ollama_host,
90
+ **current_config,
91
+ **{
92
+ "provider": provider,
93
+ "model": model,
94
+ "api_key": api_key,
95
+ "emoji": emoji,
96
+ "ollama_host": ollama_host,
97
+ },
95
98
  }
96
99
 
97
100
  try:
@@ -32,17 +32,21 @@ class ChangelogGenerator:
32
32
  else:
33
33
  cmd = self._resolve_range(to_ref)
34
34
  try:
35
- return run_git_command(cmd).split("\n")
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
 
@@ -145,7 +149,7 @@ class ChangelogGenerator:
145
149
  old = path.read_text(encoding="utf-8")
146
150
  if old.strip().startswith("# CHANGELOG"):
147
151
  header, _, rest = old.partition("\n")
148
- new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
152
+ new_content = f"{header}\n\n{md_content}\n{rest}"
149
153
  else:
150
154
  new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
151
155
  else:
@@ -33,8 +33,8 @@ from devgen.utils import (
33
33
  extract_commit_messages,
34
34
  get_commit_dry_run_path,
35
35
  is_file_recent,
36
- load_template_env,
37
- render_custom_template,
36
+ load_template,
37
+ render_template,
38
38
  sanitize_ai_commit_message,
39
39
  )
40
40
 
@@ -48,6 +48,7 @@ class CacheManager:
48
48
 
49
49
  def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
50
50
  self.path = path
51
+ self.max_age_minutes = max_age_minutes
51
52
 
52
53
  def init_dry_run(self) -> None:
53
54
  self.path.parent.mkdir(parents=True, exist_ok=True)
@@ -59,7 +60,7 @@ class CacheManager:
59
60
  if dry_run:
60
61
  self.init_dry_run()
61
62
  return {}
62
- if not force_rebuild and is_file_recent(self.path):
63
+ if not force_rebuild and is_file_recent(self.path, self.max_age_minutes):
63
64
  return extract_commit_messages(self.path)
64
65
  return {}
65
66
 
@@ -73,13 +74,13 @@ class CommitMessageBuilder:
73
74
 
74
75
  def __init__(
75
76
  self,
76
- template_env,
77
+ template_str: str,
77
78
  config: Dict[str, Any],
78
79
  provider: str,
79
80
  model: str,
80
81
  debug: bool = False,
81
82
  ) -> None:
82
- self.template_env = template_env
83
+ self.template_str = template_str
83
84
  self.config = config
84
85
  self.provider = provider
85
86
  self.model = model
@@ -96,25 +97,14 @@ class CommitMessageBuilder:
96
97
  use_emoji = self.config.get("emoji", True)
97
98
  ollama_host = self.config.get("ollama_host")
98
99
 
99
- if custom_template:
100
- prompt = render_custom_template(
101
- custom_template,
102
- group_name=group,
103
- diff_text=diff,
104
- use_emoji=use_emoji,
105
- context=manifest_context,
106
- )
107
- else:
108
- prompt = (
109
- self.template_env.get_template("commit_message.tpl")
110
- .render(
111
- group_name=group,
112
- diff_text=diff,
113
- use_emoji=use_emoji,
114
- context=manifest_context,
115
- )
116
- .strip()
117
- )
100
+ template = custom_template or self.template_str
101
+ prompt = render_template(
102
+ template,
103
+ group_name=group,
104
+ diff_text=diff,
105
+ use_emoji=use_emoji,
106
+ context=manifest_context,
107
+ ).strip()
118
108
 
119
109
  provider = (
120
110
  extra_kwargs.get("provider") or self.config.get("provider") or self.provider
@@ -197,10 +187,10 @@ class CommitEngine:
197
187
  ),
198
188
  logger=self.logger,
199
189
  )
200
- self.template_env = load_template_env("commit")
190
+ self.template_str = load_template("commit", "commit_message.tpl")
201
191
  self.cache = CacheManager(get_commit_dry_run_path())
202
192
  self.message_builder = CommitMessageBuilder(
203
- self.template_env,
193
+ self.template_str,
204
194
  self.config,
205
195
  provider=self.provider,
206
196
  model=self.model,
@@ -387,6 +377,7 @@ def run_commit_engine(**kwargs: Any) -> None:
387
377
  sys.exit(0)
388
378
  except Exception as e:
389
379
  logger.error(f"Commit engine failed: {e}", exc_info=True)
380
+ sys.exit(1)
390
381
 
391
382
 
392
383
  __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:
@@ -70,7 +70,7 @@ class DiffBuilder:
70
70
  def _partition(self, files: List[str]) -> tuple[list[str], list[str]]:
71
71
  summary, to_diff = [], []
72
72
  for f in files:
73
- if any(p in f for p in IGNORED_DIFF_PATTERNS):
73
+ if Path(f).name in IGNORED_DIFF_PATTERNS:
74
74
  summary.append(f"[METADATA UPDATED] {f}")
75
75
  else:
76
76
  to_diff.append(f)
@@ -128,7 +128,8 @@ class FileGrouper:
128
128
  deepest = max(candidates, key=lambda p: len(Path(p).parts))
129
129
  parent_path = str(Path(deepest).parent)
130
130
  new_key = "root" if parent_path == "." else parent_path
131
- groups.setdefault(new_key, []).extend(groups.pop(deepest))
131
+ files = groups.pop(deepest)
132
+ groups.setdefault(new_key, []).extend(files)
132
133
  return groups
133
134
 
134
135
 
@@ -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
 
@@ -115,8 +115,11 @@ class GitOperator:
115
115
  self._run(["git", "add", *existing])
116
116
  if deleted:
117
117
  self._run(["git", "rm", *deleted])
118
- except GitError as e:
119
- raise GitError(f"Could not stage files: {e}") from e
118
+ except GitError:
119
+ # Fallback: file state may have changed between check and command.
120
+ # Try a single git add --all for the whole set; git will handle
121
+ # both additions and removals correctly.
122
+ self._run(["git", "add", "--all", "--", *files])
120
123
 
121
124
  def commit(self, message: str) -> None:
122
125
  """``git commit -m`` with the given message."""
@@ -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:
@@ -16,4 +16,6 @@ class AnthropicProvider(BaseProvider):
16
16
  max_tokens=1024,
17
17
  messages=[{"role": "user", "content": prompt}],
18
18
  )
19
- return message.content[0].text
19
+ if not message.content:
20
+ raise RuntimeError(f"Anthropic returned no content for model {model!r}.")
21
+ return message.content[0].text or ""
@@ -53,7 +53,8 @@ class BaseProvider(ABC):
53
53
  )
54
54
  chosen_model = model or self.DEFAULT_MODEL
55
55
  try:
56
- return self._generate(prompt, api_key, chosen_model, **kwargs).strip()
56
+ text = self._generate(prompt, api_key, chosen_model, **kwargs)
57
+ return (text or "").strip()
57
58
  except Exception as e:
58
59
  self._handle_error(e)
59
60
 
@@ -30,7 +30,7 @@ class GeminiProvider(BaseProvider):
30
30
  max_output_tokens=kwargs.get("max_output_tokens", 2048),
31
31
  ),
32
32
  )
33
- return response.text
33
+ return response.text or ""
34
34
  except Exception as e:
35
35
  if is_token_limit_error(e):
36
36
  raise # base class handles with friendly message
@@ -20,7 +20,9 @@ class HuggingfaceProvider(BaseProvider):
20
20
  }
21
21
 
22
22
  try:
23
- response = requests.post(api_url, headers=headers, json=payload)
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,25 +18,29 @@ 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
- kwargs.pop("debug", None)
25
- kwargs.pop("ollama_host", None)
26
27
 
27
- client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
28
- response = client.chat.completions.create(
29
- model=model,
30
- messages=[{"role": "user", "content": prompt}],
31
- **kwargs,
32
- )
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(
36
40
  f"Ollama returned no choices for model {model!r}. "
37
41
  "The model may not be installed locally."
38
42
  )
39
- return response.choices[0].message.content
43
+ return response.choices[0].message.content or ""
40
44
 
41
45
  def _handle_error(self, error):
42
46
  """Override default wrapping — Ollama has no API key to check."""
@@ -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 ""
@@ -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
- path = Path(file_path)
28
- if not path.exists():
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:
@@ -142,6 +141,15 @@ def configure_logger(
142
141
  return logger
143
142
 
144
143
 
144
+ def get_git_root(cwd: Optional[Path] = None) -> Optional[Path]:
145
+ """Return the root directory of the current git repository, or None."""
146
+ try:
147
+ output = run_git_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
148
+ return Path(output) if output else None
149
+ except (subprocess.CalledProcessError, FileNotFoundError):
150
+ return None
151
+
152
+
145
153
  def run_git_command(
146
154
  args: list[str],
147
155
  check: bool = True,
@@ -193,30 +201,74 @@ def get_git_staged_files() -> list[str]:
193
201
 
194
202
 
195
203
  def read_file_content(filepath: Path | str) -> Optional[str]:
196
- path = Path(filepath)
197
- if path.exists():
198
- return path.read_text(encoding="utf-8")
199
- return None
204
+ try:
205
+ return Path(filepath).read_text(encoding="utf-8")
206
+ except FileNotFoundError:
207
+ return None
200
208
 
201
209
 
202
210
  def delete_file(filepath: Path | str) -> bool:
203
- path = Path(filepath)
204
- if path.exists():
205
- path.unlink()
211
+ try:
212
+ Path(filepath).unlink()
206
213
  return True
207
- return False
214
+ except FileNotFoundError:
215
+ return False
208
216
 
209
217
 
210
- def load_template_env(sub_dir: str) -> Environment:
218
+ def load_template(sub_dir: str, name: str) -> str:
219
+ """Load a template file from the templates directory."""
211
220
  template_dir = Path(__file__).parent / "templates" / sub_dir
212
- return Environment(loader=FileSystemLoader(template_dir))
221
+ template_path = template_dir / name
222
+ if not template_path.is_file():
223
+ raise RuntimeError(
224
+ f"Template not found: {template_path}. The installation may be corrupted."
225
+ )
226
+ return template_path.read_text(encoding="utf-8")
227
+
228
+
229
+ def render_template(template_str: str, **context) -> str:
230
+ """Render a simple template with variable substitution and conditionals.
231
+
232
+ Supports:
233
+ - {{ variable }} - variable substitution
234
+ - {% if condition %}...{% endif %} - conditional blocks
235
+ - {% if condition %}...{% else %}...{% endif %} - conditional with else
236
+ """
237
+ result = template_str
238
+
239
+ # Process conditional blocks: {% if var %}...{% endif %}
240
+ # and {% if var %}...{% else %}...{% endif %}
241
+ if_pattern = re.compile(
242
+ r"\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}",
243
+ re.DOTALL,
244
+ )
245
+
246
+ def replace_if(match):
247
+ var_name = match.group(1)
248
+ content = match.group(2)
249
+ value = context.get(var_name)
250
+ if value:
251
+ # Check for else block
252
+ else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
253
+ parts = else_pattern.split(content, 1)
254
+ return parts[0]
255
+ else:
256
+ else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
257
+ parts = else_pattern.split(content, 1)
258
+ return parts[1] if len(parts) > 1 else ""
259
+
260
+ result = if_pattern.sub(replace_if, result)
261
+
262
+ # Process variable substitutions: {{ variable }}
263
+ var_pattern = re.compile(r"\{\{\s*(\w+)\s*\}\}")
264
+
265
+ def replace_var(match):
266
+ var_name = match.group(1)
267
+ return str(context.get(var_name, ""))
213
268
 
269
+ result = var_pattern.sub(replace_var, result)
214
270
 
215
- def render_custom_template(template_str: str, **context) -> str:
216
- """Renders a Jinja2 template from a string."""
217
- env = Environment()
218
- template = env.from_string(template_str)
219
- return template.render(**context)
271
+ return result
220
272
 
221
273
 
222
274
  def load_config() -> Dict[str, Any]:
@@ -241,7 +293,13 @@ def load_config() -> Dict[str, Any]:
241
293
 
242
294
  try:
243
295
  with config_path.open("r", encoding="utf-8") as f:
244
- return yaml.safe_load(f) or {}
296
+ data = yaml.safe_load(f)
297
+ if not isinstance(data, dict):
298
+ print(
299
+ f"Warning: Config at {config_path} is not a dict, using defaults."
300
+ )
301
+ return {}
302
+ return data
245
303
  except Exception as e:
246
304
  print(f"Warning: Failed to load config from {config_path}: {e}")
247
305
  return {}
@@ -318,7 +376,8 @@ __all__ = [
318
376
  "get_git_staged_files",
319
377
  "read_file_content",
320
378
  "delete_file",
321
- "load_template_env",
379
+ "load_template",
380
+ "render_template",
322
381
  "load_config",
323
382
  "get_questionary_style",
324
383
  "is_token_limit_error",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devgen-cli
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: A collection of developer tools
5
5
  Home-page: https://github.com/S4NKALP/devgen
6
6
  Author: Sankalp Tharu
@@ -22,7 +22,6 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: anthropic>=0.75.0
24
24
  Requires-Dist: google-genai>=0.1.0
25
- Requires-Dist: jinja2>=3.1.6
26
25
  Requires-Dist: openai>=2.11.0
27
26
  Requires-Dist: pyyaml>=6.0.3
28
27
  Requires-Dist: questionary>=2.1.1
@@ -1,6 +1,5 @@
1
1
  anthropic>=0.75.0
2
2
  google-genai>=0.1.0
3
- jinja2>=3.1.6
4
3
  openai>=2.11.0
5
4
  pyyaml>=6.0.3
6
5
  questionary>=2.1.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devgen-cli"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "A collection of developer tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,7 +27,6 @@ classifiers = [
27
27
  dependencies = [
28
28
  "anthropic>=0.75.0",
29
29
  "google-genai>=0.1.0",
30
- "jinja2>=3.1.6",
31
30
  "openai>=2.11.0",
32
31
  "pyyaml>=6.0.3",
33
32
  "questionary>=2.1.1",
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
6
6
 
7
7
  setup(
8
8
  name="devgen-cli",
9
- version="0.2.7",
9
+ version="0.2.9",
10
10
  packages=find_packages(),
11
11
  include_package_data=True,
12
12
  install_requires=[
@@ -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
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes