devgen-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional
5
+
6
+
7
+ class LicenseGenerator:
8
+ """Generates license files from templates."""
9
+
10
+ def __init__(self):
11
+ self.templates_dir = (
12
+ Path(__file__).parent.parent / "prompt_templates" / "licenses"
13
+ )
14
+
15
+ def list_licenses(self) -> List[Dict[str, str]]:
16
+ """Lists available license templates."""
17
+ licenses = []
18
+ if not self.templates_dir.exists():
19
+ return []
20
+
21
+ for file_path in self.templates_dir.glob("*.json"):
22
+ try:
23
+ with file_path.open("r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ licenses.append(
26
+ {
27
+ "key": data.get("key", file_path.stem),
28
+ "name": data.get("name", "Unknown License"),
29
+ "description": data.get("description", ""),
30
+ }
31
+ )
32
+ except Exception:
33
+ continue
34
+
35
+ # Sort by name
36
+ return sorted(licenses, key=lambda x: x["name"])
37
+
38
+ def get_license_template(self, key: str) -> Optional[Dict]:
39
+ """Loads a specific license template by key."""
40
+ # We assume key matches filename for simplicity, or we search.
41
+ # Based on file listing, filenames match keys (e.g. mit.json -> mit)
42
+ file_path = self.templates_dir / f"{key}.json"
43
+ if not file_path.exists():
44
+ return None
45
+
46
+ try:
47
+ with file_path.open("r", encoding="utf-8") as f:
48
+ return json.load(f)
49
+ except Exception:
50
+ return None
51
+
52
+ def render_license(self, key: str, author: str, year: str = "") -> str:
53
+ """Renders the license content with placeholders replaced."""
54
+ template_data = self.get_license_template(key)
55
+ if not template_data:
56
+ raise ValueError(f"License template '{key}' not found.")
57
+
58
+ content = template_data.get("template", "")
59
+
60
+ if not year:
61
+ year = str(datetime.now().year)
62
+
63
+ # Replace placeholders
64
+ # Support various formats found in templates
65
+ year_placeholders = ["[year]", "[yyyy]", "{{year}}", "<year>"]
66
+ author_placeholders = [
67
+ "[fullname]",
68
+ "[name of copyright owner]",
69
+ "{{author}}",
70
+ "<name of author>",
71
+ "[author]",
72
+ ]
73
+
74
+ for p in year_placeholders:
75
+ content = content.replace(p, year)
76
+
77
+ for p in author_placeholders:
78
+ content = content.replace(p, author)
79
+
80
+ return content
@@ -0,0 +1,66 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ from devgen.modules.changelog_generator import ChangelogGenerator
5
+ from devgen.utils import configure_logger
6
+
7
+
8
+ class ReleaseNotesGenerator(ChangelogGenerator):
9
+ """Generate short, clean release notes from git history."""
10
+
11
+ SECTION_EMOJIS = {
12
+ "BREAKING CHANGES": "⚠️",
13
+ "Features": "✨",
14
+ "Bug Fixes": "🐛",
15
+ "Documentation": "📚",
16
+ "Refactor": "♻️",
17
+ "Tests": "✅",
18
+ "Chore": "🔧",
19
+ "Style": "✨",
20
+ "Other Changes": "🧹",
21
+ }
22
+
23
+ def __init__(self, logger=None):
24
+ super().__init__(logger or configure_logger("devgen.releasenotes"))
25
+
26
+ def generate_release_markdown(self, groups, version="Unreleased"):
27
+ """Generate human-friendly release notes."""
28
+ date_str = datetime.now().strftime("%Y-%m-%d")
29
+ md = [f"## 🚀 Release {version} — {date_str}\n"]
30
+
31
+ order = [
32
+ "BREAKING CHANGES",
33
+ "Features",
34
+ "Bug Fixes",
35
+ "Documentation",
36
+ "Other Changes",
37
+ ]
38
+
39
+ for section in order:
40
+ commits = groups.get(section)
41
+ if not commits:
42
+ continue
43
+
44
+ emoji = self.SECTION_EMOJIS.get(section, "")
45
+ md.append(f"### {emoji} {section}")
46
+
47
+ for c in commits:
48
+ scope = f"**{c['scope']}**: " if c["scope"] else ""
49
+ md.append(f"- {scope}{c['subject']}")
50
+
51
+ md.append("")
52
+
53
+ return "\n".join(md)
54
+
55
+ def run(self, output_file="RELEASE-NOTES.md", version="Unreleased", from_ref=""):
56
+ """Main logic: get commits → parse → generate → write."""
57
+ raw_commits = self.get_commits(from_ref)
58
+ if not raw_commits or not raw_commits[0]:
59
+ print("❗ No commits found for release notes.")
60
+ return
61
+
62
+ parsed = self.parse_commits(raw_commits)
63
+ md = self.generate_release_markdown(parsed, version=version)
64
+
65
+ Path(output_file).write_text(md, encoding="utf-8")
66
+ print(f" Release notes written to {output_file}")
@@ -0,0 +1,21 @@
1
+ from devgen.providers.anthropic import AnthropicProvider
2
+ from devgen.providers.gemini import GeminiProvider
3
+ from devgen.providers.huggingface import HuggingfaceProvider
4
+ from devgen.providers.openai import OpenaiProvider
5
+ from devgen.providers.openrouter import OpenrouterProvider
6
+
7
+
8
+ def get_provider(name):
9
+ name_lower = name.lower()
10
+ if name_lower == "gemini":
11
+ return GeminiProvider()
12
+ elif name_lower == "openai":
13
+ return OpenaiProvider()
14
+ elif name_lower == "huggingface":
15
+ return HuggingfaceProvider()
16
+ elif name_lower == "openrouter":
17
+ return OpenrouterProvider()
18
+ elif name_lower == "anthropic":
19
+ return AnthropicProvider()
20
+
21
+ raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
@@ -0,0 +1,23 @@
1
+ import anthropic
2
+
3
+
4
+ class AnthropicProvider:
5
+ """Generates content using Anthropic's Claude models."""
6
+
7
+ def generate(
8
+ self, prompt: str, api_key: str, model: str = "claude-3-opus-20240229", **kwargs
9
+ ) -> str:
10
+ """Generates a response using the Anthropic API."""
11
+ if not api_key:
12
+ raise ValueError("Anthropic API key is required.")
13
+
14
+ try:
15
+ client = anthropic.Anthropic(api_key=api_key)
16
+ message = client.messages.create(
17
+ model=model,
18
+ max_tokens=1024,
19
+ messages=[{"role": "user", "content": prompt}],
20
+ )
21
+ return message.content[0].text.strip()
22
+ except Exception as e:
23
+ raise RuntimeError(f"Anthropic generation failed: {e}")
@@ -0,0 +1,24 @@
1
+ import google.generativeai as genai
2
+
3
+
4
+ class GeminiProvider:
5
+ """Generates content using Google's Gemini models."""
6
+
7
+ def generate(
8
+ self, prompt: str, api_key: str, model: str = "gemini-pro", **kwargs
9
+ ) -> str:
10
+ """Generates a response using the Gemini API."""
11
+ if not api_key:
12
+ raise ValueError("Gemini API key is required.")
13
+
14
+ genai.configure(api_key=api_key)
15
+
16
+ # Handle model name mapping if needed, or trust user input
17
+ # gemini-pro is a common default
18
+
19
+ try:
20
+ model_instance = genai.GenerativeModel(model)
21
+ response = model_instance.generate_content(prompt)
22
+ return response.text.strip()
23
+ except Exception as e:
24
+ raise RuntimeError(f"Gemini generation failed: {e}")
@@ -0,0 +1,45 @@
1
+ import requests
2
+
3
+
4
+ class HuggingfaceProvider:
5
+ """Generates content using Hugging Face Inference API."""
6
+
7
+ API_URL_TEMPLATE = "https://api-inference.huggingface.co/models/{model}"
8
+
9
+ def generate(
10
+ self,
11
+ prompt: str,
12
+ api_key: str,
13
+ model: str = "mistralai/Mistral-7B-Instruct-v0.2",
14
+ **kwargs,
15
+ ) -> str:
16
+ """Generates a response using Hugging Face API."""
17
+ if not api_key:
18
+ raise ValueError("Hugging Face API token is required.")
19
+
20
+ api_url = self.API_URL_TEMPLATE.format(model=model)
21
+ headers = {"Authorization": f"Bearer {api_key}"}
22
+
23
+ # HF models often expect specific prompting formats, but we'll send raw prompt
24
+ # Some models are text-generation, some are conversational.
25
+ # Assuming text-generation for generic usage.
26
+
27
+ payload = {
28
+ "inputs": prompt,
29
+ "parameters": {"max_new_tokens": 500, "return_full_text": False},
30
+ }
31
+
32
+ try:
33
+ response = requests.post(api_url, headers=headers, json=payload)
34
+ response.raise_for_status()
35
+ result = response.json()
36
+
37
+ if isinstance(result, list) and "generated_text" in result[0]:
38
+ return result[0]["generated_text"].strip()
39
+ elif isinstance(result, dict) and "error" in result:
40
+ raise RuntimeError(f"Hugging Face API error: {result['error']}")
41
+ else:
42
+ return str(result)
43
+
44
+ except Exception as e:
45
+ raise RuntimeError(f"Hugging Face generation failed: {e}")
@@ -0,0 +1,48 @@
1
+ from openai import OpenAI
2
+
3
+
4
+ class OpenaiProvider:
5
+ """Generates a response string using the OpenAI ChatCompletion API based on the provided prompt and parameters. This method initializes an OpenAI client with the given API key, sends a chat completion request with specified model and additional parameters, and returns the content of the generated message.
6
+
7
+ Args:
8
+ prompt (str): The input prompt to generate a response for.
9
+ api_key (str): The API key used to authenticate with the OpenAI service.
10
+ model (str, optional): The model to use for generation; defaults to "gpt-4o".
11
+ **kwargs: Additional keyword arguments to customize the API request (e.g., temperature).
12
+
13
+ Returns:
14
+ str: The content of the generated response message.
15
+ """
16
+
17
+ DEFAULT_MODEL = "gpt-4o"
18
+
19
+ def generate(
20
+ self, prompt: str, api_key: str, model: str | None = None, **kwargs
21
+ ) -> str:
22
+ """Generates a response from the OpenAI ChatCompletion API based on the provided prompt and parameters.
23
+
24
+ Creates a client instance with the specified API key, sends a chat completion request using the selected model and additional parameters, and returns the generated message content as a string.
25
+
26
+ Args:
27
+ prompt (str): Prompt input.
28
+ api_key (str): OpenAI API key.
29
+ model (str, optional): Model to use (default: gpt-4o).
30
+ **kwargs: Additional OpenAI ChatCompletion parameters (e.g., temperature).
31
+
32
+ Returns:
33
+ str: Generated response content.
34
+ """
35
+ # 1. Create a client instance with the API key.
36
+ try:
37
+ client = OpenAI(api_key=api_key)
38
+ except Exception as e:
39
+ # Add error handling if the client fails to initialize
40
+ raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
41
+
42
+ # 2. Use the modern API syntax: client.chat.completions.create
43
+ response = client.chat.completions.create(
44
+ model=model or self.DEFAULT_MODEL,
45
+ messages=[{"role": "user", "content": prompt}],
46
+ **kwargs,
47
+ )
48
+ return response.choices[0].message.content.strip()
@@ -0,0 +1,33 @@
1
+ from openai import OpenAI
2
+
3
+
4
+ class OpenrouterProvider:
5
+ """Generates content using OpenRouter (OpenAI-compatible API)."""
6
+
7
+ BASE_URL = "https://openrouter.ai/api/v1"
8
+
9
+ def generate(
10
+ self, prompt: str, api_key: str, model: str = "openai/gpt-3.5-turbo", **kwargs
11
+ ) -> str:
12
+ """Generates a response using OpenRouter."""
13
+ if not api_key:
14
+ raise ValueError("OpenRouter API key is required.")
15
+
16
+ try:
17
+ client = OpenAI(
18
+ base_url=self.BASE_URL,
19
+ api_key=api_key,
20
+ )
21
+
22
+ response = client.chat.completions.create(
23
+ model=model,
24
+ messages=[{"role": "user", "content": prompt}],
25
+ extra_headers={
26
+ "HTTP-Referer": "https://github.com/S4NKALP/devgen", # Optional
27
+ "X-Title": "devgen CLI", # Optional
28
+ },
29
+ **kwargs,
30
+ )
31
+ return response.choices[0].message.content.strip()
32
+ except Exception as e:
33
+ raise RuntimeError(f"OpenRouter generation failed: {e}")
devgen/utils.py ADDED
@@ -0,0 +1,198 @@
1
+ import logging
2
+ import re
3
+ import subprocess
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ import yaml
9
+ from jinja2 import Environment, FileSystemLoader
10
+
11
+
12
+ def ensure_log_directory() -> Path:
13
+ log_dir = Path.home() / ".cache" / "devgen"
14
+ log_dir.mkdir(parents=True, exist_ok=True)
15
+ return log_dir
16
+
17
+
18
+ def get_main_log_path() -> Path:
19
+ return ensure_log_directory() / "devgen.log"
20
+
21
+
22
+ def get_commit_dry_run_path() -> Path:
23
+ return ensure_log_directory() / "commit_dry_run.md"
24
+
25
+
26
+ def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
27
+ path = Path(file_path)
28
+ if not path.exists():
29
+ return False
30
+ return (time.time() - path.stat().st_mtime) <= max_age_minutes * 60
31
+
32
+
33
+ def sanitize_ai_commit_message(raw_text: str) -> str:
34
+ lines = raw_text.strip().split("\n")
35
+ cleaned_lines = []
36
+ in_block = False
37
+ # Regex for conventional commit header
38
+ header_pattern = re.compile(
39
+ r"^(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*"
40
+ )
41
+
42
+ for line in lines:
43
+ stripped = line.strip()
44
+ if in_block:
45
+ if header_pattern.match(stripped) or "**Sponsor**" in line:
46
+ break
47
+ cleaned_lines.append(line)
48
+ elif header_pattern.match(stripped):
49
+ in_block = True
50
+ cleaned_lines.append(line)
51
+
52
+ return "\n".join(cleaned_lines).strip() if cleaned_lines else ""
53
+
54
+
55
+ def parse_markdown_sections(
56
+ filepath: Path | str, marker_pattern: str
57
+ ) -> dict[str, str]:
58
+ path = Path(filepath)
59
+ if not path.exists():
60
+ return {}
61
+
62
+ with path.open(encoding="utf-8") as f:
63
+ content = f.read()
64
+
65
+ results = {}
66
+ matches = re.findall(marker_pattern, content, re.DOTALL)
67
+ for key, value in matches:
68
+ results[key] = value.strip()
69
+ return results
70
+
71
+
72
+ def extract_commit_messages(filepath: Path | str) -> dict[str, str]:
73
+ pattern = r"## Group: `(.*?)`\s*```md\n(.*?)\n```"
74
+ return parse_markdown_sections(filepath, pattern)
75
+
76
+
77
+ def configure_logger(
78
+ name: str = "devgen", log_file: Optional[Path | str] = None
79
+ ) -> logging.Logger:
80
+ logger = logging.getLogger(name)
81
+ logger.setLevel(logging.INFO)
82
+
83
+ if logger.hasHandlers():
84
+ logger.handlers.clear()
85
+
86
+ formatter = logging.Formatter(
87
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
88
+ )
89
+
90
+ # Console handler
91
+ ch = logging.StreamHandler()
92
+ ch.setFormatter(formatter)
93
+ logger.addHandler(ch)
94
+
95
+ # File handler
96
+ if log_file:
97
+ path = Path(log_file)
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ fh = logging.FileHandler(path, mode="w", encoding="utf-8")
100
+ fh.setFormatter(formatter)
101
+ logger.addHandler(fh)
102
+
103
+ return logger
104
+
105
+
106
+ def get_git_staged_files() -> list[str]:
107
+ try:
108
+ res = subprocess.run(
109
+ ["git", "diff", "--name-only", "--cached"],
110
+ capture_output=True,
111
+ text=True,
112
+ check=True,
113
+ )
114
+ return [f for f in res.stdout.splitlines() if f.strip()]
115
+ except subprocess.CalledProcessError:
116
+ return []
117
+
118
+
119
+ def read_file_content(filepath: Path | str) -> Optional[str]:
120
+ path = Path(filepath)
121
+ if path.exists():
122
+ return path.read_text(encoding="utf-8")
123
+ return None
124
+
125
+
126
+ def delete_file(filepath: Path | str) -> bool:
127
+ path = Path(filepath)
128
+ if path.exists():
129
+ path.unlink()
130
+ return True
131
+ return False
132
+
133
+
134
+ def load_template_env(sub_dir: str) -> Environment:
135
+ template_dir = Path(__file__).parent / "prompt_templates" / sub_dir
136
+ return Environment(loader=FileSystemLoader(template_dir))
137
+
138
+
139
+ def load_config() -> Dict[str, Any]:
140
+ config_path = Path.home() / ".devgen.yaml"
141
+
142
+ if not config_path.exists():
143
+ default_config = {
144
+ "provider": "gemini",
145
+ "model": "gemini-2.5-flash",
146
+ "api_key": "",
147
+ "emoji": True,
148
+ }
149
+ try:
150
+ with config_path.open("w", encoding="utf-8") as f:
151
+ yaml.dump(default_config, f, default_flow_style=False)
152
+ # We don't print here to avoid noise during normal execution
153
+ except Exception as e:
154
+ print(f"Warning: Failed to create default config at {config_path}: {e}")
155
+ return {}
156
+
157
+ try:
158
+ with config_path.open("r", encoding="utf-8") as f:
159
+ return yaml.safe_load(f) or {}
160
+ except Exception as e:
161
+ print(f"Warning: Failed to load config from {config_path}: {e}")
162
+ return {}
163
+
164
+
165
+ __all__ = [
166
+ "ensure_log_directory",
167
+ "get_main_log_path",
168
+ "get_commit_dry_run_path",
169
+ "is_file_recent",
170
+ "sanitize_ai_commit_message",
171
+ "extract_commit_messages",
172
+ "configure_logger",
173
+ "get_git_staged_files",
174
+ "read_file_content",
175
+ "delete_file",
176
+ "load_template_env",
177
+ "load_config",
178
+ "get_questionary_style",
179
+ ]
180
+
181
+
182
+ def get_questionary_style():
183
+ from questionary import Style
184
+
185
+ return Style(
186
+ [
187
+ ("qmark", "fg:#673ab7 bold"), # Token.QuestionMark
188
+ ("question", "bold"), # Token.Question
189
+ ("answer", "fg:#f44336 bold"), # Token.Answer
190
+ ("pointer", "fg:#673ab7 bold"), # Token.Pointer
191
+ ("highlighted", "fg:#673ab7 bold"), # Token.Selected
192
+ ("selected", "fg:#cc5454"), # Token.SelectedItem
193
+ ("separator", "fg:#cc5454"), # Token.Separator
194
+ ("instruction", ""), # Token.Instruction
195
+ ("text", ""), # Token.Text
196
+ ("disabled", "fg:#858585 italic"), # Token.Disabled
197
+ ]
198
+ )