Skip to content

Commit 5e2c4ce

Browse files
feat: add use_cli (Typer), use_api (FastAPI), use_db (SQLAlchemy+Alembic) optional configs
- New prompts: use_cli, use_api, use_db (all default false) - Conditional deps in pyproject.toml: typer, fastapi/uvicorn, sqlalchemy/alembic - Conditional [project.scripts] entry point: cli:app when use_cli, else main:main - New justfile recipes: run (cli), serve (api), migrate/make-migration (db) - New template files: cli.py, api/, db/, alembic/ (with env.py, script.py.mako, versions/) - copilot-instructions.md updated with CLI/API/DB sections - validate-template.yml: added validate-cli, validate-api, validate-db jobs - _tasks cleanup: removes cli.py/api//db//alembic/ when respective flag is false Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8678aa0 commit 5e2c4ce

18 files changed

Lines changed: 406 additions & 1 deletion

.github/workflows/validate-template.yml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,120 @@ jobs:
9393
- name: Test
9494
working-directory: /tmp/rendered-minimal
9595
run: uv run pytest -v
96+
97+
validate-cli:
98+
name: Validate (use_cli)
99+
runs-on: ubuntu-latest
100+
steps:
101+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
102+
- uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7
103+
with:
104+
version: "0.11.3"
105+
python-version: "3.12"
106+
- name: Configure git
107+
run: |
108+
git config --global user.name "CI"
109+
git config --global user.email "ci@test.com"
110+
- name: Render template (cli)
111+
run: |
112+
TEMPLATE_DIR=$(mktemp -d)
113+
git archive HEAD | tar -C "$TEMPLATE_DIR" -xf -
114+
uvx copier copy "$TEMPLATE_DIR" /tmp/rendered-cli \
115+
--trust \
116+
--defaults \
117+
--overwrite \
118+
--data project_name=cli-project \
119+
--data description="A CLI project" \
120+
--data author_name="Test Author" \
121+
--data author_email="test@example.com" \
122+
--data github_user=test-user \
123+
--data use_cli=true
124+
- name: Lint
125+
working-directory: /tmp/rendered-cli
126+
run: |
127+
uv run ruff check src/ tests/
128+
uv run ruff format --check src/ tests/
129+
- name: Type check
130+
working-directory: /tmp/rendered-cli
131+
run: uv run mypy src/
132+
- name: Test
133+
working-directory: /tmp/rendered-cli
134+
run: uv run pytest -v
135+
136+
validate-api:
137+
name: Validate (use_api)
138+
runs-on: ubuntu-latest
139+
steps:
140+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
141+
- uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7
142+
with:
143+
version: "0.11.3"
144+
python-version: "3.12"
145+
- name: Configure git
146+
run: |
147+
git config --global user.name "CI"
148+
git config --global user.email "ci@test.com"
149+
- name: Render template (api)
150+
run: |
151+
TEMPLATE_DIR=$(mktemp -d)
152+
git archive HEAD | tar -C "$TEMPLATE_DIR" -xf -
153+
uvx copier copy "$TEMPLATE_DIR" /tmp/rendered-api \
154+
--trust \
155+
--defaults \
156+
--overwrite \
157+
--data project_name=api-project \
158+
--data description="An API project" \
159+
--data author_name="Test Author" \
160+
--data author_email="test@example.com" \
161+
--data github_user=test-user \
162+
--data use_api=true
163+
- name: Lint
164+
working-directory: /tmp/rendered-api
165+
run: |
166+
uv run ruff check src/ tests/
167+
uv run ruff format --check src/ tests/
168+
- name: Type check
169+
working-directory: /tmp/rendered-api
170+
run: uv run mypy src/
171+
- name: Test
172+
working-directory: /tmp/rendered-api
173+
run: uv run pytest -v --cov=src --cov-report=xml
174+
175+
validate-db:
176+
name: Validate (use_db)
177+
runs-on: ubuntu-latest
178+
steps:
179+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
180+
- uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7
181+
with:
182+
version: "0.11.3"
183+
python-version: "3.12"
184+
- name: Configure git
185+
run: |
186+
git config --global user.name "CI"
187+
git config --global user.email "ci@test.com"
188+
- name: Render template (db)
189+
run: |
190+
TEMPLATE_DIR=$(mktemp -d)
191+
git archive HEAD | tar -C "$TEMPLATE_DIR" -xf -
192+
uvx copier copy "$TEMPLATE_DIR" /tmp/rendered-db \
193+
--trust \
194+
--defaults \
195+
--overwrite \
196+
--data project_name=db-project \
197+
--data description="A DB project" \
198+
--data author_name="Test Author" \
199+
--data author_email="test@example.com" \
200+
--data github_user=test-user \
201+
--data use_db=true
202+
- name: Lint
203+
working-directory: /tmp/rendered-db
204+
run: |
205+
uv run ruff check src/ tests/
206+
uv run ruff format --check src/ tests/
207+
- name: Type check
208+
working-directory: /tmp/rendered-db
209+
run: uv run mypy src/
210+
- name: Test
211+
working-directory: /tmp/rendered-db
212+
run: uv run pytest -v --cov=src --cov-report=xml

