Skip to content

Commit b4cd0b2

Browse files
committed
feat: add optional GitHub repo creation via gh CLI
1 parent 989cac4 commit b4cd0b2

2 files changed

Lines changed: 204 additions & 54 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ My personal template for Python projects.
1010
* **Code quality tools**: [ruff](https://docs.astral.sh/ruff/) for linting and formatting, pre-commit hooks
1111
* **Testing setup** with [pytest](https://docs.pytest.org/en/stable/) and [Hatch](https://hatch.pypa.io/latest/) for cross-version testing
1212
* **GitHub Actions integration**: optional workflows for linting, testing and PyPI publishing
13+
* **Conditional repo setup**: if `gh` is installed, optionally create a public/private GitHub repo
1314
* **Licenses from** [choosealicense.com](https://choosealicense.com/)
1415
* **Basic ```README.md```** with badges and installation instructions
1516

sprout.py

Lines changed: 203 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import re
4+
import shutil
5+
import subprocess
46
from collections.abc import Sequence
57
from datetime import date
68
from pathlib import Path
@@ -39,6 +41,60 @@ def __init__(self, environment: Environment):
3941
environment.globals["current_year"] = date.today().year
4042

4143

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+
4298
def validate_package_name(
4399
value: str, answers: dict[str, Any]
44100
) -> tuple[bool, str | None]:
@@ -101,58 +157,102 @@ def _python_identifier(name: str) -> str:
101157
return sanitized
102158

103159

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/")
113164

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-
]
150165

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
156256

157257

158258
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:
181281
def questions(env: Environment, destination: Path) -> list[Question]:
182282
git_user_name = env.globals.get("git_user_name", "")
183283
git_user_email = env.globals.get("git_user_email", "")
284+
gh_available = shutil.which("gh") is not None
184285

185286
suggested_package = _python_identifier(destination.name)
186287

@@ -296,6 +397,26 @@ def default_python_default_version(answers: dict[str, Any]) -> str:
296397
default=default_repository_url,
297398
validators=[validate_repository_url],
298399
),
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+
),
299420
Question(
300421
key="python_min_version",
301422
prompt="Minimum supported Python version",
@@ -330,16 +451,44 @@ def default_python_default_version(answers: dict[str, Any]) -> str:
330451
multiselect=True,
331452
default=[],
332453
),
333-
Question(
454+
Question.yes_no(
334455
key="readme_badges",
335456
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,
339458
),
340459
]
341460

342461

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+
343492
extensions: Sequence[type[Extension]] = (
344493
GitDefaultsExtension,
345494
PythonVersionExtension,

0 commit comments

Comments
 (0)