|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import re |
| 4 | +import shutil |
| 5 | +import subprocess |
4 | 6 | from collections.abc import Sequence |
5 | 7 | from datetime import date |
6 | 8 | from pathlib import Path |
@@ -39,6 +41,60 @@ def __init__(self, environment: Environment): |
39 | 41 | environment.globals["current_year"] = date.today().year |
40 | 42 |
|
41 | 43 |
|
| 44 | +python_version_choices = [ |
| 45 | + ("3.8", "Python 3.8"), |
| 46 | + ("3.9", "Python 3.9"), |
| 47 | + ("3.10", "Python 3.10"), |
| 48 | + ("3.11", "Python 3.11"), |
| 49 | + ("3.12", "Python 3.12"), |
| 50 | + ("3.13", "Python 3.13"), |
| 51 | + ("3.14", "Python 3.14"), |
| 52 | +] |
| 53 | + |
| 54 | +license_choices = [ |
| 55 | + ("None", "No license"), |
| 56 | + ("MIT", "MIT License"), |
| 57 | + ("Apache-2.0", "Apache License 2.0"), |
| 58 | + ("GPL-3.0", "GNU General Public License v3.0 only"), |
| 59 | + ("BSD-3-Clause", 'BSD 3-Clause "New" or "Revised" License'), |
| 60 | + ("Unlicense", "The Unlicense"), |
| 61 | + ("GPL-2.0", "GNU General Public License v2.0 only"), |
| 62 | + ("AGPL-3.0", "GNU Affero General Public License v3.0"), |
| 63 | + ("LGPL-3.0", "GNU Lesser General Public License v3.0 only"), |
| 64 | + ("LGPL-2.1", "GNU Lesser General Public License v2.1 only"), |
| 65 | + ("BSD-2-Clause", 'BSD 2-Clause "Simplified" License'), |
| 66 | + ("BSD-3-Clause-Clear", "BSD 3-Clause Clear License"), |
| 67 | + ("BSL-1.0", "Boost Software License 1.0"), |
| 68 | + ("CC-BY-4.0", "Creative Commons Attribution 4.0 International"), |
| 69 | + ("CC-BY-SA-4.0", "Creative Commons Attribution Share Alike 4.0"), |
| 70 | + ("CC0-1.0", "Creative Commons Zero v1.0 Universal"), |
| 71 | + ("WTFPL", "Do What The F*ck You Want To Public License"), |
| 72 | + ("AFL-3.0", "Academic Free License v3.0"), |
| 73 | + ("Artistic-2.0", "Artistic License 2.0"), |
| 74 | + ("ECL-2.0", "Educational Community License v2.0"), |
| 75 | + ("EPL-1.0", "Eclipse Public License 1.0"), |
| 76 | + ("EPL-2.0", "Eclipse Public License 2.0"), |
| 77 | + ("EUPL-1.1", "European Union Public License 1.1"), |
| 78 | + ("EUPL-1.2", "European Union Public License 1.2"), |
| 79 | + ("ISC", "ISC License"), |
| 80 | + ("LPPL-1.3c", "LaTeX Project Public License v1.3c"), |
| 81 | + ("MPL-2.0", "Mozilla Public License 2.0"), |
| 82 | + ("MS-PL", "Microsoft Public License"), |
| 83 | + ("MS-RL", "Microsoft Reciprocal License"), |
| 84 | + ("NCSA", "University of Illinois/NCSA Open Source License"), |
| 85 | + ("OFL-1.1", "SIL Open Font License 1.1"), |
| 86 | + ("OSL-3.0", "Open Software License 3.0"), |
| 87 | + ("PostgreSQL", "PostgreSQL License"), |
| 88 | + ("Zlib", "zlib License"), |
| 89 | +] |
| 90 | + |
| 91 | +github_actions_choices = [ |
| 92 | + ("tests", "Run tests"), |
| 93 | + ("lint", "Lint and format"), |
| 94 | + ("publish", "Publish to PyPI"), |
| 95 | +] |
| 96 | + |
| 97 | + |
42 | 98 | def validate_package_name( |
43 | 99 | value: str, answers: dict[str, Any] |
44 | 100 | ) -> tuple[bool, str | None]: |
@@ -101,58 +157,102 @@ def _python_identifier(name: str) -> str: |
101 | 157 | return sanitized |
102 | 158 |
|
103 | 159 |
|
104 | | -python_version_choices = [ |
105 | | - ("3.8", "Python 3.8"), |
106 | | - ("3.9", "Python 3.9"), |
107 | | - ("3.10", "Python 3.10"), |
108 | | - ("3.11", "Python 3.11"), |
109 | | - ("3.12", "Python 3.12"), |
110 | | - ("3.13", "Python 3.13"), |
111 | | - ("3.14", "Python 3.14"), |
112 | | -] |
| 160 | +def _is_github_repo_url(value: object) -> bool: |
| 161 | + if not isinstance(value, str): |
| 162 | + return False |
| 163 | + return value.strip().startswith("https://github.com/") |
113 | 164 |
|
114 | | -license_choices = [ |
115 | | - ("None", "No license"), |
116 | | - ("MIT", "MIT License"), |
117 | | - ("Apache-2.0", "Apache License 2.0"), |
118 | | - ("GPL-3.0", "GNU General Public License v3.0 only"), |
119 | | - ("BSD-3-Clause", 'BSD 3-Clause "New" or "Revised" License'), |
120 | | - ("Unlicense", "The Unlicense"), |
121 | | - ("GPL-2.0", "GNU General Public License v2.0 only"), |
122 | | - ("AGPL-3.0", "GNU Affero General Public License v3.0"), |
123 | | - ("LGPL-3.0", "GNU Lesser General Public License v3.0 only"), |
124 | | - ("LGPL-2.1", "GNU Lesser General Public License v2.1 only"), |
125 | | - ("BSD-2-Clause", 'BSD 2-Clause "Simplified" License'), |
126 | | - ("BSD-3-Clause-Clear", "BSD 3-Clause Clear License"), |
127 | | - ("BSL-1.0", "Boost Software License 1.0"), |
128 | | - ("CC-BY-4.0", "Creative Commons Attribution 4.0 International"), |
129 | | - ("CC-BY-SA-4.0", "Creative Commons Attribution Share Alike 4.0"), |
130 | | - ("CC0-1.0", "Creative Commons Zero v1.0 Universal"), |
131 | | - ("WTFPL", "Do What The F*ck You Want To Public License"), |
132 | | - ("AFL-3.0", "Academic Free License v3.0"), |
133 | | - ("Artistic-2.0", "Artistic License 2.0"), |
134 | | - ("ECL-2.0", "Educational Community License v2.0"), |
135 | | - ("EPL-1.0", "Eclipse Public License 1.0"), |
136 | | - ("EPL-2.0", "Eclipse Public License 2.0"), |
137 | | - ("EUPL-1.1", "European Union Public License 1.1"), |
138 | | - ("EUPL-1.2", "European Union Public License 1.2"), |
139 | | - ("ISC", "ISC License"), |
140 | | - ("LPPL-1.3c", "LaTeX Project Public License v1.3c"), |
141 | | - ("MPL-2.0", "Mozilla Public License 2.0"), |
142 | | - ("MS-PL", "Microsoft Public License"), |
143 | | - ("MS-RL", "Microsoft Reciprocal License"), |
144 | | - ("NCSA", "University of Illinois/NCSA Open Source License"), |
145 | | - ("OFL-1.1", "SIL Open Font License 1.1"), |
146 | | - ("OSL-3.0", "Open Software License 3.0"), |
147 | | - ("PostgreSQL", "PostgreSQL License"), |
148 | | - ("Zlib", "zlib License"), |
149 | | -] |
150 | 165 |
|
151 | | -github_actions_choices = [ |
152 | | - ("tests", "Run tests"), |
153 | | - ("lint", "Lint and format"), |
154 | | - ("publish", "Publish to PyPI"), |
155 | | -] |
| 166 | +def _github_repo_target(answers: dict[str, Any]) -> str: |
| 167 | + repository_url = str(answers.get("repository_url") or "").strip() |
| 168 | + match = re.match( |
| 169 | + r"^https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$", |
| 170 | + repository_url, |
| 171 | + ) |
| 172 | + if match: |
| 173 | + owner = match.group("owner") |
| 174 | + repo = match.group("repo") |
| 175 | + return f"{owner}/{repo}" |
| 176 | + |
| 177 | + repo_name = str(answers.get("repo_name") or "").strip() |
| 178 | + return repo_name or "my-package" |
| 179 | + |
| 180 | + |
| 181 | +def _ensure_git_repo(destination: Path, *, console: Any) -> bool: |
| 182 | + git_executable = shutil.which("git") |
| 183 | + if git_executable is None: |
| 184 | + console.print( |
| 185 | + "[yellow]Git is not installed; skipping local repository initialization.[/yellow]" |
| 186 | + ) |
| 187 | + return False |
| 188 | + |
| 189 | + if (destination / ".git").exists(): |
| 190 | + return True |
| 191 | + |
| 192 | + result = subprocess.run( |
| 193 | + [git_executable, "init"], |
| 194 | + cwd=destination, |
| 195 | + check=False, |
| 196 | + capture_output=True, |
| 197 | + text=True, |
| 198 | + ) |
| 199 | + if result.returncode == 0: |
| 200 | + return True |
| 201 | + |
| 202 | + details = result.stderr.strip() or result.stdout.strip() or "unknown error" |
| 203 | + console.print(f"[yellow]Failed to initialize git repository: {details}[/yellow]") |
| 204 | + return False |
| 205 | + |
| 206 | + |
| 207 | +def _create_github_repo( |
| 208 | + destination: Path, answers: dict[str, Any], *, console: Any |
| 209 | +) -> None: |
| 210 | + gh_executable = shutil.which("gh") |
| 211 | + if gh_executable is None: |
| 212 | + console.print( |
| 213 | + "[yellow]GitHub CLI not found; skipping repository creation.[/yellow]" |
| 214 | + ) |
| 215 | + return |
| 216 | + |
| 217 | + visibility = str(answers.get("github_repo_visibility") or "public").strip().lower() |
| 218 | + if visibility not in {"public", "private"}: |
| 219 | + visibility = "public" |
| 220 | + |
| 221 | + repo_target = _github_repo_target(answers) |
| 222 | + description = str(answers.get("description") or "").strip() |
| 223 | + |
| 224 | + command = [ |
| 225 | + gh_executable, |
| 226 | + "repo", |
| 227 | + "create", |
| 228 | + repo_target, |
| 229 | + f"--{visibility}", |
| 230 | + ] |
| 231 | + |
| 232 | + if description: |
| 233 | + command.extend(["--description", description]) |
| 234 | + |
| 235 | + if _ensure_git_repo(destination, console=console): |
| 236 | + command.extend(["--source", str(destination), "--remote", "origin"]) |
| 237 | + else: |
| 238 | + console.print( |
| 239 | + "[yellow]Proceeding to create GitHub repository without connecting the local folder.[/yellow]" |
| 240 | + ) |
| 241 | + |
| 242 | + result = subprocess.run( |
| 243 | + command, |
| 244 | + cwd=destination, |
| 245 | + capture_output=True, |
| 246 | + text=True, |
| 247 | + ) |
| 248 | + if result.returncode == 0: |
| 249 | + return |
| 250 | + |
| 251 | + details = result.stderr.strip() or result.stdout.strip() or "unknown error" |
| 252 | + console.print(f"[yellow]Failed to create GitHub repository: {details}[/yellow]") |
| 253 | + |
| 254 | + |
| 255 | +# Sprout manifest entrypoints |
156 | 256 |
|
157 | 257 |
|
158 | 258 | def should_skip_file(relative_path: str, answers: dict[str, Any]) -> bool: |
@@ -181,6 +281,7 @@ def should_skip_file(relative_path: str, answers: dict[str, Any]) -> bool: |
181 | 281 | def questions(env: Environment, destination: Path) -> list[Question]: |
182 | 282 | git_user_name = env.globals.get("git_user_name", "") |
183 | 283 | git_user_email = env.globals.get("git_user_email", "") |
| 284 | + gh_available = shutil.which("gh") is not None |
184 | 285 |
|
185 | 286 | suggested_package = _python_identifier(destination.name) |
186 | 287 |
|
@@ -296,6 +397,26 @@ def default_python_default_version(answers: dict[str, Any]) -> str: |
296 | 397 | default=default_repository_url, |
297 | 398 | validators=[validate_repository_url], |
298 | 399 | ), |
| 400 | + Question.yes_no( |
| 401 | + key="create_github_repo", |
| 402 | + prompt="Create GitHub repository now?", |
| 403 | + help_text="Uses GitHub CLI (`gh repo create`) after files are generated.", |
| 404 | + default=False, |
| 405 | + when=gh_available, |
| 406 | + ), |
| 407 | + Question( |
| 408 | + key="github_repo_visibility", |
| 409 | + prompt="GitHub repository visibility", |
| 410 | + choices=[("public", "Public"), ("private", "Private")], |
| 411 | + default="public", |
| 412 | + when=lambda answers: bool(answers.get("create_github_repo")), |
| 413 | + ), |
| 414 | + Question.yes_no( |
| 415 | + key="git_init", |
| 416 | + prompt="Initialize a local git repository?", |
| 417 | + default=True, |
| 418 | + when=lambda answers: not bool(answers.get("create_github_repo")), |
| 419 | + ), |
299 | 420 | Question( |
300 | 421 | key="python_min_version", |
301 | 422 | prompt="Minimum supported Python version", |
@@ -330,16 +451,44 @@ def default_python_default_version(answers: dict[str, Any]) -> str: |
330 | 451 | multiselect=True, |
331 | 452 | default=[], |
332 | 453 | ), |
333 | | - Question( |
| 454 | + Question.yes_no( |
334 | 455 | key="readme_badges", |
335 | 456 | prompt="Include README badges?", |
336 | | - choices=[("yes", "Yes"), ("no", "No")], |
337 | | - default="yes", |
338 | | - parser=lambda value, answers: value.lower() in {"yes", "y", "true", "1"}, |
| 457 | + default=True, |
339 | 458 | ), |
340 | 459 | ] |
341 | 460 |
|
342 | 461 |
|
| 462 | +def apply( |
| 463 | + *, |
| 464 | + env: Environment, |
| 465 | + template_dir: Path, |
| 466 | + destination: Path, |
| 467 | + answers: dict[str, Any], |
| 468 | + console: Any, |
| 469 | + render_templates: Any, |
| 470 | +) -> list[Path]: |
| 471 | + created = render_templates( |
| 472 | + env, |
| 473 | + template_dir, |
| 474 | + destination, |
| 475 | + answers, |
| 476 | + skip=should_skip_file, |
| 477 | + render_paths=True, |
| 478 | + ) |
| 479 | + |
| 480 | + if bool(answers.get("create_github_repo")): |
| 481 | + if not _is_github_repo_url(answers.get("repository_url")): |
| 482 | + console.print( |
| 483 | + "[yellow]Repository URL is not a GitHub URL; GitHub repository will use repo name only.[/yellow]" |
| 484 | + ) |
| 485 | + _create_github_repo(destination, answers, console=console) |
| 486 | + elif bool(answers.get("git_init")): |
| 487 | + _ensure_git_repo(destination, console=console) |
| 488 | + |
| 489 | + return created |
| 490 | + |
| 491 | + |
343 | 492 | extensions: Sequence[type[Extension]] = ( |
344 | 493 | GitDefaultsExtension, |
345 | 494 | PythonVersionExtension, |
|
0 commit comments