copier.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ use_iot:
9999
default: false
100100
help: Include IoT/embedded setup (serial, GPIO, MQTT drivers, device config)?
101101

102+
use_cli:
103+
type: bool
104+
default: false
105+
help: Include Typer CLI scaffold (commands, --help, rich output)?
106+
107+
use_api:
108+
type: bool
109+
default: false
110+
help: Include FastAPI REST API scaffold (routes, health endpoint, uvicorn serve)?
111+
112+
use_db:
113+
type: bool
114+
default: false
115+
help: Include SQLAlchemy 2 + Alembic (database session, migrations)?
116+
102117
# --- Post-setup options ---
103118

104119
init_dvc:
@@ -133,6 +148,12 @@ _tasks:
133148
when: "{{ not use_iot }}"
134149
- command: rm -f tests/test_drivers.py
135150
when: "{{ not use_iot }}"
151+
- command: rm -f "src/{{ package_name }}/cli.py"
152+
when: "{{ not use_cli }}"
153+
- command: rm -rf "src/{{ package_name }}/api/"
154+
when: "{{ not use_api }}"
155+
- command: rm -rf "src/{{ package_name }}/db/" alembic/ alembic.ini
156+
when: "{{ not use_db }}"
136157
- command: rm -rf docker/ .dockerignore
137158
when: "{{ use_docker == 'none' }}"
138159
- command: rm -f docker/Dockerfile.gpu

template/.github/copilot-instructions.md.jinja

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ just coverage # pytest with coverage report{% if testing == 'full' %} (80% th
2424
just docs # mkdocs serve (local preview)
2525
{%- endif %}
2626
just release patch|minor|major # bump version, tag, push
27+
{%- if use_cli %}
28+
just run [args] # run the CLI app
29+
{%- endif %}
30+
{%- if use_api %}
31+
just serve # start API dev server (uvicorn --reload)
32+
{%- endif %}
33+
{%- if use_db %}
34+
just migrate # apply pending DB migrations
35+
just make-migration "msg" # generate migration from model diff
36+
{%- endif %}
2737
```
2838

2939
## Code Style
@@ -123,6 +133,49 @@ just deploy <host> # rsync src/ + restart systemd service (or docker pull + co
123133
- **GPIO**: use `gpiozero` — `Device.pin_factory = MockFactory()` in test fixtures for CI-safe testing
124134
{% endif %}
125135

136+
{% if use_cli %}
137+
## CLI Development
138+
139+
- **Commands live in** `src/{{ package_name }}/cli.py` — add `@app.command()` functions there
140+
- **Entry point**: `{{ project_name }} <command>` (configured in `pyproject.toml` `[project.scripts]`)
141+
- **Testing**: use `typer.testing.CliRunner` to invoke commands in tests; never call `app()` directly
142+
- **Output**: prefer `typer.echo()` for plain output, `rich.print()` for styled output
143+
- **Options vs Arguments**: use `typer.Option` for optional flags, `typer.Argument` for positional args
144+
145+
```bash
146+
just run --help # see available commands
147+
just run <command> # run a specific command
148+
```
149+
{% endif %}
150+
{% if use_api %}
151+
## API Development
152+
153+
- **Routes live in** `src/{{ package_name }}/api/routes/` — add new routers there and include them in `api/main.py`
154+
- **No business logic in routes** — routes call service functions; keep logic in `src/{{ package_name }}/`
155+
- **Testing**: use `fastapi.testclient.TestClient` with the `app` instance — no running server needed
156+
- **Health check**: `GET /health/` is always available; add domain routes under their own prefix
157+
158+
```bash
159+
just serve # start dev server at http://localhost:8000
160+
# API docs at http://localhost:8000/docs
161+
```
162+
{% endif %}
163+
{% if use_db %}
164+
## Database Development
165+
166+
- **Models live in** `src/{{ package_name }}/db/models/` — subclass `Base` from `db/base.py`
167+
- **Session**: use `get_db()` from `db/session.py` as a dependency (or context manager)
168+
- **DATABASE_URL**: set in environment (defaults to `sqlite:///./{{ package_name }}.db` for local dev)
169+
- **Migrations**: always create a migration after changing models; never edit existing migrations
170+
171+
```bash
172+
just make-migration "describe your change" # auto-generate migration from model diff
173+
just migrate # apply all pending migrations
174+
```
175+
176+
- Alembic env is in `alembic/env.py` — models must be imported there to register with metadata
177+
{% endif %}
178+
126179
## What NOT to Do
127180

