Skip to content
This repository was archived by the owner on Jul 29, 2024. It is now read-only.

Commit 9a8506d

Browse files
Script that is run at startup before cookiecutter is invoked (#9)
2 parents a59ac54 + b89d369 commit 9a8506d

File tree

4 files changed

+113
-2
lines changed

4 files changed

+113
-2
lines changed

src/PythonProjectBootstrapper/EntryPoint.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# ----------------------------------------------------------------------
77
"""This file serves as an example of how to create scripts that can be invoked from the command line once the package is installed."""
88

9+
import importlib
910
import sys
1011

1112
from enum import Enum
@@ -17,6 +18,7 @@
1718
from typer.core import TyperGroup # type: ignore [import-untyped]
1819

1920
from cookiecutter.main import cookiecutter
21+
from dbrownell_Common.ContextlibEx import ExitStack
2022
from dbrownell_Common import PathEx
2123
from PythonProjectBootstrapper import __version__
2224

@@ -65,6 +67,7 @@ class ProjectType(str, Enum):
6567
_replay_option = typer.Option(
6668
"--replay", help="Do not prompt for input, instead read from saved json."
6769
)
70+
_yes_option = typer.Option("--yes", help="Answer yes to all prompts.")
6871
_version_option = typer.Option("--version", help="Display the current version and exit.")
6972

7073

@@ -91,6 +94,7 @@ def FrozenExecute(
9194
],
9295
configuration_filename: Annotated[Optional[Path], _configuration_filename_option] = None,
9396
replay: Annotated[bool, _replay_option] = False,
97+
yes: Annotated[bool, _yes_option] = False,
9498
version: Annotated[bool, _version_option] = False,
9599
) -> None:
96100
if output_dir.is_file():
@@ -105,6 +109,7 @@ def FrozenExecute(
105109
output_dir,
106110
configuration_filename,
107111
replay=replay,
112+
yes=yes,
108113
version=version,
109114
)
110115

@@ -122,13 +127,15 @@ def StandardExecute(
122127
],
123128
configuration_filename: Annotated[Optional[Path], _configuration_filename_option] = None,
124129
replay: Annotated[bool, _replay_option] = False,
130+
yes: Annotated[bool, _yes_option] = False,
125131
version: Annotated[bool, _version_option] = False,
126132
) -> None:
127133
_ExecuteOutputDir(
128134
project,
129135
output_dir,
130136
configuration_filename,
131137
replay=replay,
138+
yes=yes,
132139
version=version,
133140
)
134141

@@ -145,14 +152,29 @@ def _ExecuteOutputDir(
145152
configuration_filename: Optional[Path],
146153
*,
147154
replay: bool,
155+
yes: bool,
148156
version: bool,
149157
) -> None:
150158
if version:
151159
sys.stdout.write(__version__)
152160
return
153161

162+
project_dir = PathEx.EnsureDir(_project_root_dir / project.value)
163+
164+
# Does the project have a startup script? If so, invoke it dynamically.
165+
potential_startup_script = project_dir / "hooks" / "startup.py"
166+
if potential_startup_script.is_file():
167+
sys.path.insert(0, str(potential_startup_script.parent))
168+
with ExitStack(lambda: sys.path.pop(0)):
169+
module = importlib.import_module(potential_startup_script.stem)
170+
171+
execute_func = getattr(module, "Execute", None)
172+
if execute_func:
173+
if execute_func(project_dir, output_dir, yes=yes) is False:
174+
return
175+
154176
cookiecutter(
155-
str(PathEx.EnsureDir(_project_root_dir / project.value)),
177+
str(project_dir),
156178
output_dir=str(output_dir),
157179
config_file=str(configuration_filename) if configuration_filename is not None else None,
158180
replay=replay,

src/PythonProjectBootstrapper/package/hooks/post_gen_project.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pathlib import Path
1414

1515
from dbrownell_Common import PathEx
16-
from rich import print
16+
from rich import print # pylint: disable=redefined-builtin
1717
from rich.panel import Panel
1818

1919

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# ----------------------------------------------------------------------
2+
# |
3+
# | Copyright (c) 2024 Scientific Software Engineering Center at Georgia Tech
4+
# | Distributed under the MIT License.
5+
# |
6+
# ----------------------------------------------------------------------
7+
import sys
8+
import textwrap
9+
import uuid
10+
11+
from pathlib import Path
12+
13+
from rich import print # pylint: disable=redefined-builtin
14+
from rich.panel import Panel
15+
16+
17+
# ----------------------------------------------------------------------
18+
def Execute(
19+
template_dir: Path, # pylint: disable=unused-argument
20+
output_dir: Path,
21+
*,
22+
yes: bool,
23+
) -> bool:
24+
# Ensure that the panel content is easy to read and modify here, but also leverages Panel's word
25+
# wrapping capabilities.
26+
panel_content = textwrap.dedent(
27+
"""\
28+
This project creates a Python package hosted on GitHub that uploads a Python wheel to PyPi.
29+
It also includes opt-in functionality to create docker images that ensure the exact
30+
reproducibility of all commits (which is especially useful for scientific software).
31+
32+
If you continue, you will be asked a series of questions about your project and given
33+
step-by-step instructions on how to set up your project so that it works with 3rd party
34+
solutions (GitHub, PyPi, etc.).
35+
36+
The entire process should take about 10 minutes to complete.
37+
""",
38+
)
39+
40+
paragraph_sentinel = str(uuid.uuid4())
41+
42+
panel_content = (
43+
panel_content.replace("\n\n", paragraph_sentinel)
44+
.replace("\n", " ")
45+
.replace(paragraph_sentinel, "\n\n")
46+
)
47+
48+
sys.stdout.write("\n")
49+
50+
print(
51+
Panel(
52+
panel_content.rstrip(),
53+
border_style="red",
54+
padding=1,
55+
title="Python Package",
56+
),
57+
)
58+
59+
if not yes:
60+
while True:
61+
sys.stdout.write("\nEnter 'yes' to continue or 'no' to exit: ")
62+
result = input().strip().lower()
63+
64+
if result in ["yes", "y"]:
65+
break
66+
67+
if result in ["no", "n"]:
68+
return False
69+
70+
if not (output_dir / ".git").is_dir():
71+
raise Exception(f"{output_dir} is not a git repository.")
72+
73+
return True

tests/EntryPoint_UnitTest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
# ----------------------------------------------------------------------
77
"""Unit tests for EntryPoint.py"""
88

9+
from pathlib import Path
10+
from unittest.mock import patch
11+
12+
from dbrownell_Common import PathEx
913
from typer.testing import CliRunner
1014

1115
from PythonProjectBootstrapper import __version__
@@ -17,3 +21,15 @@ def test_Version():
1721
result = CliRunner().invoke(app, ["package", "<output_dir>", "--version"])
1822
assert result.exit_code == 0
1923
assert result.stdout == __version__
24+
25+
26+
# ----------------------------------------------------------------------
27+
def test_Standard():
28+
with patch("PythonProjectBootstrapper.EntryPoint.cookiecutter") as mock_cookiecutter:
29+
repo_root = PathEx.EnsureDir(Path(__file__).parent.parent)
30+
31+
result = CliRunner().invoke(app, ["package", str(repo_root), "--yes"])
32+
33+
assert result.exit_code == 0
34+
assert "This project creates a Python package" in result.stdout
35+
assert len(mock_cookiecutter.call_args_list) == 1

0 commit comments

Comments
 (0)