Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ build-backend = "hatchling.build"
line-length = 120

[tool.ruff.lint]
select = ["E", "I", "S"]
select = ["E", "I", "S", "B", "TRY", "PTH"]
ignore = ["TRY003"]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
Expand Down
136 changes: 72 additions & 64 deletions scripts/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

def _get_git_config(key: str) -> str:
try:
return subprocess.check_output(["/usr/bin/git", "config", key], text=True).strip() # noqa: S603
except (subprocess.CalledProcessError, FileNotFoundError):
return subprocess.check_output(["/usr/bin/git", "config", key], text=True, timeout=5).strip() # noqa: S603
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return ""


Expand All @@ -23,51 +23,20 @@ def _get_default_github() -> str:
# Try to extract from remote URL
try:
url = subprocess.check_output( # noqa: S603
["/usr/bin/git", "remote", "get-url", "origin"], text=True
["/usr/bin/git", "remote", "get-url", "origin"], text=True, timeout=5
).strip()
if "github.com" in url:
if url.startswith("https"):
return url.split("/")[-2]
if url.startswith("git@"):
return url.split(":")[-1].split("/")[0]
except (subprocess.CalledProcessError, FileNotFoundError):
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
pass

return ""


@command(context_settings={"help_option_names": ["-h", "--help"]})
@option(
"--name",
prompt="Project name",
default=lambda: Path.cwd().name,
help="Project new name",
)
@option(
"--description",
prompt="Project description",
default="A Python project",
help="Project short description",
)
@option(
"--author",
prompt="Author name",
default=lambda: _get_git_config("user.name"),
help="Author name",
)
@option(
"--email",
prompt="Author email",
default=lambda: _get_git_config("user.email"),
help="Author email",
)
@option(
"--github",
prompt="GitHub username",
default=_get_default_github,
help="GitHub username",
)
def main(name: str, description: str, author: str, email: str, github: str):
def _validate_inputs(name: str, description: str, author: str, email: str, github: str):
# Validate inputs to prevent configuration injection
for label, value in [
("name", name),
Expand All @@ -91,9 +60,11 @@ def main(name: str, description: str, author: str, email: str, github: str):
if not re.match(r"^[a-zA-Z0-9-]+$", github):
raise UsageError(f"Invalid GitHub username '{github}'. Only alphanumeric characters and dashes are allowed.")

if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
if not re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
raise UsageError(f"Invalid email address '{email}'.")


def _perform_replacements(source: str, github: str, name: str, description: str, author: str, email: str):
# Sanitize for TOML double-quoted strings (escape backslashes and double quotes)
def toml_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
Expand All @@ -102,6 +73,67 @@ def toml_escape(s: str) -> str:
escaped_author = toml_escape(author)
escaped_email = toml_escape(email)

replacements = [
("docs/reference/app.md", r"^::: project\.app", f"::: {source}.app"),
("mkdocs.yml", r"^repo_name: .*", f"repo_name: {github}/{name}"),
("mkdocs.yml", r"^repo_url: .*", f"repo_url: https://github.com/{github}/{name}"),
("pyproject.toml", r"^source = \[.*\]", f'source = ["{source}"]'),
("pyproject.toml", r'^app = "project\.app:main"', f'app = "{source}.app:main"'),
("pyproject.toml", r'^name = ".*"', f'name = "{source}"'),
("pyproject.toml", r'^description = ".*"', f'description = "{escaped_description}"'),
("pyproject.toml", r"^authors = \[.*\]", f'authors = ["{escaped_author} <{escaped_email}>"]'),
("docs/README.md", r"^# .*", f"# {description}"),
(".github/CODEOWNERS", r"@.*", f"@{github}"),
(".github/FUNDING.yml", r"^github: \[.*\]", f"github: [{github}]"),
]

for filepath, pattern, replacement in replacements:
path = Path(filepath)
if not path.exists():
secho(f" Warning: File {filepath} not found, skipping. ⚠️", fg="yellow")
continue

content = path.read_text()
# Use a lambda for replacement to avoid regex backreference injection
new_content = re.sub(pattern, lambda _, r=replacement: r, content, flags=re.MULTILINE)
path.write_text(new_content)
secho(f" Updated {filepath} βœ…", fg="blue")


@command(context_settings={"help_option_names": ["-h", "--help"]})
@option(
"--name",
prompt="Project name",
default=lambda: Path.cwd().name,
help="Project new name",
)
@option(
"--description",
prompt="Project description",
default="A Python project",
help="Project short description",
)
@option(
"--author",
prompt="Author name",
default=lambda: _get_git_config("user.name"),
help="Author name",
)
@option(
"--email",
prompt="Author email",
default=lambda: _get_git_config("user.email"),
help="Author email",
)
@option(
"--github",
prompt="GitHub username",
default=_get_default_github,
help="GitHub username",
)
def main(name: str, description: str, author: str, email: str, github: str):
_validate_inputs(name, description, author, email, github)

source = name.replace("-", "_").lower()

secho("\nProject Configuration:", bold=True)
Expand All @@ -124,38 +156,14 @@ def print_field(label: str, value: str):
secho(f"\nInitializing project '{name}'... πŸš€", fg="green", bold=True)

# 1. Rename project directory
if os.path.isdir("project"):
if Path("project").is_dir():
shutil.move("project", source)
secho(f"Renamed 'project' directory to '{source}'", fg="blue")
elif not os.path.isdir(source):
elif not Path(source).is_dir():
raise ClickException(f"Error: Neither 'project' nor '{source}' directory found.")

# 2. File modifications
replacements = [
("docs/reference/app.md", r"^::: project\.app", f"::: {source}.app"),
("mkdocs.yml", r"^repo_name: .*", f"repo_name: {github}/{name}"),
("mkdocs.yml", r"^repo_url: .*", f"repo_url: https://github.com/{github}/{name}"),
("pyproject.toml", r"^source = \[.*\]", f'source = ["{source}"]'),
("pyproject.toml", r'^app = "project\.app:main"', f'app = "{source}.app:main"'),
("pyproject.toml", r'^name = ".*"', f'name = "{source}"'),
("pyproject.toml", r'^description = ".*"', f'description = "{escaped_description}"'),
("pyproject.toml", r"^authors = \[.*\]", f'authors = ["{escaped_author} <{escaped_email}>"]'),
("docs/README.md", r"^# .*", f"# {description}"),
(".github/CODEOWNERS", r"@.*", f"@{github}"),
(".github/FUNDING.yml", r"^github: \[.*\]", f"github: [{github}]"),
]

for filepath, pattern, replacement in replacements:
path = Path(filepath)
if not path.exists():
secho(f" Warning: File {filepath} not found, skipping. ⚠️", fg="yellow")
continue

content = path.read_text()
# Use a lambda for replacement to avoid regex backreference injection
new_content = re.sub(pattern, lambda _: replacement, content, flags=re.MULTILINE)
path.write_text(new_content)
secho(f" Updated {filepath} βœ…", fg="blue")
_perform_replacements(source, github, name, description, author, email)

secho("\nProject initialization complete! ✨", fg="green", bold=True)

Expand Down