128181
- Do not bypass `uv` with `pip install` — always use `uv add` or `uv run`

template/alembic.ini.jinja

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Alembic configuration for {{ project_name }}
2+
[alembic]
3+
script_location = alembic
4+
file_template = %(year)d%(month).2d%(day).2d_%(rev)s_%(slug)s
5+
prepend_sys_path = .
6+
version_path_separator = os
7+
8+
[loggers]
9+
keys = root,sqlalchemy,alembic
10+
11+
[handlers]
12+
keys = console
13+
14+
[formatters]
15+
keys = generic
16+
17+
[logger_root]
18+
level = WARN
19+
handlers = console
20+
qualname =
21+
22+
[logger_sqlalchemy]
23+
level = WARN
24+
handlers =
25+
qualname = sqlalchemy.engine
26+
27+
[logger_alembic]
28+
level = INFO
29+
handlers =
30+
qualname = alembic
31+
32+
[handler_console]
33+
class = StreamHandler
34+
args = (sys.stderr,)
35+
level = NOTSET
36+
formatter = generic
37+
38+
[formatter_generic]
39+
format = %(levelname)-5.5s [%(name)s] %(message)s
40+
datefmt = %H:%M:%S

template/alembic/env.py.jinja

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Alembic environment configuration."""
2+
3+
import os
4+
from logging.config import fileConfig
5+
6+
from alembic import context
7+
from sqlalchemy import engine_from_config, pool
8+
9+
from {{ package_name }}.db.base import Base
10+
import {{ package_name }}.db.models # noqa: F401 — import models to register with Base.metadata
11+
12+
config = context.config
13+
14+
if config.config_file_name is not None:
15+
fileConfig(config.config_file_name)
16+
17+
target_metadata = Base.metadata
18+
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./{{ package_name }}.db")
19+
20+
21+
def run_migrations_offline() -> None:
22+
"""Run migrations without a live database connection."""
23+
context.configure(
24+
url=DATABASE_URL,
25+
target_metadata=target_metadata,
26+
literal_binds=True,
27+
dialect_opts={"paramstyle": "named"},
28+
)
29+
with context.begin_transaction():
30+
context.run_migrations()
31+
32+
33+
def run_migrations_online() -> None:
34+
"""Run migrations with a live database connection."""
35+
connectable = engine_from_config(
36+
{"sqlalchemy.url": DATABASE_URL},
37+
prefix="sqlalchemy.",
38+
poolclass=pool.NullPool,
39+
)
40+
with connectable.connect() as connection:
41+
context.configure(connection=connection, target_metadata=target_metadata)
42+
with context.begin_transaction():
43+
context.run_migrations()
44+
45+
46+
if context.is_offline_mode():
47+
run_migrations_offline()
48+
else:
49+
run_migrations_online()

template/alembic/script.py.mako

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
${imports if imports else ""}
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = ${repr(up_revision)}
16+
down_revision: Union[str, None] = ${repr(down_revision)}
17+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19+
20+
21+
def upgrade() -> None:
22+
${upgrades if upgrades else "pass"}
23+
24+
25+
def downgrade() -> None:
26+
${downgrades if downgrades else "pass"}

template/alembic/versions/.gitkeep

Whitespace-only changes.

template/justfile.jinja

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,26 @@ deploy host tag="latest":
170170
{% endif %}
171171
{% endif %}
172172

173+
{% if use_cli %}
174+
# Run the CLI application
175+
run *args:
176+
uv run {{ project_name }} {% raw %}{{ args }}{% endraw %}
177+
{% endif %}
178+
{% if use_api %}
179+
# Start the API development server
180+
serve:
181+
uv run uvicorn {{ package_name }}.api.main:app --reload --host 0.0.0.0 --port 8000
182+
{% endif %}
183+
{% if use_db %}
184+
# Run all pending database migrations
185+
migrate:
186+
uv run alembic upgrade head
187+
188+
# Create a new migration (usage: just make-migration "add user table")
189+
make-migration message:
190+
uv run alembic revision --autogenerate -m {% raw %}"{{ message }}"{% endraw %}
191+
{% endif %}
192+
173193
# Clean build artifacts
174194
clean:
175195
rm -rf dist/ build/{% if use_docs %} site/{% endif %} .pytest_cache/{% if use_typecheck %} .mypy_cache/{% endif %} .ruff_cache/ htmlcov/

0 commit comments

Comments
 (0)