Skip to content

Commit 04aa9d5

Browse files
committed
Hooks Completely Re-written
- Removed Task from post_gen_project.py - Renamed hook files: - pre_prompt.py -> pre_prompt.uv - pre_gen_project.py -> pre_gen_project.uv - post_gen_project.py -> post_gen_project.uv - Added uv shebang magic to hooks. - Added inline script metadata to add dependencies - Call commands using `sh` module - Customized the logger from loguru - Added logging for optional and required events. - Added a README to explain further. I want the hooks to emit useful messages when things go bad but remain mostly silent otherwise.
1 parent 94d71f5 commit 04aa9d5

File tree

8 files changed

+317
-266
lines changed

8 files changed

+317
-266
lines changed

README.md

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,36 @@ you and people that interact with your project repository.
2626
- Automatically creates an initial git commit.
2727
- Optionally creates an upstream repository and pushes (GitHub only, requires [gh][gh]).
2828

29-
## Package Features
29+
## So Many Package Features
3030

31-
- Python project designed to be managed with [uv][uv].
32-
- Exposes a command line interface built with [typer][typer].
33-
- Package is callable via `python -m <package>`.
31+
### Code Features
32+
- Python `src` style project designed to be managed with [uv][uv].
33+
- Includes a command line interface built with [typer][typer].
34+
- Application settings optionally managed with [pydantic-settings][pydantic-settings].
35+
- Preconfigured with a `self` subcommand like all the cool kids.
36+
- Logging handled by [loguru][loguru] with optional logging to a file.
37+
- Package is also callable via `python -m <package>` magic.
38+
39+
40+
### Quality Of Life Features
3441
- [Poe the Poet][poe] tasks integrated into pyproject.toml:
35-
- Test with pytest.
42+
- Test with pytest, tests started for you!
3643
- Generate HTML code coverage reports.
3744
- Run code quality checks using `mypy`, `ruff`, and `ty`.
3845
- Publish to PyPI via GitHub Actions with `poe publish`.
3946
- Development tool options integrated into pyproject.toml.
47+
48+
### GitHub Integrations
4049
- Generic GitHub Issue and Pull Request templates.
41-
- Configured to use [dependabot][dependabot] dependency checker.
42-
- Checks project dependencies.
43-
- Checks project GitHub action dependencies.
50+
- The [dependabot][dependabot] checks:
51+
- Project dependencies.
52+
- Project GitHub action dependencies (these never age out right?).
4453
- Operating System and Python version test matrices.
54+
55+
### Miscellaneous
4556
- Configured to use [direnv][direnv] to automatically activate & deactivate venvs.
4657
- Optionally configured badges in README.md for cool points.
47-
- Optionally managed CLI settings using [pydantic-settings][pydantic-settings].
48-
- Optionally logs to a file.
58+
4959

5060
## Prerequisites
5161

@@ -66,8 +76,6 @@ you and people that interact with your project repository.
6676

6777
## Creating Your Project
6878

69-
70-
7179
If you haven't authenticated to GitHub with `gh` yet and you plan to
7280
ask `cookiecutter` to create the upstream repository, you should do
7381
that now:
@@ -87,26 +95,14 @@ After answering the `cookiecutter` prompts, you should see the
8795
following:
8896

8997
```console
90-
Task [Install Dev Python............] 🟢
91-
Task [Create .venv..................] 🟢
92-
Task [Enable Direnv.................] 🟢
93-
Task [Sync Project Deps.............] 🟢
94-
Task [Ruff Check Source.............] 🟢
95-
Task [Initialize Git................] 🟢
96-
Task [Add Files.....................] 🟢
97-
Task [Initial Commit................] 🟢
98-
Task [Create Upstream Repo .........] 🟢
98+
✨ Your new project is ready to use! ✨
9999
$
100100
```
101101

102-
If you didn't ask to have the upstream GitHub repository created, the
103-
last task will not run. If you don't have `gh` installed or you
104-
aren't authenticated, the last task will fail but the template
105-
generation will complete successfully.
106-
107102
### Example Package Tree
108103

