use Codex 5.3 to review template to spec

This commit is contained in:
Jev
2026-02-14 11:47:18 +01:00
parent 1b66fecb9a
commit 46e0ef9de7
16 changed files with 277 additions and 123 deletions

View File

@@ -1,18 +1,18 @@
# Python App Template
A Cookiecutter template for building CLI-first Python applications with uv, Typer, Pydantic, and strict typing.
A Cookiecutter template for building CLI-first Python applications with `uv`, Typer, Pydantic, and strict typing.
## Features
- **CLI-first** architecture with Typer
- **Local-first config** (XDG paths) with env override
- **uv** for fast dependency management and reproducible installs
- **Ruff** for linting/formatting
- **MyPy** with strict typing
- **Pytest** with coverage support
- **Invoke** tasks for common operations
- **py.typed** marker for typed packages
- **Example command** wired from core `commands/` into CLI
- CLI-first architecture with Typer
- Local-first config (XDG paths) with env override
- `uv_build` backend and lockfile-first reproducibility
- Ruff for linting/formatting
- MyPy strict mode
- Pytest with coverage support
- Invoke tasks for common operations
- `py.typed` marker for typed packages
- Example command wired from core `commands/` into CLI
## Quick Start
@@ -31,11 +31,14 @@ A Cookiecutter template for building CLI-first Python applications with uv, Type
cookiecutter /path/to/python-app-template
```
3. The template will prompt for project details and automatically:
- Initialize git repository
- Set up uv environment
- Run initial linting and formatting
- Execute tests to verify setup
3. Post-generation hook runs `uv lock` to create `uv.lock`.
4. Bootstrap the generated project:
```bash
uv sync --frozen --group dev
uv run invoke lint
uv run invoke test
```
## Template Variables
@@ -48,7 +51,8 @@ A Cookiecutter template for building CLI-first Python applications with uv, Type
| `author_name` | Author's full name | "Your Name" |
| `author_email` | Author's email | "your.email@example.com" |
| `version` | Initial version | "0.1.0" |
| `python_version` | Minimum Python version | "3.12" |
Python is fixed to 3.12+ by template policy.
## Generated Project Structure
@@ -57,6 +61,7 @@ your-project/
├── README.md
├── pyproject.toml
├── tasks.py
├── uv.lock
├── src/
│ └── your_package/
│ ├── __init__.py
@@ -81,8 +86,9 @@ your-project/
│ └── logging.py
├── tests/
│ ├── conftest.py
│ ├── test_public.py
── test_internals.py
│ ├── test_cli.py
── test_internals.py
│ └── test_public.py
└── examples/
└── config_init.sh
```
@@ -91,13 +97,13 @@ your-project/
### Setup
```bash
uv sync --group dev
uv sync --frozen --group dev
```
### Code Quality
```bash
uv run ruff check src tests
uv run ruff format src tests
uv run ruff format --check src tests
uv run mypy src
uv run invoke lint
```
@@ -117,14 +123,15 @@ To modify this template:
3. Modify the post-generation hook in `hooks/post_gen_project.py`
4. Test changes:
```bash
cookiecutter . --output-dir /tmp
./test.sh
```
## Requirements
- Python 3.12+
- uv (automatically installed during generation)
- git (for version control)
- `uv`
- `cruft` or `cookiecutter`
- `git` (optional, for your project workflow)
## License

View File

@@ -6,6 +6,5 @@
"author_name": "Your Name",
"author_email": "your.email@example.com",
"version": "0.1.0",
"python_version": "3.12",
"year": "{% now 'utc', '%Y' %}"
}

View File

