devgen-cli 0.2.4__py3-none-any.whl → 0.2.6__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
@@ -21,8 +21,17 @@ def generate_with_ai(
21
21
  provider_module = import_module(f"devgen.providers.{provider}")
22
22
  class_name = "".join([x.capitalize() for x in provider.split("_")]) + "Provider"
23
23
  provider_class = getattr(provider_module, class_name)
24
- except (ModuleNotFoundError, AttributeError) as e:
25
- raise ImportError(f"Provider `{provider}` not found or invalid: {e}") from e
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
26
35
 
27
36
  provider_instance = provider_class()
28
37
  return provider_instance.generate(prompt, api_key=api_key, model=model, **kwargs)
devgen/cli/commit.py CHANGED
@@ -58,12 +58,28 @@ def run_commit(
58
58
  help="Review/edit commit message before committing.",
59
59
  ),
60
60
  ] = False,
61
+ max_groups: Annotated[
62
+ int | None,
63
+ typer.Option(
64
+ "--max-groups",
65
+ help="Maximum number of commit groups. Lower this if you hit token-limit errors.",
66
+ ),
67
+ ] = None,
68
+ max_diff_size: Annotated[
69
+ int | None,
70
+ typer.Option(
71
+ "--max-diff-size",
72
+ help="Maximum diff size in chars per group (default 8000). "
73
+ "Lower this if you hit token-limit errors.",
74
+ ),
75
+ ] = None,
61
76
  ) -> None:
62
77
  log_file = get_main_log_path()
63
78
  logger = configure_logger("devgen.cli.commit", log_file, console=debug)
64
79
  logger.info(f"Log file: {log_file}")
65
80
  logger.info(
66
- f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, check={check}"
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}"
67
83
  )
68
84
 