109104
```console
105+
$ cd <YOUR_PACKAGE_NAME_HERE>
110106
$ poe tree
111107
.
112108
├── .cookiecutter.json

hooks/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# UV Hooks
2+
3+
Cookiecutter looks for three files to run during the templating process:
4+
1. `pre_prompt`
5+
1. `pre_gen_project`
6+
1. `post_gen_project`
7+
8+
If the file in question has a `.py` suffix, it attempts to run using
9+
Python otherwise it execs the file with `subprocess`.
10+
11+
It's nice to write the hooks in Python, however the execution
12+
environment for the hooks is kinds of ambiguous and limits the
13+
hook implementations to only using the Python standard library.
14+
15+
So I did a thing.
16+
17+
The files in this directory have a suffix `.uv` indicating that they
18+
are **not** Python programs, so cookiecutter execs them using
19+
`subprocess`. This lets me run the code in the file using uv to
20+
satisfy dependencies without relying on the execution environment
21+
to supply them.
22+
23+
### `pre_prompt.uv`
24+
25+
```python
26+
#!/usr/bin/env -S uv run --quiet --script
27+
# /// script
28+
# requires-python = ">=3.13"
29+
# dependencies = [ "sh", "packaging>=25", "loguru" ]
30+
# ///
31+
...
32+
```
33+
34+
## Justification for this Hackery?
35+
36+
I wanted to use `loguru` for logging and `sh` for command execution.
37+
38+

hooks/post_gen_project.py

Lines changed: 0 additions & 121 deletions
This file was deleted.

hooks/post_gen_project.uv

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env -S uv run --quiet --script
2+
# /// script
3+
# requires-python = ">=3.13"
4+
# dependencies = [ "sh", "loguru" ]
5+
# ///
6+
"""Post generation tasks for cookiecutter templates."""
7+
8+
import sys
9+
from pathlib import Path
10+
from functools import partial
11+
12+
import sh
13+
from loguru import logger
14+
15+
16+
def _logger_setup() -> None:
17+
"""Setup logger for post-generation tasks."""
18+
logger.remove()
19+
logger.add(
20+
sys.stdout,
21+
format="✨ {function:<15} | <level>{level:<8}</level> | {message}",
22+
)
23+
logger.level("REQUIRED", no=50, color="<red>", icon="💔")
24+
logger.level("OPTIONAL", no=30, color="<blue>", icon="⚠️")
25+
logger.required = partial(logger.log, "REQUIRED")
26+
logger.optional = partial(logger.log, "OPTIONAL")
27+
28+
29+
def _remove_empty_comments(path: Path | str) -> None:
30+
"""Remove empty comment lines from the given path.
31+
32+
Cookiecutter template files have Jinja directives in them
33+
that are commented out. When the directives are processed,
34+
it leaves lines with empty comments (e.g. `# `) in the files.
35+
"""
36+
path = Path(path)
37+
lines = path.read_text().splitlines()
38+
text = [line for line in lines if line.strip() != "#"]
39+
path.write_text("\n".join(text) + "\n")
40+
41+
42+
def post_gen_project() -> int:
43+
"""Things to do after cookiecutter has rendered the template.
44+
45+
- Remove empty comment lines left over from Jinja processing.
46+
- Install requested development python version.
47+
- Create a python virtual environment.
48+
- Sync project requirements to venv.
49+
- Ruff check src and test.
50+
- Initialize a git repo.
51+
- Add all files to git.
52+
- Commit initial state of the repo.
53+
- Optionally create a GitHub repository and push the initial commit.
54+
"""
55+
for subdir in ["src", "tests"]:
56+
for path in Path(subdir).rglob("*.py"):
57+
_remove_empty_comments(path)
58+
59+
cmds = [
60+
(True, sh.uv.python.install.bake("{{ cookiecutter.python_version_dev }}")),
61+
(False, sh.direnv.bake("allow")),
62+
(True, sh.uv.sync.bake("--quiet", "--no-progress")),
63+
(True, sh.uvx.ruff.check.bake("--fix", "src", "tests")),
64+
(True, sh.git.init.bake("--quiet", "--initial-branch", "main")),
65+
(True, sh.git.add.bake(".")),
66+
(True, sh.git.commit.bake("-m", "initial commit")),
67+
# {% if cookiecutter.create_github_repo %}
68+
(
69+
False,
70+
sh.gh.repo.create.bake(
71+
"{{ cookiecutter.package_name }}",
72+
"--public",
73+
"--push",
74+
"--source=.",
75+
"--remote=upstream",
76+
),
77+
),
78+
# {% endif %}
79+
]
80+
81+
for required, cmd in cmds:
82+
try:
83+
cmd()
84+
except sh.CommandNotFound:
85+
if required:
86+
logger.required("Required command not available: %s" % cmd)
87+
raise
88+
logger.optional("Command not found: %s" % cmd)
89+
except sh.ErrorReturnCode_1 as error:
90+
if required:
91+
logger.required(f"Command failed: {cmd}")
92+
logger.required(error.stderr.decode("utf-8").strip())
93+
raise
94+
logger.optional(f"Command failed: {cmd}")
95+
logger.optional(error.stderr.decode("utf-8").strip())
96+
97+
print("✨ Your new project is ready to use! ✨")
98+
return 0
99+
100+
101+
if __name__ == "__main__":
102+
_logger_setup()
103+
sys.exit(post_gen_project())

hooks/pre_gen_project.py

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)