@@ -1,63 +1,31 @@
#!/usr/bin/env python3
"""Post-generation hook for the Python library template."""
"""Post-generation hook for deterministic project bootstrapping."""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
def run_command(cmd: list[str], description: str) -> None:
"""Run a command and handle errors."""
print(f"Running: {description}")
try:
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
if result.stdout:
print(result.stdout)
except subprocess.CalledProcessError as e:
print(f"Error running {description}: {e}")
if e.stderr:
print(f"Error output: {e.stderr}")
def main() -> None:
"""Create a lockfile so generated projects are reproducible by default."""
if shutil.which("uv") is None:
print("Error: 'uv' is required but was not found on PATH.", file=sys.stderr)
sys.exit(1)
print("Running: uv lock")
try:
subprocess.run(["uv", "lock"], check=True)
except subprocess.CalledProcessError as exc:
print(f"Error: uv lock failed with exit code {exc.returncode}.", file=sys.stderr)
sys.exit(exc.returncode)
def main():
"""Initialize the generated project."""
project_dir = Path.cwd()
print(f"Setting up project in: {project_dir}")
# Initialize git repository
run_command(["git", "init"], "git init")
# Sync dependencies with uv
run_command(["uv", "sync", "--group", "dev"], "uv sync --group dev")
# Run initial formatting and linting
run_command(["uv", "run", "ruff", "format", "src", "tests"], "ruff format")
run_command(
["uv", "run", "ruff", "check", "--fix", "src", "tests"],
"ruff check --fix",
)
# Run type checking
try:
run_command(["uv", "run", "mypy", "src"], "mypy type checking")
except SystemExit:
# mypy might fail on initial template, continue anyway
print("MyPy check failed - this is normal for initial template")
# Run tests to ensure everything works
try:
run_command(["uv", "run", "pytest"], "pytest")
except SystemExit:
print("Tests failed - you may need to adjust the generated code")
print("\n✅ Project setup complete!")
print("\nProject lockfile generated.")
print("Next steps:")
print("1. Review and customize the generated files")
print("2. Add your actual dependencies to pyproject.toml")
print("3. Implement your core logic in src/")
print("4. Update tests as needed")
print("5. Update README.md with proper documentation")
print("1. uv sync --frozen --group dev")
print("2. uv run invoke lint")
print("3. uv run invoke test")
if __name__ == "__main__":

12
test.sh
View File