69
85
  run_commit_engine(
@@ -73,6 +89,8 @@ def run_commit(
73
89
  force_rebuild=force_rebuild,
74
90
  check=check,
75
91
  logger=logger,
92
+ max_groups=max_groups,
93
+ max_diff_size=max_diff_size,
76
94
  )
77
95
 
78
96
 
devgen/cli/config.py CHANGED
@@ -62,7 +62,14 @@ def edit_config(
62
62
  if key == "provider":
63
63
  value = questionary.select(
64
64
  "Select AI Provider:",
65
- choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
65
+ choices=[
66
+ "gemini",
67
+ "openai",
68
+ "huggingface",
69
+ "openrouter",
70
+ "anthropic",
71
+ "ollama",
72
+ ],
66
73
  default=str(current_val) if current_val else "gemini",
67
74
  style=style,
68
75
  ).ask()
@@ -117,7 +124,14 @@ def set_config() -> None:
117
124
  # Questions
118
125
  provider = questionary.select(
119
126
  "Select AI Provider:",
120
- choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
127
+ choices=[
128
+ "gemini",
129
+ "openai",
130
+ "huggingface",
131
+ "openrouter",
132
+ "anthropic",
133
+ "ollama",
134
+ ],
121
135
  default=config.get("provider", "gemini"),
122
136
  style=style,
123
137
  ).ask()
@@ -151,12 +165,24 @@ def set_config() -> None:
151
165
  raise typer.Exit(code=130)
152
166
  emoji = emoji_choice == "Yes"
153
167
 
168
+ ollama_host = config.get("ollama_host", "http://localhost:11434")
169
+ if provider == "ollama":
170
+ ollama_host_input = questionary.text(
171
+ "Ollama server URL:",
172
+ default=ollama_host,
173
+ style=style,
174
+ ).ask()
175
+ if ollama_host_input is None:
176
+ raise typer.Exit(code=130)
177
+ ollama_host = ollama_host_input.strip() or ollama_host
178
+
154
179
  # Save Config
155
180
  new_config = {
156
181
  "provider": provider,
157
182
  "model": model,
158
183
  "api_key": api_key,
159
184
  "emoji": emoji,
185
+ "ollama_host": ollama_host,
160
186
  }
161
187
 
162
188
  # Merge with existing config to preserve other keys?
devgen/cli/main.py CHANGED
@@ -48,13 +48,21 @@ def _version_callback(value: bool) -> None:
48
48
  typer.echo(f"devgen version: {version}")
49
49
  else:
50
50
  typer.secho(
51
- "Error: Version not found in pyproject.toml",
51
+ "Could not determine version: no `project.version` or "
52
+ "`tool.poetry.version` found in pyproject.toml.",
52
53
  fg=typer.colors.RED,
53
54
  err=True,
54
55
  )
55
- except (FileNotFoundError, KeyError, Exception) as e:
56
+ except FileNotFoundError:
56
57
  typer.secho(
57
- f"Error: Could not determine version: {e}",
58
+ f"pyproject.toml not found at {pyproject_path}. "
59
+ "The installation may be corrupt.",
60
+ fg=typer.colors.RED,
61
+ err=True,
62
+ )
63
+ except toml.TomlDecodeError as e:
64
+ typer.secho(
65
+ f"pyproject.toml is not valid TOML: {e}",
58
66
  fg=typer.colors.RED,
59
67
  err=True,
60
68
  )
devgen/cli/setup.py CHANGED
@@ -33,7 +33,14 @@ def setup_config() -> None:
33
33
  # Questions
34
34
  provider = questionary.select(
35
35
  "Select AI Provider:",
36
- choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
36
+ choices=[
37
+ "gemini",
38
+ "openai",
39
+ "huggingface",
40
+ "openrouter",
41
+ "anthropic",
42
+ "ollama",
43
+ ],
37
44
  default=current_config.get("provider", "gemini"),
38
45
  style=style,
39
46
  ).ask()
@@ -67,12 +74,24 @@ def setup_config() -> None:
67
74
  raise typer.Exit(code=130)
68
75
  emoji = emoji_choice == "Yes"
69
76
 
77
+ ollama_host = current_config.get("ollama_host", "http://localhost:11434")
78
+ if provider == "ollama":
79
+ ollama_host_input = questionary.text(
80
+ "Ollama server URL:",
81
+ default=ollama_host,
82
+ style=style,
83
+ ).ask()
84
+ if ollama_host_input is None:
85
+ raise typer.Exit(code=130)
86
+ ollama_host = ollama_host_input.strip() or ollama_host
87
+
70
88
  # Save Config
71
89
  new_config = {
72
90
  "provider": provider,
73
91
  "model": model,
74
92
  "api_key": api_key,
75
93
  "emoji": emoji,
94
+ "ollama_host": ollama_host,
76
95
  }
77
96
 
78
97
  try:
@@ -5,179 +5,151 @@ from datetime import datetime
5
5
  from pathlib import Path
6
6
  from typing import Dict, List, Optional
7
7
 
8
+ from devgen.modules.changelog_sections import (
9
+ DEFAULT_STYLES,
10
+ Section,
11
+ section_for_type,
12
+ )
8
13
  from devgen.utils import configure_logger, run_git_command
9
14
 
10
15
 
11
16
  class ChangelogGenerator:
12
17
  """Generates a changelog from git history using Semantic Release style."""
13
18
 
19
+ #: Conventional commit header: ``type(scope)!: subject``
20
+ _CC_PATTERN = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
21
+
14
22
  def __init__(self, logger=None):
15
23
  self.logger = logger or configure_logger("devgen.changelog")
16
24
 
17
- def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
18
- """Fetches commit messages in the specified range."""
19
- range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
20
- # Format: hash|author|date|subject|body
21
- fmt = "%H|%an|%ad|%s|%b"
22
- cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
23
-
24
- if not from_ref:
25
- # If no start ref, try to find the last tag
26
- try:
27
- last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
28
- cmd = [
29
- "git",
30
- "log",
31
- f"--format={fmt}",
32
- "--date=short",
33
- f"{last_tag}..HEAD",
34
- ]
35
- self.logger.info(f"Generating changelog from last tag: {last_tag}")
36
- except (RuntimeError, subprocess.CalledProcessError):
37
- self.logger.info("No tags found, generating for all commits.")
38
- cmd = ["git", "log", f"--format={fmt}", "--date=short"]
25
+ # ------------------------------------------------------------------ commits
39
26
 
27
+ def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
28
+ """Fetch commit lines in the requested range."""
29
+ if from_ref:
30
+ range_spec = f"{from_ref}..{to_ref}"
31
+ cmd = self._log_cmd(range_spec)
32
+ else:
33
+ cmd = self._resolve_range(to_ref)
40
34
  try:
41
35
  return run_git_command(cmd).split("\n")
42
36
  except subprocess.CalledProcessError as e:
43
37
  self.logger.error(f"Git command failed: {e}")
44
- raise RuntimeError(f"Git command failed: {e}")
38
+ raise RuntimeError(f"Git command failed: {e}") from e
45
39
 
46
- def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
47
- """Parses raw commit strings into structured data."""
48
- groups = defaultdict(list)
49
- # Conventional Commit Regex: type(scope)!: subject
50
- cc_pattern = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
40
+ def _log_cmd(self, range_spec: str) -> List[str]:
41
+ # Format: hash|author|date|subject|body
42
+ return [
43
+ "git",
44
+ "log",
45
+ "--format=%H|%an|%ad|%s|%b",
46
+ "--date=short",
47
+ range_spec,
48
+ ]
51
49
 
50
+ def _resolve_range(self, to_ref: str) -> List[str]:
51
+ try:
52
+ last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
53
+ self.logger.info(f"Generating changelog from last tag: {last_tag}")
54
+ return self._log_cmd(f"{last_tag}..HEAD")
55
+ except (RuntimeError, subprocess.CalledProcessError):
56
+ self.logger.info("No tags found, generating for all commits.")
57
+ return ["git", "log", "--format=%H|%an|%ad|%s|%b", "--date=short"]
58
+
59
+ # ----------------------------------------------------------------- parsing
60
+
61
+ def parse_commits(self, raw_commits: List[str]) -> Dict[Section, List[Dict]]:
62
+ """Group raw commit lines into :class:`Section` buckets."""
63
+ groups: Dict[Section, List[Dict]] = defaultdict(list)
52
64
  for line in raw_commits:
53
- if not line.strip():
54
- continue
55
-
56
- parts = line.split("|", 4)
57
- if len(parts) < 4:
65
+ entry = self._parse_line(line)
66
+ if entry is None:
58
67
  continue
68
+ section = section_for_type(entry["type"])
69
+ entry["section"] = section
70
+ if entry["breaking"]:
71
+ groups[Section.BREAKING].append(entry)
72
+ groups[section].append(entry)
73
+ return dict(groups)
74
+
75
+ def _parse_line(self, line: str) -> Dict | None:
76
+ if not line.strip():
77
+ return None
78
+ parts = line.split("|", 4)
79
+ if len(parts) < 4:
80
+ return None
81
+ commit_hash, author, date, subject = parts[:4]
82
+ body = parts[4] if len(parts) > 4 else ""
83
+
84
+ match = self._CC_PATTERN.match(subject)
85
+ if match:
86
+ c_type, c_scope, breaking, c_subject = match.groups()
87
+ return {
88
+ "type": c_type,
89
+ "hash": commit_hash,
90
+ "author": author,
91
+ "date": date,
92
+ "scope": c_scope,
93
+ "subject": c_subject,
94
+ "body": body,
95
+ "breaking": bool(breaking),
96
+ }
97
+ return {
98
+ "type": "",
99
+ "hash": commit_hash,
100
+ "author": author,
101
+ "date": date,
102
+ "scope": None,
103
+ "subject": subject,
104
+ "body": body,
105
+ "breaking": False,
106
+ }
59
107
 
60
- commit_hash, author, date, subject, body = (
61
- parts[0],
62
- parts[1],
63
- parts[2],
64
- parts[3],
65
- parts[4] if len(parts) > 4 else "",
66
- )
67
-
68
- match = cc_pattern.match(subject)
69
- if match:
70
- c_type, c_scope, breaking, c_subject = match.groups()
71
- entry = {
72
- "hash": commit_hash,
73
- "author": author,
74
- "date": date,
75
- "scope": c_scope,
76
- "subject": c_subject,
77
- "body": body,
78
- "breaking": bool(breaking),
79
- }
80
-
81
- if breaking:
82
- groups["BREAKING CHANGES"].append(entry)
83
-
84
- if c_type in ["feat", "feature"]:
85
- groups["Features"].append(entry)
86
- elif c_type in ["fix", "bug"]:
87
- groups["Bug Fixes"].append(entry)
88
- elif c_type in ["docs"]:
89
- groups["Documentation"].append(entry)
90
- elif c_type in [
91
- "style",
92
- "refactor",
93
- "perf",
94
- "test",
95
- "build",
96
- "ci",
97
- "chore",
98
- ]:
99
- groups["Other Changes"].append(entry)
100
- else:
101
- groups["Other Changes"].append(entry)
102
- else:
103
- # Non-conventional commits
104
- groups["Other Changes"].append(
105
- {
106
- "hash": commit_hash,
107
- "author": author,
108
- "date": date,
109
- "scope": None,
110
- "subject": subject,
111
- "body": body,
112
- "breaking": False,
113
- }
114
- )
115
-
116
- return groups
108
+ # ------------------------------------------------------------------ output
117
109
 
118
110
  def generate_markdown(
119
- self, groups: Dict[str, List[Dict]], version: str = "Unreleased"
111
+ self,
112
+ groups: Dict[Section, List[Dict]],
113
+ version: str = "Unreleased",
120
114
  ) -> str:
121
- """Generates markdown changelog from parsed commits."""
115
+ """Render groups as a markdown changelog block."""
122
116
  date_str = datetime.now().strftime("%Y-%m-%d")
123
- md = [f"## {version} ({date_str})\n"]
124
-
125
- # Order: Breaking, Features, Fixes, Docs, Others
126
- order = [
127
- "BREAKING CHANGES",
128
- "Features",
129
- "Bug Fixes",
130
- "Documentation",
131
- "Other Changes",
132
- ]
133
-
134
- emoji_map = {
135
- "BREAKING CHANGES": "💥 BREAKING CHANGES",
136
- "Features": "✨ Features",
137
- "Bug Fixes": "🐛 Bug Fixes",
138
- "Documentation": "📚 Documentation",
139
- "Other Changes": "🔨 Other Changes",
140
- }
141
-
142
- for section in order:
117
+ out = [f"## {version} ({date_str})\n"]
118
+ for section in Section.ordered():
143
119
  commits = groups.get(section)
144
- if commits:
145
- header = emoji_map.get(section, section)
146
- md.append(f"## {header}\n")
147
- for c in commits:
148
- scope = f"**{c['scope']}**: " if c["scope"] else ""
149
- md.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
150
- md.append("")
151
-
152
- return "\n".join(md)
120
+ if not commits:
121
+ continue
122
+ style = DEFAULT_STYLES[section]
123
+ out.append(f"## {style.emoji} {style.heading}\n")
124
+ for c in commits:
125
+ scope = f"**{c['scope']}**: " if c.get("scope") else ""
126
+ out.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
127
+ out.append("")
128
+ return "\n".join(out)
153
129
 
154
130
  def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
155
- """Main execution method."""
131
+ """Fetch parse → write (or print) the changelog."""
156
132
  raw_commits = self.get_commits(from_ref)
157
133
  if not raw_commits or not raw_commits[0]:
158
134
  self.logger.warning("No commits found.")
159
135
  return
136
+ groups = self.parse_commits(raw_commits)
137
+ md_content = self.generate_markdown(groups)
160
138
 
161
- parsed = self.parse_commits(raw_commits)
162
- md_content = self.generate_markdown(parsed)
163
-
164
- if output_file:
165
- path = Path(output_file)
166
- if path.exists():
167
- old_content = path.read_text(encoding="utf-8")
168
- # Prepend the new content, assume # CHANGELOG is at the top or needs to be
169
- if old_content.strip().startswith("# CHANGELOG"):
170
- lines = old_content.split("\n", 1)
171
- header = lines[0]
172
- rest = lines[1] if len(lines) > 1 else ""
173
- new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
174
- else:
175
- new_content = f"# CHANGELOG\n\n{md_content}\n\n{old_content}"
176
- path.write_text(new_content, encoding="utf-8")
177
- else:
178
- path.write_text(f"# CHANGELOG\n\n{md_content}", encoding="utf-8")
139
+ if not output_file:
140
+ print(md_content)
141
+ return
179
142
 
180
- self.logger.info(f"Changelog written to {output_file}")
181
- print(f" Changelog updated: {output_file}")
143
+ path = Path(output_file)
144
+ if path.exists():
145
+ old = path.read_text(encoding="utf-8")
146
+ if old.strip().startswith("# CHANGELOG"):
147
+ header, _, rest = old.partition("\n")
148
+ new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
149
+ else:
150
+ new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
182
151
  else:
183
- print(md_content)
152
+ new_content = f"# CHANGELOG\n\n{md_content}"
153
+ path.write_text(new_content, encoding="utf-8")
154
+ self.logger.info(f"Changelog written to {output_file}")
155
+ print(f" Changelog updated: {output_file}")
@@ -0,0 +1,79 @@
1
+ """Shared section definitions for changelog and release-note generators.
2
+
3
+ Both generators render the same set of Conventional Commit groups; this
4
+ module is the single source of truth for the section name, the emoji,
5
+ and the display order.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import Mapping
11
+
12
+
13
+ class Section(str, Enum):
14
+ """Sections in the order they should be rendered."""
15
+
16
+ BREAKING = "BREAKING CHANGES"
17
+ FEATURES = "Features"
18
+ BUG_FIXES = "Bug Fixes"
19
+ PERFORMANCE = "Performance"
20
+ DOCUMENTATION = "Documentation"
21
+ REFACTOR = "Refactor"
22
+ TESTS = "Tests"
23
+ STYLE = "Style"
24
+ CHORE = "Chore"
25
+ OTHER = "Other Changes"
26
+
27
+ @classmethod
28
+ def ordered(cls) -> tuple["Section", ...]:
29
+ return tuple(cls)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SectionStyle:
34
+ """Visual style for a section (emoji + name for the heading)."""
35
+
36
+ emoji: str
37
+ heading: str
38
+
39
+
40
+ # Default style for each section. Generators can override per output
41
+ # format (changelog vs. release notes).
42
+ DEFAULT_STYLES: Mapping[Section, SectionStyle] = {
43
+ Section.BREAKING: SectionStyle("💥", "BREAKING CHANGES"),
44
+ Section.FEATURES: SectionStyle("✨", "Features"),
45
+ Section.BUG_FIXES: SectionStyle("🐛", "Bug Fixes"),
46
+ Section.PERFORMANCE: SectionStyle("⚡", "Performance"),
47
+ Section.DOCUMENTATION: SectionStyle("📚", "Documentation"),
48
+ Section.REFACTOR: SectionStyle("♻️", "Refactor"),
49
+ Section.TESTS: SectionStyle("✅", "Tests"),
50
+ Section.STYLE: SectionStyle("💄", "Style"),
51
+ Section.CHORE: SectionStyle("🔧", "Chore"),
52
+ Section.OTHER: SectionStyle("🧹", "Other Changes"),
53
+ }
54
+
55
+
56
+ # Conventional commit type → Section mapping. Long-form aliases ("feature",
57
+ # "bug") are also accepted.
58
+ TYPE_TO_SECTION: Mapping[str, Section] = {
59
+ "feat": Section.FEATURES,
60
+ "feature": Section.FEATURES,
61
+ "fix": Section.BUG_FIXES,
62
+ "bug": Section.BUG_FIXES,
63
+ "perf": Section.PERFORMANCE,
64
+ "docs": Section.DOCUMENTATION,
65
+ "refactor": Section.REFACTOR,
66
+ "test": Section.TESTS,
67
+ "style": Section.STYLE,
68
+ "build": Section.CHORE,
69
+ "ci": Section.CHORE,
70
+ "chore": Section.CHORE,
71
+ }
72
+
73
+
74
+ def section_for_type(commit_type: str) -> Section:
75
+ """Return the Section for a Conventional Commit ``type``.
76
+
77
+ Unknown types fall back to :attr:`Section.OTHER`.
78
+ """
79
+ return TYPE_TO_SECTION.get(commit_type.lower(), Section.OTHER)