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.
devgen/cli/release.py ADDED
@@ -0,0 +1,30 @@
1
+ import typer
2
+ from typing_extensions import Annotated
3
+
4
+ from devgen.modules.release_note_generator import ReleaseNotesGenerator
5
+
6
+ app = typer.Typer(
7
+ name="release",
8
+ help="🚀 Generate release notes from git history.",
9
+ no_args_is_help=True,
10
+ rich_markup_mode="markdown",
11
+ )
12
+
13
+
14
+ @app.command("notes")
15
+ def generate_release_notes(
16
+ version: Annotated[
17
+ str,
18
+ typer.Option("--version", "-v", help="Version for this release (ex: 1.4.0)"),
19
+ ] = "Unreleased",
20
+ output: Annotated[
21
+ str,
22
+ typer.Option("--output", "-o", help="Output file path."),
23
+ ] = "RELEASE-NOTES.md",
24
+ from_ref: Annotated[
25
+ str,
26
+ typer.Option("--from", "-f", help="Start reference. Defaults to last tag."),
27
+ ] = "",
28
+ ):
29
+ generator = ReleaseNotesGenerator()
30
+ generator.run(output_file=output, version=version, from_ref=from_ref)
devgen/cli/setup.py ADDED
@@ -0,0 +1,85 @@
1
+ from pathlib import Path
2
+
3
+ import questionary
4
+ import typer
5
+ import yaml
6
+
7
+ app = typer.Typer(
8
+ name="setup",
9
+ help="⚙️ Setup and configuration management.",
10
+ no_args_is_help=True,
11
+ rich_markup_mode="markdown",
12
+ )
13
+
14
+
15
+ @app.command("config")
16
+ def setup_config() -> None:
17
+ """Interactively setup configuration."""
18
+ typer.secho("🛠️ Interactive Configuration Setup", fg=typer.colors.CYAN, bold=True)
19
+
20
+ config_path = Path.home() / ".devgen.yaml"
21
+ current_config = {}
22
+ if config_path.exists():
23
+ try:
24
+ with config_path.open("r", encoding="utf-8") as f:
25
+ current_config = yaml.safe_load(f) or {}
26
+ except Exception:
27
+ pass
28
+
29
+ from devgen.utils import get_questionary_style
30
+
31
+ style = get_questionary_style()
32
+
33
+ # Questions
34
+ provider = questionary.select(
35
+ "Select AI Provider:",
36
+ choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
37
+ default=current_config.get("provider", "gemini"),
38
+ style=style,
39
+ ).ask()
40
+ if provider is None:
41
+ raise typer.Exit(code=130)
42
+
43
+ model_default = current_config.get("model", "gemini-2.5-flash")
44
+
45
+ model = questionary.text(
46
+ "Enter Model Name:", default=model_default, style=style
47
+ ).ask()
48
+ if model is None:
49
+ raise typer.Exit(code=130)
50
+
51
+ api_key = questionary.password(
52
+ "Enter API Key (leave empty to keep existing or none):", style=style
53
+ ).ask()
54
+ if api_key is None:
55
+ raise typer.Exit(code=130)
56
+
57
+ if not api_key:
58
+ api_key = current_config.get("api_key", "")
59
+
60
+ emoji_choice = questionary.select(
61
+ "Use Emojis in Commit Messages?",
62
+ choices=["Yes", "No"],
63
+ default="Yes" if current_config.get("emoji", True) else "No",
64
+ style=style,
65
+ ).ask()
66
+ if emoji_choice is None:
67
+ raise typer.Exit(code=130)
68
+ emoji = emoji_choice == "Yes"
69
+
70
+ # Save Config
71
+ new_config = {
72
+ "provider": provider,
73
+ "model": model,
74
+ "api_key": api_key,
75
+ "emoji": emoji,
76
+ }
77
+
78
+ try:
79
+ with config_path.open("w", encoding="utf-8") as f:
80
+ yaml.dump(new_config, f, default_flow_style=False)
81
+ typer.secho(f"\nConfiguration saved to {config_path}", fg=typer.colors.GREEN)
82
+ typer.echo(yaml.dump(new_config, default_flow_style=False))
83
+ except Exception as e:
84
+ typer.secho(f"\nFailed to save configuration: {e}", fg=typer.colors.RED)
85
+ raise typer.Exit(code=1)
File without changes
@@ -0,0 +1,190 @@
1
+ import re
2
+ import subprocess
3
+ from collections import defaultdict
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ from devgen.utils import configure_logger
9
+
10
+
11
+ class ChangelogGenerator:
12
+ """Generates a changelog from git history using Semantic Release style."""
13
+
14
+ def __init__(self, logger=None):
15
+ self.logger = logger or configure_logger("devgen.changelog")
16
+
17
+ def _exec_git(self, args: List[str]) -> str:
18
+ """Executes a git command."""
19
+ try:
20
+ res = subprocess.run(
21
+ args,
22
+ capture_output=True,
23
+ text=True,
24
+ encoding="utf-8",
25
+ errors="replace",
26
+ check=True,
27
+ )
28
+ return res.stdout.strip()
29
+ except subprocess.CalledProcessError as e:
30
+ self.logger.error(f"Git command failed: {e}")
31
+ raise RuntimeError(f"Git command failed: {e}")
32
+
33
+ def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
34
+ """Fetches commit messages in the specified range."""
35
+ range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
36
+ # Format: hash|author|date|subject|body
37
+ fmt = "%H|%an|%ad|%s|%b"
38
+ cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
39
+
40
+ if not from_ref:
41
+ # If no start ref, try to find the last tag
42
+ try:
43
+ last_tag = self._exec_git(["git", "describe", "--tags", "--abbrev=0"])
44
+ cmd = [
45
+ "git",
46
+ "log",
47
+ f"--format={fmt}",
48
+ "--date=short",
49
+ f"{last_tag}..HEAD",
50
+ ]
51
+ self.logger.info(f"Generating changelog from last tag: {last_tag}")
52
+ except RuntimeError:
53
+ self.logger.info("No tags found, generating for all commits.")
54
+ cmd = ["git", "log", f"--format={fmt}", "--date=short"]
55
+
56
+ return self._exec_git(cmd).split("\n")
57
+
58
+ def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
59
+ """Parses raw commit strings into structured data."""
60
+ groups = defaultdict(list)
61
+ # Conventional Commit Regex: type(scope)!: subject
62
+ cc_pattern = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
63
+
64
+ for line in raw_commits:
65
+ if not line.strip():
66
+ continue
67
+
68
+ parts = line.split("|", 4)
69
+ if len(parts) < 4:
70
+ continue
71
+
72
+ commit_hash, author, date, subject, body = (
73
+ parts[0],
74
+ parts[1],
75
+ parts[2],
76
+ parts[3],
77
+ parts[4] if len(parts) > 4 else "",
78
+ )
79
+
80
+ match = cc_pattern.match(subject)
81
+ if match:
82
+ c_type, c_scope, breaking, c_subject = match.groups()
83
+ entry = {
84
+ "hash": commit_hash,
85
+ "author": author,
86
+ "date": date,
87
+ "scope": c_scope,
88
+ "subject": c_subject,
89
+ "body": body,
90
+ "breaking": bool(breaking),
91
+ }
92
+
93
+ if breaking:
94
+ groups["BREAKING CHANGES"].append(entry)
95
+
96
+ if c_type in ["feat", "feature"]:
97
+ groups["Features"].append(entry)
98
+ elif c_type in ["fix", "bug"]:
99
+ groups["Bug Fixes"].append(entry)
100
+ elif c_type in ["docs"]:
101
+ groups["Documentation"].append(entry)
102
+ elif c_type in [
103
+ "style",
104
+ "refactor",
105
+ "perf",
106
+ "test",
107
+ "build",
108
+ "ci",
109
+ "chore",
110
+ ]:
111
+ groups["Other Changes"].append(entry)
112
+ else:
113
+ groups["Other Changes"].append(entry)
114
+ else:
115
+ # Non-conventional commits
116
+ groups["Other Changes"].append(
117
+ {
118
+ "hash": commit_hash,
119
+ "author": author,
120
+ "date": date,
121
+ "scope": None,
122
+ "subject": subject,
123
+ "body": body,
124
+ "breaking": False,
125
+ }
126
+ )
127
+
128
+ return groups
129
+
130
+ def generate_markdown(
131
+ self, groups: Dict[str, List[Dict]], version: str = "Unreleased"
132
+ ) -> str:
133
+ """Generates markdown changelog from parsed commits."""
134
+ date_str = datetime.now().strftime("%Y-%m-%d")
135
+ md = [f"# {version} ({date_str})\n"]
136
+
137
+ # Order: Breaking, Features, Fixes, Docs, Others
138
+ order = [
139
+ "BREAKING CHANGES",
140
+ "Features",
141
+ "Bug Fixes",
142
+ "Documentation",
143
+ "Other Changes",
144
+ ]
145
+
146
+ emoji_map = {
147
+ "BREAKING CHANGES": "💥 BREAKING CHANGES",
148
+ "Features": "✨ Features",
149
+ "Bug Fixes": "🐛 Bug Fixes",
150
+ "Documentation": "📚 Documentation",
151
+ "Other Changes": "🔨 Other Changes",
152
+ }
153
+
154
+ for section in order:
155
+ commits = groups.get(section)
156
+ if commits:
157
+ header = emoji_map.get(section, section)
158
+ md.append(f"## {header}\n")
159
+ for c in commits:
160
+ scope = f"**{c['scope']}**: " if c["scope"] else ""
161
+ md.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
162
+ md.append("")
163
+
164
+ return "\n".join(md)
165
+
166
+ def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
167
+ """Main execution method."""
168
+ raw_commits = self.get_commits(from_ref)
169
+ if not raw_commits or not raw_commits[0]:
170
+ self.logger.warning("No commits found.")
171
+ return
172
+
173
+ parsed = self.parse_commits(raw_commits)
174
+ md_content = self.generate_markdown(parsed)
175
+
176
+ if output_file:
177
+ path = Path(output_file)
178
+ # If file exists, prepend? For now, just overwrite or append logic could be complex.
179
+ # Let's implement prepend logic if file exists.
180
+ if path.exists():
181
+ old_content = path.read_text(encoding="utf-8")
182
+ new_content = old_content + "\n\n" + md_content
183
+ path.write_text(new_content, encoding="utf-8")
184
+ else:
185
+ path.write_text(md_content, encoding="utf-8")
186
+
187
+ self.logger.info(f"Changelog written to {output_file}")
188
+ print(f" Changelog updated: {output_file}")
189
+ else:
190
+ print(md_content)
@@ -0,0 +1,257 @@
1
+ import subprocess
2
+ from collections import defaultdict
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+
7
+ from devgen.ai import generate_with_ai
8
+ from devgen.utils import (
9
+ configure_logger,
10
+ extract_commit_messages,
11
+ get_commit_dry_run_path,
12
+ is_file_recent,
13
+ load_template_env,
14
+ sanitize_ai_commit_message,
15
+ )
16
+
17
+
18
+ class CommitEngineError(Exception):
19
+ """Exception raised for errors in the commit engine."""
20
+
21
+ pass
22
+
23
+
24
+ class CommitEngine:
25
+ """
26
+ Engine for generating AI-powered commit messages.
27
+ Handles detection of changes, grouping, AI generation, and git operations.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ dry_run: bool = False,
33
+ push: bool = False,
34
+ debug: bool = False,
35
+ force_rebuild: bool = False,
36
+ provider: str = "gemini",
37
+ model: str = "gemini-2.5-flash",
38
+ logger: Any | None = None,
39
+ **kwargs,
40
+ ):
41
+ self.dry_run = dry_run
42
+ self.push = push
43
+ self.debug = debug
44
+ self.force_rebuild = force_rebuild
45
+ self.provider = provider
46
+ self.model = model
47
+ self.logger = logger or configure_logger(
48
+ "devgen.commit", Path.home() / ".cache" / "devgen" / "commit.log"
49
+ )
50
+ self.kwargs = kwargs
51
+ self.dry_run_path = get_commit_dry_run_path()
52
+ self.template_env = load_template_env("commit")
53
+
54
+ # Load config from ~/.devgen.yaml
55
+ from devgen.utils import load_config
56
+
57
+ self.config = load_config()
58
+
59
+ def _exec_git(self, args: List[str], allow_error: bool = False) -> str:
60
+ """Executes a git command."""
61
+ try:
62
+ res = subprocess.run(
63
+ args,
64
+ capture_output=True,
65
+ text=True,
66
+ encoding="utf-8",
67
+ errors="replace",
68
+ check=not allow_error,
69
+ )
70
+ return res.stdout.strip()
71
+ except subprocess.CalledProcessError as e:
72
+ msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
73
+ self.logger.error(msg)
74
+ raise CommitEngineError(msg) from e
75
+
76
+ def detect_changes(self) -> List[str]:
77
+ """Detects changed, deleted, or untracked files."""
78
+ out = self._exec_git(
79
+ [
80
+ "git",
81
+ "ls-files",
82
+ "--deleted",
83
+ "--modified",
84
+ "--others",
85
+ "--exclude-standard",
86
+ ]
87
+ )
88
+ return [f.strip() for f in out.split("\n") if f.strip()]
89
+
90
+ def group_files(self, files: List[str]) -> Dict[str, List[str]]:
91
+ """Groups files by their parent directory."""
92
+ groups = defaultdict(list)
93
+ for f in files:
94
+ parent = str(Path(f).parent)
95
+ key = "root" if parent == "." else parent
96
+ groups[key].append(f)
97
+ return groups
98
+
99
+ def generate_diff(self, files: List[str]) -> str:
100
+ """Generates diff for specific files."""
101
+ return self._exec_git(["git", "--no-pager", "diff", "--staged", "--", *files])
102
+
103
+ def _init_dry_run(self):
104
+ """Initializes the dry-run file."""
105
+ self.dry_run_path.parent.mkdir(parents=True, exist_ok=True)
106
+ with self.dry_run_path.open("w", encoding="utf-8") as f:
107
+ ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
108
+ f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
109
+
110
+ def _log_dry_run(self, group: str, msg: str):
111
+ """Appends a dry-run entry."""
112
+ with self.dry_run_path.open("a", encoding="utf-8") as f:
113
+ f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
114
+
115
+ def stage_files(self, files: List[str]):
116
+ """Stages files in git."""
117
+ if not files:
118
+ return
119
+ self.logger.info(f"Staging: {files}")
120
+ self._exec_git(["git", "add", *files])
121
+
122
+ def commit_staged(self, msg: str):
123
+ """Commits staged changes."""
124
+ self.logger.info(f"Committing:\n{msg}")
125
+ self._exec_git(["git", "commit", "-m", msg])
126
+
127
+ def push_commits(self):
128
+ """Pushes commits to remote."""
129
+ self.logger.info("Pushing to remote...")
130
+ self._exec_git(["git", "push"])
131
+ self.logger.info("Push successful.")
132
+
133
+ def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
134
+ """Generates a commit message using AI or cache."""
135
+ if not self.force_rebuild and group in cache:
136
+ self.logger.info(f"Using cached message for {group}")
137
+ return cache[group]
138
+
139
+ # Get settings from config or kwargs
140
+ provider = (
141
+ self.kwargs.get("provider") or self.config.get("provider") or self.provider
142
+ )
143
+ model = self.kwargs.get("model") or self.config.get("model") or self.model
144
+ api_key = self.kwargs.get("api_key") or self.config.get("api_key")
145
+ use_emoji = self.config.get("emoji", True)
146
+
147
+ template = self.template_env.get_template("commit_message.j2")
148
+ prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
149
+
150
+ raw = generate_with_ai(
151
+ prompt,
152
+ provider=provider,
153
+ model=model,
154
+ api_key=api_key,
155
+ debug=self.debug,
156
+ **self.kwargs,
157
+ )
158
+ return sanitize_ai_commit_message(raw)
159
+
160
+ def is_ahead_of_remote(self) -> bool:
161
+ """Checks if local branch has unpushed commits."""
162
+ try:
163
+ self._exec_git(["git", "fetch", "origin"])
164
+ count = self._exec_git(
165
+ ["git", "rev-list", "--count", "@{u}..HEAD"], allow_error=True
166
+ )
167
+ if count and int(count) > 0:
168
+ return True
169
+ except CommitEngineError:
170
+ # Maybe no upstream
171
+ return bool(self._exec_git(["git", "rev-parse", "HEAD"], allow_error=True))
172
+ return False
173
+
174
+ def load_cache(self) -> Dict[str, str]:
175
+ """Loads dry-run cache."""
176
+ if self.dry_run:
177
+ self._init_dry_run()
178
+ return {}
179
+ if not self.force_rebuild and is_file_recent(self.dry_run_path):
180
+ self.logger.info(f"Loading cache from {self.dry_run_path}")
181
+ return extract_commit_messages(self.dry_run_path)
182
+ return {}
183
+
184
+ def process_group(
185
+ self, group: str, files: List[str], cache: Dict[str, str]
186
+ ) -> bool:
187
+ """Processes a single file group."""
188
+ self.stage_files(files)
189
+ diff = self.generate_diff(files)
190
+
191
+ if not diff.strip():
192
+ self.logger.info(f"Skipping empty diff for {group}")
193
+ self._exec_git(["git", "reset", "HEAD", "--", *files])
194
+ return True
195
+
196
+ msg = self.generate_message(group, diff, cache)
197
+ if not msg:
198
+ self.logger.error(f"Empty message for {group}")
199
+ return False
200
+
201
+ if self.dry_run:
202
+ self._log_dry_run(group, msg)
203
+ self._exec_git(["git", "reset", "HEAD", "--", *files])
204
+ else:
205
+ self.commit_staged(msg)
206
+
207
+ return True
208
+
209
+ def execute(self):
210
+ """Main execution method."""
211
+ files = self.detect_changes()
212
+ ahead = self.is_ahead_of_remote()
213
+
214
+ if not files and not ahead:
215
+ self.logger.info("Nothing to commit or push.")
216
+ return
217
+
218
+ failed = []
219
+ if files:
220
+ groups = self.group_files(files)
221
+ cache = self.load_cache()
222
+
223
+ for group, group_files in groups.items():
224
+ try:
225
+ if not self.process_group(group, group_files, cache):
226
+ failed.append(group)
227
+ except KeyboardInterrupt:
228
+ self.logger.warning("\nOperation interrupted by user.")
229
+ raise
230
+ else:
231
+ self.logger.info("No changes to commit, checking push...")
232
+
233
+ if self.push and not self.dry_run:
234
+ if not failed:
235
+ self.push_commits()
236
+ else:
237
+ self.logger.error("Push aborted due to failed commits.")
238
+
239
+ if self.dry_run:
240
+ self.logger.info(f"Dry run done. See {self.dry_run_path}")
241
+ else:
242
+ self.logger.info("Done.")
243
+ if failed:
244
+ self.logger.warning(f"Failed groups: {failed}")
245
+
246
+
247
+ def run_commit_engine(**kwargs):
248
+ """Entry point for the commit engine."""
249
+ logger = configure_logger("devgen.commit")
250
+ try:
251
+ engine = CommitEngine(**kwargs)
252
+ engine.execute()
253
+ except Exception as e:
254
+ logger.error(f"Commit engine failed: {e}", exc_info=True)
255
+
256
+
257
+ __all__ = ["run_commit_engine"]
@@ -0,0 +1,116 @@
1
+ from pathlib import Path
2
+ from typing import List
3
+
4
+ import requests
5
+
6
+ from devgen.utils import configure_logger
7
+
8
+
9
+ class GitignoreGenerator:
10
+ """Fetches and manages .gitignore templates from GitHub."""
11
+
12
+ def __init__(self, logger=None):
13
+ self.logger = logger or configure_logger("devgen.gitignore")
14
+ self.cache_dir = Path.home() / ".cache" / "devgen" / "gitignore"
15
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
16
+ self.api_url = "https://api.github.com/repos/github/gitignore/contents/"
17
+ self.raw_url_base = "https://raw.githubusercontent.com/github/gitignore/main/"
18
+
19
+ def list_available_templates(self) -> List[str]:
20
+ """Lists available templates from GitHub."""
21
+ try:
22
+ response = requests.get(self.api_url, timeout=10)
23
+ response.raise_for_status()
24
+ data = response.json()
25
+ return [
26
+ item["name"].replace(".gitignore", "")
27
+ for item in data
28
+ if item["name"].endswith(".gitignore")
29
+ ]
30
+ except requests.RequestException as e:
31
+ self.logger.error(f"Failed to fetch templates: {e}")
32
+ raise RuntimeError(f"Failed to fetch templates: {e}")
33
+
34
+ def list_cached_templates(self) -> List[str]:
35
+ """Lists cached templates."""
36
+ if not self.cache_dir.exists():
37
+ return []
38
+ return [f.stem for f in self.cache_dir.glob("*.gitignore")]
39
+
40
+ def get_template_content(
41
+ self, name: str, use_cache: bool = True, offline: bool = False
42
+ ) -> str:
43
+ """Fetches template content from cache or GitHub."""
44
+ cache_file = self.cache_dir / f"{name}.gitignore"
45
+
46
+ if offline:
47
+ if cache_file.exists():
48
+ self.logger.info(f"Using cached template for {name}")
49
+ return cache_file.read_text(encoding="utf-8")
50
+ else:
51
+ raise RuntimeError(
52
+ f"Template '{name}' not found in cache (offline mode)."
53
+ )
54
+
55
+ # Try to fetch from GitHub
56
+ try:
57
+ url = f"{self.raw_url_base}{name}.gitignore"
58
+ response = requests.get(url, timeout=10)
59
+
60
+ if response.status_code == 404:
61
+ # Try with capitalized first letter if lowercase fails, though the list should be accurate
62
+ # The GitHub repo is case-sensitive for raw URLs usually matching the filename.
63
+ # The list_available_templates returns exact filenames (minus extension).
64
+ # So if we use the name from the list, it should be correct.
65
+ # However, if user inputs manually, we might need to handle case.
66
+ raise RuntimeError(f"Template '{name}' not found on GitHub.")
67
+
68
+ response.raise_for_status()
69
+ content = response.text
70
+
71
+ if use_cache:
72
+ cache_file.write_text(content, encoding="utf-8")
73
+ self.logger.info(f"Cached template for {name}")
74
+
75
+ return content
76
+
77
+ except requests.RequestException as e:
78
+ if cache_file.exists():
79
+ self.logger.warning(f"Failed to fetch {name}, using cache: {e}")
80
+ return cache_file.read_text(encoding="utf-8")
81
+ raise RuntimeError(f"Failed to fetch {name} and no cache available: {e}")
82
+
83
+ def generate(
84
+ self,
85
+ templates: List[str],
86
+ output_file: str = ".gitignore",
87
+ append: bool = True,
88
+ offline: bool = False,
89
+ ):
90
+ """Generates the .gitignore file."""
91
+ content = ""
92
+ for name in templates:
93
+ try:
94
+ tpl_content = self.get_template_content(name, offline=offline)
95
+ content += f"\n### {name} ###\n{tpl_content}\n"
96
+ except Exception as e:
97
+ self.logger.error(str(e))
98
+ print(f" Error fetching {name}: {e}")
99
+
100
+ if not content:
101
+ return
102
+
103
+ mode = "a" if append else "w"
104
+ path = Path(output_file)
105
+
106
+ if path.exists() and append:
107
+ print(f"Appending to {output_file}...")
108
+ elif path.exists() and not append:
109
+ print(f"Overwriting {output_file}...")
110
+ else:
111
+ print(f"Creating {output_file}...")
112
+
113
+ with path.open(mode, encoding="utf-8") as f:
114
+ f.write(content)
115
+
116
+ print(f" Successfully wrote to {output_file}")