@@ -121,7 +121,7 @@ main() {
cd "$TEMP_DIR"
cruft create "$TEMPLATE_DIR" \
--no-input \
--extra-context '{"project_name": "Test Project", "project_slug": "test-project", "package_name": "test_project", "description": "A test project", "author_name": "Test Author", "author_email": "test@example.com", "version": "0.1.0", "python_version": "3.12"}' \
--extra-context '{"project_name": "Test Project", "project_slug": "test-project", "package_name": "test_project", "description": "A test project", "author_name": "Test Author", "author_email": "test@example.com", "version": "0.1.0"}' \
|| { print_error "Failed to generate project with cruft"; exit 1; }
else
cd "$TEMP_DIR"
@@ -134,7 +134,6 @@ main() {
author_name="Test Author" \
author_email="test@example.com" \
version="0.1.0" \
python_version="3.12" \
|| { print_error "Failed to generate project with cookiecutter"; exit 1; }
fi
@@ -146,9 +145,12 @@ main() {
print_status "Generated files:"
ls -la
# Install dependencies
print_status "Installing dependencies with uv..."
uv sync --group dev || { print_error "Failed to install dependencies"; exit 1; }
# Ensure lockfile exists and install dependencies
print_status "Checking for generated uv.lock..."
[ -f "uv.lock" ] || { print_error "uv.lock was not generated"; exit 1; }
print_status "Installing dependencies with uv (frozen)..."
uv sync --frozen --group dev || { print_error "Failed to install dependencies"; exit 1; }
# Run ruff check
print_status "Running ruff check..."

View File

@@ -5,7 +5,7 @@
## Installation
```bash
uv sync
uv sync --frozen --group dev
```
## Usage
@@ -30,17 +30,31 @@ Override with:
export {{ cookiecutter.package_name | upper }}_CONFIG=/path/to/config.toml
```
Resolution order:
1. `{{ cookiecutter.package_name | upper }}_CONFIG` explicit path (errors if missing)
2. `~/.config/{{ cookiecutter.project_slug }}/config.toml` when present
3. In-code defaults
## Reproducible Workflow
```bash
uv lock
uv sync --frozen --group dev
uv run invoke lint
uv run invoke test
```
## Development
### Setup
```bash
uv sync --group dev
uv sync --frozen --group dev
```
### Code Quality
```bash
uv run ruff check src tests
uv run ruff format src tests
uv run ruff format --check src tests
uv run mypy src
uv run invoke lint
```

View File

@@ -2,5 +2,13 @@
set -euo pipefail
uv run {{ cookiecutter.project_slug }} config init
EXAMPLE_ROOT="${TMPDIR:-/tmp}/{{ cookiecutter.project_slug }}-example"
mkdir -p "$EXAMPLE_ROOT/config" "$EXAMPLE_ROOT/data"
XDG_CONFIG_HOME="$EXAMPLE_ROOT/config" \
XDG_DATA_HOME="$EXAMPLE_ROOT/data" \
uv run {{ cookiecutter.project_slug }} config init --overwrite
XDG_CONFIG_HOME="$EXAMPLE_ROOT/config" \
XDG_DATA_HOME="$EXAMPLE_ROOT/data" \
uv run {{ cookiecutter.project_slug }} config show

View File

@@ -6,7 +6,7 @@ readme = "README.md"
authors = [
{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }
]
requires-python = ">={{ cookiecutter.python_version }}"
requires-python = ">=3.12"
dependencies = [
"coloredlogs>=15.0",
"pydantic>=2.0",
@@ -30,7 +30,7 @@ dev = [
]
[tool.mypy]
python_version = "{{ cookiecutter.python_version }}"
python_version = "3.12"
strict = true
files = ["src"]

View File

@@ -4,9 +4,12 @@ from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
from {{ cookiecutter.package_name }}.config import get_config
from {{ cookiecutter.package_name }}.config.settings import Settings
try:
__version__ = version("{{ cookiecutter.project_slug }}")
except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode
__version__ = "0.0.0"
__all__ = ["__version__"]
__all__ = ["Settings", "__version__", "get_config"]

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
import tomllib
from typing import NoReturn
import typer
from pydantic import ValidationError
from {{ cookiecutter.package_name }}.config import get_config
from {{ cookiecutter.package_name }}.config.settings import Settings
@@ -8,10 +12,13 @@ from {{ cookiecutter.package_name }}.config.settings import Settings
def load_config() -> Settings:
"""Load configuration once at the CLI boundary."""
return get_config()
try:
return get_config()
except (FileNotFoundError, tomllib.TOMLDecodeError, ValidationError) as exc:
exit_with_error(str(exc))
def exit_with_error(message: str, code: int = 1) -> None:
def exit_with_error(message: str, code: int = 1) -> NoReturn:
"""Print a concise error and exit with a non-zero status."""
typer.secho(message, fg=typer.colors.RED, err=True)
raise typer.Exit(code)

View File

@@ -6,8 +6,8 @@ from typing import Annotated
import typer
from {{ cookiecutter.package_name }}.cli.common import exit_with_error
from {{ cookiecutter.package_name }}.config import get_config, get_config_source
from {{ cookiecutter.package_name }}.cli.common import exit_with_error, load_config
from {{ cookiecutter.package_name }}.config import get_config_source
from {{ cookiecutter.package_name }}.config.paths import config_file_path
from {{ cookiecutter.package_name }}.config.settings import write_default_config
@@ -17,12 +17,7 @@ app = typer.Typer(no_args_is_help=True)
@app.command()
def show() -> None:
"""Print the effective configuration and source path."""
try:
config = get_config()
except (FileNotFoundError, ValueError) as exc:
exit_with_error(str(exc))
return
config = load_config()
source = get_config_source()
payload = {
"source": str(source) if source else "<defaults>",

View File

@@ -4,6 +4,7 @@ from typing import Annotated
import typer
from {{ cookiecutter.package_name }}.cli.common import load_config
from {{ cookiecutter.package_name }}.commands import format_greeting
app = typer.Typer(no_args_is_help=True)
@@ -12,6 +13,12 @@ app = typer.Typer(no_args_is_help=True)
@app.command()
def say(
name: Annotated[str, typer.Option("--name", "-n", help="Name to greet.")] = "World",
greeting: Annotated[
str | None,
typer.Option("--greeting", "-g", help="Greeting prefix; overrides config value."),
] = None,
) -> None:
"""Print a greeting."""
print(format_greeting(name))
config = load_config()
message = format_greeting(name=name, greeting=greeting or config.app.greeting)
print(message)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
def format_greeting(name: str) -> str:
def format_greeting(name: str, greeting: str = "Hello") -> str:
"""Return a greeting message."""
return f"Hello, {name}!"
return f"{greeting}, {name}!"

View File

@@ -4,15 +4,28 @@ import os
import tomllib
from pathlib import Path
from pydantic import BaseModel
from pydantic import BaseModel, Field
from .paths import ENV_CONFIG_VAR, config_file_path
from .paths import ENV_CONFIG_VAR, config_file_path, data_dir_path
class AppConfig(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
greeting: str = "Hello"
class DatabaseConfig(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
path: str = Field(default_factory=lambda: str(data_dir_path() / "data.db"))
class Settings(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
greeting: str = "Hello"
app: AppConfig = AppConfig()
database: DatabaseConfig = DatabaseConfig()
def load_settings() -> tuple[Settings, Path | None]:
@@ -43,23 +56,43 @@ def write_default_config(path: Path) -> None:
def _to_toml(payload: dict[str, object]) -> str:
lines: list[str] = []
for section, values in payload.items():
if isinstance(values, dict):
lines.append(f"[{section}]")
for key, value in values.items():
lines.append(f"{key} = {_toml_value(value)}")
lines.append("")
for key, value in payload.items():
if isinstance(value, dict):
_write_table(lines, key, value)
else:
lines.append(f"{section} = {_toml_value(values)}")
lines.append(f"{key} = {_toml_value(value)}")
return "\n".join(lines).rstrip() + "\n"
def _write_table(lines: list[str], table_name: str, values: dict[str, object]) -> None:
lines.append(f"[{table_name}]")
scalar_items: list[tuple[str, object]] = []
nested_items: list[tuple[str, dict[str, object]]] = []
for key, value in values.items():
if isinstance(value, dict):
nested_items.append((key, value))
else:
scalar_items.append((key, value))
for key, value in scalar_items:
lines.append(f"{key} = {_toml_value(value)}")
for key, nested in nested_items:
lines.append("")
_write_table(lines, f"{table_name}.{key}", nested)
lines.append("")
def _toml_value(value: object) -> str:
if isinstance(value, str):
escaped = value.replace("\\", "\\\\").replace('"', "\\\"")
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
if isinstance(value, int | float):
return str(value)
raise TypeError(f"Unsupported TOML value: {value!r}")

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import json
from typer.testing import CliRunner
from {{ cookiecutter.package_name }}.cli.app import app
from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR
runner = CliRunner()
def test_config_show_success_defaults() -> None:
result = runner.invoke(app, ["config", "show"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["source"] == "<defaults>"
assert payload["config"]["app"]["greeting"] == "Hello"
def test_config_init_create_and_overwrite(tmp_path) -> None:
path = tmp_path / "config.toml"
create_result = runner.invoke(app, ["config", "init", "--path", str(path)])
assert create_result.exit_code == 0
assert path.exists()
conflict_result = runner.invoke(app, ["config", "init", "--path", str(path)])
assert conflict_result.exit_code == 1
assert "Config already exists" in conflict_result.output
overwrite_result = runner.invoke(
app,
["config", "init", "--path", str(path), "--overwrite"],
)
assert overwrite_result.exit_code == 0
def test_config_show_missing_env_file_exits_with_code_1(tmp_path, monkeypatch) -> None:
missing = tmp_path / "missing.toml"
monkeypatch.setenv(ENV_CONFIG_VAR, str(missing))
result = runner.invoke(app, ["config", "show"])
assert result.exit_code == 1
assert "Config file not found" in result.output
def test_config_show_invalid_config_exits_with_code_1(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('unknown = "value"\n')
monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path))
result = runner.invoke(app, ["config", "show"])
assert result.exit_code == 1
assert "Extra inputs are not permitted" in result.output
def test_hello_cli_override_precedence(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('[app]\ngreeting = "Hi"\n\n[database]\npath = "/tmp/example.db"\n')
monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path))
from_config = runner.invoke(app, ["hello", "say", "--name", "Ada"])
assert from_config.exit_code == 0
assert "Hi, Ada!" in from_config.output
from_cli = runner.invoke(
app,
["hello", "say", "--name", "Ada", "--greeting", "Yo"],
)
assert from_cli.exit_code == 0
assert "Yo, Ada!" in from_cli.output

View File

@@ -1,18 +1,22 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from {{ cookiecutter.package_name }}.commands import format_greeting
from {{ cookiecutter.package_name }}.config import get_config, get_config_source
from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR
from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR, config_file_path
def test_env_config_override(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("greeting = \"Hi\"\n")
config_path.write_text('[app]\ngreeting = "Hi"\n\n[database]\npath = "/tmp/app.db"\n')
monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path))
settings = get_config()
assert settings.greeting == "Hi"
assert settings.app.greeting == "Hi"
assert settings.database.path == "/tmp/app.db"
assert get_config_source() == config_path
@@ -20,13 +24,39 @@ def test_missing_env_config_raises(tmp_path, monkeypatch) -> None:
missing = tmp_path / "missing.toml"
monkeypatch.setenv(ENV_CONFIG_VAR, str(missing))
try:
with pytest.raises(FileNotFoundError, match="Config file not found"):
get_config()
except FileNotFoundError as exc:
assert str(missing) in str(exc)
else:
raise AssertionError("Expected FileNotFoundError")
def test_xdg_default_config_is_loaded(monkeypatch) -> None:
path = config_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('[app]\ngreeting = "Hola"\n\n[database]\npath = "/tmp/default.db"\n')
monkeypatch.delenv(ENV_CONFIG_VAR, raising=False)
settings = get_config()
assert settings.app.greeting == "Hola"
assert settings.database.path == "/tmp/default.db"
assert get_config_source() == path
def test_unknown_key_raises_validation_error(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('unknown = "value"\n')
monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path))
with pytest.raises(ValidationError):
get_config()
def test_settings_are_frozen() -> None:
settings = get_config()
with pytest.raises(ValidationError, match="frozen"):
settings.app.greeting = "Mutated"
def test_format_greeting() -> None:
assert format_greeting("Ada") == "Hello, Ada!"
assert format_greeting("Ada", greeting="Hi") == "Hi, Ada!"

View File

@@ -1,13 +1,19 @@
from __future__ import annotations
from {{ cookiecutter.package_name }} import __version__
from {{ cookiecutter.package_name }}.config import get_config
from {{ cookiecutter.package_name }} import __all__, __version__, get_config
from {{ cookiecutter.package_name }}.config.settings import Settings
def test_public_version_is_string() -> None:
assert isinstance(__version__, str)
def test_get_config_defaults() -> None:
def test_public_exports_include_stable_symbols() -> None:
assert set(__all__) >= {"__version__", "get_config", "Settings"}
def test_get_config_defaults_shape() -> None:
settings = get_config()
assert settings.greeting == "Hello"
assert isinstance(settings, Settings)
assert settings.app.greeting == "Hello"
assert settings.database.path.endswith("/data.db")