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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/test_init_project.py
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,26 +57,27 @@ A template for Python applications.
[src/python_app_template/assets/]: src/python_app_template/assets/
[tests/]: tests/

[.coveragerc]: .coveragerc
[.dockerignore]: .dockerignore
[.flake8]: .flake8
[.gitignore]: .gitignore
[.pre-commit-config.yaml]: .pre-commit-config.yaml
[activate-venv.sh]: activate-venv.sh
[cloudbuild.yaml]: cloudbuild.yaml
[codecov.yml]: codecov.yml
[CONTRIBUTING.md]: CONTRIBUTING.md
[docker-compose.yml]: docker-compose.yml
[Dockerfile]: Dockerfile
[CONTRIBUTING.md]: CONTRIBUTING.md
[Git Workflow]: CONTRIBUTING.md#git-workflow
[init_project.py]: init_project.py
[LICENSE]: LICENSE
[Makefile]: Makefile
[MANIFEST.in]: MANIFEST.in
[pyproject.toml]: pyproject.toml
[pytest.ini]: pytest.ini
[README.md]: README.md
[requirements.txt]: requirements.txt
[requirements-test.txt]: requirements-test.txt
[README.md]: README.md
[setup.py]: setup.py
[Git Workflow]: CONTRIBUTING.md#git-workflow


## Introduction
Expand Down Expand Up @@ -186,6 +187,7 @@ This is a brief summary of all the relevant files of the repository.

| File | Description |
| -------------------------------| ------------------------------------------------------------------------------- |
|[.dockerignore] | Lists files and folders that Docker should ignore when building an image. |
|[.flake8] | Configuration for [PEP8] checker. |
|[.gitignore] | List of files and directories to be ignored by git. |
|[.pre-commit-config.yaml] | Configuration to automate software quality checks. |
Expand All @@ -195,6 +197,7 @@ This is a brief summary of all the relevant files of the repository.
|[CONTRIBUTING.md] | A guide for contributing to the project. |
|[docker-compose.yml] | Configuration for [docker compose]. |
|[Dockerfile] | Instructions for building the Docker image. |
|[init_project.py] | Automates changing the project name across relevant files and directories. |
|[LICENSE] | The software license. |
|[Makefile] | Set of commands to simplify development. |
|[MANIFEST.in] | Set of patterns to include or exclude files from installed package. |
Expand Down
65 changes: 36 additions & 29 deletions init_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

OLD_NAME = "python-app-template"

FILES_TO_UPDATE = [
FILES_TO_UPDATE = (
"cloudbuild.yaml",
"CONTRIBUTING.md",
"Makefile",
Expand All @@ -13,11 +13,11 @@
"requirements.txt",
"tests/test_assets.py",
"tests/test_version.py",
]
)

VERSION_FILE = "src/{old_name}/version.py"
VERSION_FILE_TPL = "{src_dir}/{old_name}/version.py"

SRC_DIR = pathlib.Path("src")
SOURCE_DIR = pathlib.Path("src")


