From 5af8fd3d42f00ec7c1f0315a1f647bcab9b54302 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 8 Jul 2025 14:46:24 -0700 Subject: [PATCH 1/2] feat(rename): Include simple script to quickly rename references to the old project name, and update to new project name. --- .gitignore | 1 + rename.py | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 rename.py diff --git a/.gitignore b/.gitignore index 103fb24..8826ec0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ !tox.ini !requirements.txt !.python-version-default +!rename.py # recursively re-ignore __pycache__ diff --git a/rename.py b/rename.py new file mode 100644 index 0000000..d48a1c3 --- /dev/null +++ b/rename.py @@ -0,0 +1,274 @@ +# BSD 3-Clause License +# +# Copyright (c) 2025, Spill-Tea +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Primitive script to update repository (project) name throughout template. + +Arguments: + new-name (str): new project name (defaults to root directory name) + old-name (str): old project name (defaults to PyTemplate) + path (str): root path of project (defaults to cwd) + dry-run (bool): print out what files / directories would be modified + timeout (int): Time in seconds to allow a subprocess to run. + +Notes: + * client must have git installed. + +""" + +import argparse +import os +import subprocess +from collections.abc import Iterator + + +def bypass(path: str, git_root: str, timeout: int = 1) -> bool: + """Use git to identify if a path is ignored as specified by a .gitignore file.""" + # NOTE: Do not capture errors to avoid assuming a path is included or not. + result = subprocess.run( + ["git", "-C", git_root, "check-ignore", path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=timeout, + ) + + return result.returncode == 0 + + +def find_git_root(start_path: str, timeout: int = 1) -> str: + """Confirm we are in a git repository.""" + try: + result = subprocess.run( + ["git", "-C", start_path, "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout.strip() + + except subprocess.CalledProcessError as e: + raise RuntimeError("This script must be run inside a Git repository.") from e + + +def safe_scandir(path: str) -> Iterator[os.DirEntry]: + """Wrapper around os.scandir.""" + try: + with os.scandir(path) as it: + yield from it + except PermissionError: + return + + +def replace_in_file( + filepath: str, + old: str, + new: str, + dry_run: bool = False, +) -> int: + """Replace an old keyword found within a file.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + except (UnicodeDecodeError, FileNotFoundError) as e: + print(f"[Warning] ({e.__class__.__name__}) {filepath}") + return 0 + + if old not in content: + return 0 + + if dry_run: + print(f"[DRY RUN] Would update content within file: {filepath}") + + else: + new_content: str = content.replace(old, new) + with open(filepath, "w", encoding="utf-8") as f: + f.write(new_content) + print(f"Updated content within file: {filepath}") + + return 1 + + +def update_project_name( + path: str, + old_name: str, + new_name: str, + dry_run: bool, + git_root: str, + timeout: int = 1, +) -> int: + """Recursively search, and modify files in place to update project name if used.""" + count = 0 + for entry in safe_scandir(path): + full_path = os.path.join(path, entry.name) + + if bypass(full_path, git_root, timeout): + continue + + if entry.is_dir(follow_symlinks=False): + count += update_project_name( + full_path, old_name, new_name, dry_run, git_root + ) + + elif entry.is_file(follow_symlinks=False): + count += replace_in_file(full_path, old_name, new_name, dry_run) + + return count + + +def _filetype(entry: os.DirEntry) -> str: + key: str = "" + if entry.is_file(follow_symlinks=False): + key = " filepath" + elif entry.is_dir(follow_symlinks=False): + key = " directory" + + return key + + +def rename_directories_and_files( + path: str, + old_name: str, + new_name: str, + dry_run: bool, + git_root: str, + timeout: int = 1, +) -> int: + """Rename both directories and filenames alike if old keyword present.""" + count: int = 0 + for entry in safe_scandir(path): + full_path = os.path.join(path, entry.name) + if bypass(full_path, git_root, timeout): + continue + + # NOTE: Depth First Search. Handle all children before renaming a directory. + if entry.is_dir(follow_symlinks=False): + count += rename_directories_and_files( + full_path, old_name, new_name, dry_run, git_root + ) + + if old_name not in entry.name: + continue + + new_path = os.path.join(path, entry.name.replace(old_name, new_name)) + key: str = _filetype(entry) + if dry_run: + print(f"[DRY RUN] Would rename{key}: {full_path} -> {new_path}") + else: + os.rename(full_path, new_path) + print(f"Renamed{key}: {full_path} -> {new_path}") + count += 1 + + return count + + +def parse_args() -> argparse.Namespace: + """Define and return parsed arguments.""" + parser = argparse.ArgumentParser(description="Rename a Python project template.") + parser.add_argument( + "--new-name", + help="New project name (e.g. my_project)", + ) + parser.add_argument( + "--old-name", + default="PyTemplate", + help="Old project name to replace (optional, defaults to PyTemplate)", + ) + parser.add_argument( + "--path", + default=".", + help="Root path of the project (default: current directory)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change, but don't modify anything", + ) + parser.add_argument( + "--timeout", + default=1, + help="Time in seconds to allow a subprocess to run.", + type=int, + ) + + return parser.parse_args() + + +def main() -> None: + """Main script Entry point.""" + args: argparse.Namespace = parse_args() + git_root: str = find_git_root(args.path) + + # NOTE: When this template is forked, the project should be renamed. So we can + # reasonably assume the name of the new project. Report assumption to client. + if args.new_name is None: + args.new_name = os.path.basename(git_root) + print(f"Assuming new project name: {args.new_name}") + + if args.new_name == args.old_name: + print("Exiting. Both New and old names are identical.") + return + + print( + f"Project Found at: '{args.path}'", + f"Replacing '{args.old_name}' --> '{args.new_name}'", + sep="\n", + ) + if args.dry_run: + print("[DRY RUN] Confirming Dry Run Mode. No changes will be made.") + + # NOTE: this script may also be updated to reflect the new project name. + total: int = 0 + print("\nStep I: Update File contents.") + total += update_project_name( + args.path, + args.old_name, + args.new_name, + dry_run=args.dry_run, + git_root=git_root, + timeout=args.timeout, + ) + print("\nStep II: Update Filepath Names.") + total += rename_directories_and_files( + args.path, + args.old_name, + args.new_name, + dry_run=args.dry_run, + git_root=git_root, + timeout=args.timeout, + ) + + if args.dry_run: + print(f"\n[DRY RUN] Complete. Would modify {total} file(s).") + else: + print(f"Success. Modified {total} file(s) in total.") + + +if __name__ == "__main__": + main() From b320145314041b11af6acbbbd13d4f6fdb5a0fd4 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 8 Jul 2025 15:34:45 -0700 Subject: [PATCH 2/2] feat(readme): Update readme to describe usage of the new helper script and provide instruction to use this template. --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 55ef5c3..d9281e5 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,52 @@ from github. ## Table of Contents - [PyTemplate](#pytemplate) + - [Using this template](#using-this-template) + - [Manual Editing of Project Template](#manual-editing-of-project-template) - [Installation](#installation) - [For Developers](#for-developers) - [License](#license) +## Using this template +Create a new repository using the `Use this template` option available on github. +Clone that new repository (e.g. `mynewproject`), and run the helper script `rename.py`. + +```bash +git clone https://github.com//.git +cd +python rename.py --old-name PyTemplate --new-name + +``` +We provide a simple helper script `rename.py` in the root directory to help rename a few +files and directory names to make your life easier. Please note that you will still need +to manually adjust the `pyproject.toml` file, specifically the `[project]` and +`[project.urls]` keys, to reflect your new project metadata. + +Also manually update the `docs/source/conf.py` file to reflect correct `author`, +`copyright`, and `release` key metadata for documentation builds. + +Finally update this `README.md` document to reflect new project urls. + +### Manual Editing of Project Template +To summarize, after running the `rename.py` script, there are three files you may need +to manually adjust for your new project: + +1. `pyproject.toml` --> update metadata +1. `docs/source/conf.py` --> update metadata +1. `README.md` --> update project urls (and license type if different) + +Note: If you need to update the `LICENSE` file, you will also need to edit the license +header from files throughout the `src/` and `tests/` directories. + +PRO-TIP: you could theoretically run the helper script several times to replace the +project name, author name, email, and (github) username. Something like: + +```bash +python rename.py --old-name PyTemplate --new-name +python rename.py --old-name 'Jason C Del Rio' --new-name +python rename.py --old-name spillthetea917@gmail.com --new-name +python rename.py --old-name Spill-Tea --new-name +``` ## Installation Clone the repository and pip install. @@ -31,7 +73,8 @@ pip install git+https://github.com/Spill-Tea/PyTemplate@main ## For Developers -After cloning the repository, create a new virtual environment and run the following commands: +After cloning the repository, create a new virtual environment and run the following +commands: ```bash pip install -e ".[dev]"