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.
Files changed (61) hide show
  1. {devgen_cli-0.2.8/devgen_cli.egg-info → devgen_cli-0.3.0}/PKG-INFO +7 -8
  2. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/commit.py +1 -1
  3. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/config.py +6 -0
  4. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/setup.py +10 -7
  5. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/changelog_generator.py +8 -4
  6. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/commit_generator.py +30 -32
  7. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/diff_builder.py +14 -16
  8. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/git_ops.py +6 -5
  9. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/gitignore_generator.py +9 -0
  10. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/anthropic.py +2 -0
  11. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/base.py +43 -20
  12. devgen_cli-0.3.0/devgen/providers/gemini.py +25 -0
  13. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/huggingface.py +3 -1
  14. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/ollama.py +13 -9
  15. devgen_cli-0.3.0/devgen/providers/openai.py +25 -0
  16. devgen_cli-0.3.0/devgen/providers/openrouter.py +30 -0
  17. devgen_cli-0.3.0/devgen/templates/commit/commit_message.tpl +29 -0
  18. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/utils.py +83 -26
  19. {devgen_cli-0.2.8 → devgen_cli-0.3.0/devgen_cli.egg-info}/PKG-INFO +7 -8
  20. devgen_cli-0.3.0/devgen_cli.egg-info/requires.txt +9 -0
  21. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/pyproject.toml +9 -10
  22. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/setup.py +1 -1
  23. devgen_cli-0.2.8/devgen/providers/gemini.py +0 -63
  24. devgen_cli-0.2.8/devgen/providers/openai.py +0 -20
  25. devgen_cli-0.2.8/devgen/providers/openrouter.py +0 -25
  26. devgen_cli-0.2.8/devgen/templates/commit/commit_message.tpl +0 -14
  27. devgen_cli-0.2.8/devgen_cli.egg-info/requires.txt +0 -10
  28. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/LICENSE +0 -0
  29. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/MANIFEST.in +0 -0
  30. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/README.md +0 -0
  31. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/__init__.py +0 -0
  32. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/ai.py +0 -0
  33. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/__init__.py +0 -0
  34. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/changelog.py +0 -0
  35. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/gitignore.py +0 -0
  36. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/license.py +0 -0
  37. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/main.py +0 -0
  38. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/cli/release.py +0 -0
  39. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/__init__.py +0 -0
  40. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/changelog_sections.py +0 -0
  41. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/license_generator.py +0 -0
  42. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/modules/release_note_generator.py +0 -0
  43. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/providers/__init__.py +0 -0
  44. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/agpl-3.0.json +0 -0
  45. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/apache-2.0.json +0 -0
  46. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/bsd-2-clause.json +0 -0
  47. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/bsd-3-clause.json +0 -0
  48. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/bsl-1.0.json +0 -0
  49. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/cc0-1.0.json +0 -0
  50. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/epl-2.0.json +0 -0
  51. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/gpl-2.0.json +0 -0
  52. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/gpl-3.0.json +0 -0
  53. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/lgpl-2.1.json +0 -0
  54. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/mit.json +0 -0
  55. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/mpl-2.0.json +0 -0
  56. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen/templates/licenses/unlicense.json +0 -0
  57. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/SOURCES.txt +0 -0
  58. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/dependency_links.txt +0 -0
  59. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/entry_points.txt +0 -0
  60. {devgen_cli-0.2.8 → devgen_cli-0.3.0}/devgen_cli.egg-info/top_level.txt +0 -0
  61. {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.2.8
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.75.0
24
- Requires-Dist: google-genai>=0.1.0
25
- Requires-Dist: jinja2>=3.1.6
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.32.5
30
- Requires-Dist: rich>=14.2.0
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.20.0
31
+ Requires-Dist: typer>=0.26.8
33
32
  Dynamic: author
34
33
  Dynamic: home-page
35
34
  Dynamic: license-file
@@ -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()
@@ -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 = {**current_config, **{
90
- "provider": provider,
91
- "model": model,
92
- "api_key": api_key,
93
- "emoji": emoji,
94
- "ollama_host": ollama_host,
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
- 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
 
@@ -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
- load_template_env,
37
- render_custom_template,
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
- template_env,
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.template_env = template_env
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
- if custom_template:
101
- prompt = render_custom_template(
102
- custom_template,
103
- group_name=group,
104
- diff_text=diff,
105
- use_emoji=use_emoji,
106
- context=manifest_context,
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.template_env = load_template_env("commit")
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.template_env,
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
- import time
246
+ if self.provider != "ollama":
247
+ import time
256
248
 
257
- time.sleep(15) # Conservative 4 RPM guard for free-tier
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
- self.logger.error(
294
- f"Failed to generate message for group {group!r}: {e}",
295
- exc_info=True,
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
- if new_key in groups:
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 = Path("pyproject.toml")
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 = Path("package.json")
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 = Path("requirements.txt")
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.strip() for f in out.split("\n") if f.strip()}
101
- staged_out = self._run(["git", "diff", "--name-only", "--cached"])
102
- files.update(f.strip() for f in staged_out.split("\n") if f.strip())
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:
@@ -16,4 +16,6 @@ class AnthropicProvider(BaseProvider):
16
16
  max_tokens=1024,
17
17
  messages=[{"role": "user", "content": prompt}],
18
18
  )
19
+ if not message.content:
20
+ raise RuntimeError(f"Anthropic returned no content for model {model!r}.")
19
21
  return message.content[0].text or ""
@@ -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
- try:
56
- text = self._generate(prompt, api_key, chosen_model, **kwargs)
57
- return (text or "").strip()
58
- except Exception as e:
59
- self._handle_error(e)
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(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,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
- kwargs.pop("debug", None)
25
- kwargs.pop("ollama_host", None)
26
-
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
- )
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
- 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:
@@ -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
- path = Path(filepath)
197
- if path.exists():
198
- return path.read_text(encoding="utf-8")
199
- return None
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
- path = Path(filepath)
204
- if path.exists():
205
- path.unlink()
214
+ try:
215
+ Path(filepath).unlink()
206
216
  return True
207
- return False
217
+ except FileNotFoundError:
218
+ return False
208
219
 
209
220
 
210
- def load_template_env(sub_dir: str) -> Environment:
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
- if not template_dir.is_dir():
224
+ template_path = template_dir / name
225
+ if not template_path.is_file():
213
226
  raise RuntimeError(
214
- f"Template directory not found: {template_dir}. "
215
- "The installation may be corrupted."
227
+ f"Template not found: {template_path}. The installation may be corrupted."
216
228
  )
217
- return Environment(loader=FileSystemLoader(template_dir))
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
- def render_custom_template(template_str: str, **context) -> str:
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
- return yaml.safe_load(f) or {}
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
- "load_template_env",
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.2.8
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.75.0
24
- Requires-Dist: google-genai>=0.1.0
25
- Requires-Dist: jinja2>=3.1.6
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.32.5
30
- Requires-Dist: rich>=14.2.0
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.20.0
31
+ Requires-Dist: typer>=0.26.8
33
32
  Dynamic: author
34
33
  Dynamic: home-page
35
34
  Dynamic: license-file
@@ -0,0 +1,9 @@
1
+ anthropic>=0.116.0
2
+ google-genai>=2.10.0
3
+ openai>=2.44.0
4
+ pyyaml>=6.0.3
5
+ questionary>=2.1.1
6
+ requests>=2.34.2
7
+ rich>=15.0.0
8
+ toml>=0.10.2
9
+ typer>=0.26.8
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devgen-cli"
7
- version = "0.2.8"
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.75.0",
29
- "google-genai>=0.1.0",
30
- "jinja2>=3.1.6",
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.32.5",
35
- "rich>=14.2.0",
33
+ "requests>=2.34.2",
34
+ "rich>=15.0.0",
36
35
  "toml>=0.10.2",
37
- "typer>=0.20.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.5.0",
54
- "ruff>=0.14.9",
52
+ "pre-commit>=4.6.0",
53
+ "ruff>=0.15.20",
55
54
  ]
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
6
6
 
7
7
  setup(
8
8
  name="devgen-cli",
9
- version="0.2.8",
9
+ version="0.3.0",
10
10
  packages=find_packages(),
11
11
  include_package_data=True,
12
12
  install_requires=[
@@ -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 }}
@@ -1,10 +0,0 @@
1
- anthropic>=0.75.0
2
- google-genai>=0.1.0
3
- jinja2>=3.1.6
4
- openai>=2.11.0
5
- pyyaml>=6.0.3
6
- questionary>=2.1.1
7
- requests>=2.32.5
8
- rich>=14.2.0
9
- toml>=0.10.2
10
- typer>=0.20.0
File without changes
File without changes
File without changes
File without changes
File without changes