diff --git a/pyproject.toml b/pyproject.toml index 960ebb3..ccaea82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/init.py b/scripts/init.py index 7ec9a97..2fe98ea 100644 --- a/scripts/init.py +++ b/scripts/init.py @@ -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 "" @@ -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), @@ -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('"', '\\"') @@ -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) @@ -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)