class Name:
Expand Down Expand Up @@ -53,9 +53,9 @@ def _replace_in_file(filepath: pathlib.Path, old_new_pairs: list[tuple[str, str]
filepath.write_text(content, encoding="utf-8")


def _rename_package_dir(old_name: str, new_name: str) -> None:
old_path = SRC_DIR / old_name
new_path = SRC_DIR / new_name
def _rename_package_dir(src_dir: pathlib.Path, old_name: str, new_name: str) -> None:
old_path = src_dir / old_name
new_path = src_dir / new_name

if not old_path.exists():
print(f"⚠️ Warning: {old_path} does not exist.")
Expand All @@ -69,27 +69,18 @@ def _rename_package_dir(old_name: str, new_name: str) -> None:
print(f"📁 Renamed package dir: {old_path} → {new_path}")


def update_project_name(old_name: str, new_name: str) -> None:
"""Updates the project name in all relevant files and rename the source directory.

Args:
old_name:
The current project name (dashes or underscores allowed).

new_name:
The new project name input by the user (dashes or underscores allowed).
"""
old_name = Name(old_name)
new_name = Name(new_name)

def update_project_name(
old_name: Name,
new_name: Name,
src_dir: pathlib.Path,
files_to_update: tuple[str, ...] = (),
) -> None:
"""Updates the project name in all relevant files and rename the source directory."""
replacements = [
(old_name.with_dashes, new_name.with_dashes),
(old_name.with_underscores, new_name.with_underscores),
]

files_to_update = FILES_TO_UPDATE.copy()
files_to_update.append(VERSION_FILE.format(old_name=old_name.with_underscores))

print("🔁 Replacing names:")
for o, n in replacements:
print(f" {o} → {n}")
Expand All @@ -102,24 +93,40 @@ def update_project_name(old_name: str, new_name: str) -> None:
else:
print(f"⏭️ Skipped (not found): {file}")

_rename_package_dir(old_name.with_underscores, new_name.with_underscores)
_rename_package_dir(src_dir, old_name.with_underscores, new_name.with_underscores)
print("\n✅ Done!")
print(f"🎉 Project renamed to: {new_name.with_dashes}.")


def main():
def main(
args: list[str] = sys.argv,
old_name: str = OLD_NAME,
files_to_update: tuple[str, ...] = FILES_TO_UPDATE,
src_dir: pathlib.Path = SOURCE_DIR
):
"""Entry point for the script.

Expects a single command-line argument: the new project name (with dashes or underscores).
"""
example = Name("my-project-name")

if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} {example.with_dashes} OR {example.with_underscores}")
if len(args) != 2:
print(f"Usage: python {args[0]} {example.with_dashes} OR {example.with_underscores}")
sys.exit(1)

new_name = sys.argv[1]
update_project_name(OLD_NAME, new_name)
old_name = Name(old_name)
new_name = Name(args[1])

files_to_update = list(files_to_update)
version_file = VERSION_FILE_TPL.format(src_dir=src_dir, old_name=old_name.with_underscores)
files_to_update.append(version_file)

update_project_name(
old_name=old_name,
new_name=new_name,
files_to_update=tuple(files_to_update),
src_dir=src_dir
)


if __name__ == "__main__":
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,9 @@ disallow_untyped_calls = false
[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
addopts = "-v --cov=src --cov-report=term-missing"
addopts = "-v --cov=python_app_template --cov=init_project --cov-report=term-missing"

[tool.coverage.run]
source = ["src", "tests"]
branch = true
parallel = true
context = "${CONTEXT}"
Expand Down
2 changes: 1 addition & 1 deletion src/python_app_template/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def run(args: list):
logger.info("Starting APP (v{})...".format(__version__))


def main():
def main(): # Entry point for the python package.
run(sys.argv[1:])


Expand Down
136 changes: 136 additions & 0 deletions tests/test_init_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import sys
import pytest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import init_project # noqa


def test_main_renames_project(tmp_path):
# Setup
old_name = "python-app-template"
new_name = "my_project"

old_dir_name = old_name.replace("-", "_")
new_dir_name = new_name.replace("-", "_")

# Create a fake source directory and package
src_dir = tmp_path / "src"
src_dir.mkdir()
old_pkg_dir = src_dir / old_dir_name
old_pkg_dir.mkdir()

# Create a version file
version_file = old_pkg_dir / "version.py"
version_file.write_text("VERSION = '0.1.0'\n# python-app-template\n")

# Create a single additional file to update
readme = tmp_path / "README.md"
readme.write_text("Welcome to python-app-template!\n")

# Run main()
args = ["init_project.py", new_name]
files_to_update = (str(readme),)
init_project.main(
args=args,
old_name=old_name,
files_to_update=files_to_update,
src_dir=src_dir
)

# Check that file was updated
updated_readme = readme.read_text()
print(updated_readme)
assert "my-project" in updated_readme
assert "python-app-template" not in updated_readme

# Check that version file was updated
new_pkg_dir = src_dir / new_dir_name
new_version_file = new_pkg_dir / "version.py"
assert new_pkg_dir.exists()
assert new_version_file.exists()
assert "my-project" in new_version_file.read_text()

# Old package dir should be gone
assert not old_pkg_dir.exists()


def test_name_str():
name = init_project.Name("my_project-name")
expected = f"{name.with_dashes} / {name.with_underscores}"
assert str(name) == expected
# Specifically check the expected strings too
assert name.with_dashes == "my-project-name"
assert name.with_underscores == "my_project_name"


def test_rename_package_dir_old_path_missing(tmp_path, capsys):
src_dir = tmp_path
old_name = "oldpkg"
new_name = "newpkg"

# old_path does NOT exist (do nothing)
# new_path also doesn't exist

init_project._rename_package_dir(src_dir, old_name, new_name)

captured = capsys.readouterr()
expected_warning = f"⚠️ Warning: {src_dir / old_name} does not exist."
assert expected_warning in captured.out


def test_rename_package_dir_new_path_exists(tmp_path, capsys):
src_dir = tmp_path
old_name = "oldpkg"
new_name = "newpkg"

old_path = src_dir / old_name
new_path = src_dir / new_name

# Create old_path directory so it exists
old_path.mkdir()
# Create new_path directory so it exists and triggers the warning
new_path.mkdir()

init_project._rename_package_dir(src_dir, old_name, new_name)

captured = capsys.readouterr()
expected_warning = f"⚠️ Warning: {new_path} already exists."
assert expected_warning in captured.out


def test_update_project_name_skips_missing_file(tmp_path, capsys):
old_name = init_project.Name("old-project")
new_name = init_project.Name("new-project")

# File that does not exist
missing_file = tmp_path / "missing_file.txt"
# Don't create the file, so it is missing

init_project.update_project_name(
old_name=old_name,
new_name=new_name,
src_dir=tmp_path,
files_to_update=(str(missing_file),)
)

captured = capsys.readouterr()
assert f"⏭️ Skipped (not found): {missing_file}" in captured.out


def test_main_usage_message_on_bad_args(capsys):
bad_args = ["script_name.py"] # only 1 argument instead of 2

with pytest.raises(SystemExit) as exc_info:
init_project.main(args=bad_args)

captured = capsys.readouterr()
example = init_project.Name("my-project-name")
expected_usage = (
f"Usage: python {bad_args[0]} {example.with_dashes} "
f"OR {example.with_underscores}"
)

assert expected_usage in captured.out
assert exc_info.value.code == 1
Loading