From 7524af86876fab4d1921e32b572de4efe6607f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Link?= Date: Mon, 30 Jun 2025 11:45:10 -0300 Subject: [PATCH 1/2] Refactor init_project.py to be unit testable --- init_project.py => bootstrap/init_project.py | 47 +++++++++++++------- pyproject.toml | 2 +- src/python_app_template/cli/main.py | 2 +- 3 files changed, 33 insertions(+), 18 deletions(-) rename init_project.py => bootstrap/init_project.py (73%) diff --git a/init_project.py b/bootstrap/init_project.py similarity index 73% rename from init_project.py rename to bootstrap/init_project.py index 3efdd67..5ed24ca 100644 --- a/init_project.py +++ b/bootstrap/init_project.py @@ -4,7 +4,7 @@ OLD_NAME = "python-app-template" -FILES_TO_UPDATE = [ +FILES_TO_UPDATE = ( "cloudbuild.yaml", "CONTRIBUTING.md", "Makefile", @@ -13,11 +13,11 @@ "requirements.txt", "tests/test_assets.py", "tests/test_version.py", -] +) -VERSION_FILE = "src/{old_name}/version.py" +VERSION_FILE = "{src_dir}/{old_name}/version.py" -SRC_DIR = pathlib.Path("src") +SOURCE_DIR = pathlib.Path("src") class Name: @@ -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: str, 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.") @@ -69,7 +69,12 @@ 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: +def update_project_name( + old_name: str, + new_name: str, + src_dir: str, + files_to_update: tuple[str] = (), +) -> None: """Updates the project name in all relevant files and rename the source directory. Args: @@ -87,8 +92,9 @@ def update_project_name(old_name: str, new_name: str) -> None: (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)) + files_to_update = list(files_to_update) + version_file = VERSION_FILE.format(src_dir=src_dir, old_name=old_name.with_underscores) + files_to_update.append(version_file) print("šŸ” Replacing names:") for o, n in replacements: @@ -102,24 +108,33 @@ 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: list[str] = FILES_TO_UPDATE, + src_dir: str = 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) + update_project_name( + old_name=old_name, + new_name=args[1], + files_to_update=files_to_update, + src_dir=src_dir + ) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index e70b01f..bdfdbd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,7 +143,7 @@ testpaths = ["tests"] addopts = "-v --cov=src --cov-report=term-missing" [tool.coverage.run] -source = ["src", "tests"] +source = ["src", "tests", "bootstrap"] branch = true parallel = true context = "${CONTEXT}" diff --git a/src/python_app_template/cli/main.py b/src/python_app_template/cli/main.py index 9d8b426..3699030 100644 --- a/src/python_app_template/cli/main.py +++ b/src/python_app_template/cli/main.py @@ -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:]) From 8867f6c1f05a95a6e9a29d8d211f6ca6b79246f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Link?= Date: Mon, 30 Jun 2025 13:41:17 -0300 Subject: [PATCH 2/2] Add test for init_project.py --- .dockerignore | 1 + README.md | 11 +- bootstrap/init_project.py => init_project.py | 44 +++--- pyproject.toml | 3 +- tests/test_init_project.py | 136 +++++++++++++++++++ 5 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 .dockerignore rename bootstrap/init_project.py => init_project.py (82%) create mode 100644 tests/test_init_project.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e713043 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +tests/test_init_project.py \ No newline at end of file diff --git a/README.md b/README.md index cd8110f..d401d19 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. | @@ -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. | diff --git a/bootstrap/init_project.py b/init_project.py similarity index 82% rename from bootstrap/init_project.py rename to init_project.py index 5ed24ca..4562d9f 100644 --- a/bootstrap/init_project.py +++ b/init_project.py @@ -15,7 +15,7 @@ "tests/test_version.py", ) -VERSION_FILE = "{src_dir}/{old_name}/version.py" +VERSION_FILE_TPL = "{src_dir}/{old_name}/version.py" SOURCE_DIR = pathlib.Path("src") @@ -53,7 +53,7 @@ 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(src_dir: str, old_name: str, new_name: str) -> None: +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 @@ -70,32 +70,17 @@ def _rename_package_dir(src_dir: str, old_name: str, new_name: str) -> None: def update_project_name( - old_name: str, - new_name: str, - src_dir: str, - files_to_update: tuple[str] = (), + 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. - - 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) - + """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 = list(files_to_update) - version_file = VERSION_FILE.format(src_dir=src_dir, old_name=old_name.with_underscores) - files_to_update.append(version_file) - print("šŸ” Replacing names:") for o, n in replacements: print(f" {o} → {n}") @@ -116,8 +101,8 @@ def update_project_name( def main( args: list[str] = sys.argv, old_name: str = OLD_NAME, - files_to_update: list[str] = FILES_TO_UPDATE, - src_dir: str = SOURCE_DIR + files_to_update: tuple[str, ...] = FILES_TO_UPDATE, + src_dir: pathlib.Path = SOURCE_DIR ): """Entry point for the script. @@ -129,10 +114,17 @@ def main( print(f"Usage: python {args[0]} {example.with_dashes} OR {example.with_underscores}") sys.exit(1) + 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=args[1], - files_to_update=files_to_update, + new_name=new_name, + files_to_update=tuple(files_to_update), src_dir=src_dir ) diff --git a/pyproject.toml b/pyproject.toml index bdfdbd0..c9cb008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", "bootstrap"] branch = true parallel = true context = "${CONTEXT}" diff --git a/tests/test_init_project.py b/tests/test_init_project.py new file mode 100644 index 0000000..ebb578b --- /dev/null +++ b/tests/test_init_project.py @@ -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