From 8627a58d32f00845f290911da8e5be55fe20c827 Mon Sep 17 00:00:00 2001 From: Robert Hafner Date: Sat, 16 May 2026 08:49:06 -0500 Subject: [PATCH 1/2] Replace print() with logging, add .env.example, add health check endpoint --- .gitignore | 1 + {{cookiecutter.__package_slug}}/.env.example | 34 +++++++++++++++++++ .../tests/test_celery.py | 7 ++-- .../tests/test_qq.py | 17 ++++------ .../tests/test_www.py | 7 ++++ .../{{cookiecutter.__package_slug}}/celery.py | 7 ++-- .../{{cookiecutter.__package_slug}}/qq.py | 5 ++- .../{{cookiecutter.__package_slug}}/www.py | 5 +++ 8 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 {{cookiecutter.__package_slug}}/.env.example diff --git a/.gitignore b/.gitignore index ecb5e96..135ddfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ workspaces/ +.opencode diff --git a/{{cookiecutter.__package_slug}}/.env.example b/{{cookiecutter.__package_slug}}/.env.example new file mode 100644 index 0000000..8a3c968 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.env.example @@ -0,0 +1,34 @@ +# Development environment +# IS_DEV=true +# DEBUG=false + +{%- if cookiecutter.include_fastapi == "y" %} +# FastAPI hot reload +# RELOAD=true + +{%- endif %} +{%- if cookiecutter.include_sqlalchemy == "y" %} +# Database connection +# SQLite (default for local development): +# DATABASE_URL=sqlite:///./test.db +# PostgreSQL (for Docker Compose): +# DATABASE_URL=postgresql://main:main12345@localhost/main + +# Create test data on startup +# CREATE_TEST_DATA=true + +{%- endif %} +{%- if cookiecutter.include_celery == "y" %} +# Celery broker +# Redis (for Docker Compose): +# CELERY_BROKER=redis://localhost:6379/0 + +{%- endif %} +{%- if cookiecutter.include_aiocache == "y" %} +# Cache settings +# CACHE_ENABLED=true +# Redis cache (for Docker Compose): +# CACHE_REDIS_HOST=localhost +# CACHE_REDIS_PORT=6379 + +{%- endif %} diff --git a/{{cookiecutter.__package_slug}}/tests/test_celery.py b/{{cookiecutter.__package_slug}}/tests/test_celery.py index 86d3a51..f1f1df1 100644 --- a/{{cookiecutter.__package_slug}}/tests/test_celery.py +++ b/{{cookiecutter.__package_slug}}/tests/test_celery.py @@ -37,14 +37,13 @@ def test_hello_world_task_name(): assert hello_world.name == "{{cookiecutter.__package_slug}}.celery.hello_world" -def test_hello_world_execution(capsys): +def test_hello_world_execution(caplog): """Test that hello_world task executes without error.""" # Run the task directly (not async) hello_world() - # Check that it printed the expected message - captured = capsys.readouterr() - assert "Hello World!" in captured.out + # Check that it logged the expected message + assert "Hello World!" in caplog.text def test_periodic_task_setup_exists(): diff --git a/{{cookiecutter.__package_slug}}/tests/test_qq.py b/{{cookiecutter.__package_slug}}/tests/test_qq.py index 87796ba..692be71 100644 --- a/{{cookiecutter.__package_slug}}/tests/test_qq.py +++ b/{{cookiecutter.__package_slug}}/tests/test_qq.py @@ -79,28 +79,25 @@ def test_reader_is_async(): @pytest.mark.asyncio -async def test_reader_with_integer(capsys): +async def test_reader_with_integer(caplog): """Test reader with an integer identifier.""" await reader(42) - captured = capsys.readouterr() - assert "42" in captured.out + assert "42" in caplog.text @pytest.mark.asyncio -async def test_reader_with_string(capsys): +async def test_reader_with_string(caplog): """Test reader with a string identifier.""" await reader("test_value") - captured = capsys.readouterr() - assert "test_value" in captured.out + assert "test_value" in caplog.text @pytest.mark.asyncio -async def test_reader_prints_output(capsys): - """Test that reader prints its identifier.""" +async def test_reader_prints_output(caplog): + """Test that reader logs its identifier.""" test_id = "test_123" await reader(test_id) - captured = capsys.readouterr() - assert test_id in captured.out + assert test_id in caplog.text def test_runner_configured_correctly(): diff --git a/{{cookiecutter.__package_slug}}/tests/test_www.py b/{{cookiecutter.__package_slug}}/tests/test_www.py index b6bea10..b50c5a6 100644 --- a/{{cookiecutter.__package_slug}}/tests/test_www.py +++ b/{{cookiecutter.__package_slug}}/tests/test_www.py @@ -75,3 +75,10 @@ def test_basic_health(fastapi_client): """Test basic application health by accessing root.""" response = fastapi_client.get("/") assert response.status_code in [200, 307], "App should respond to requests" + + +def test_health_check(fastapi_client): + """Test that the health endpoint returns ok.""" + response = fastapi_client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/celery.py b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/celery.py index b601562..86fb115 100644 --- a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/celery.py +++ b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/celery.py @@ -1,3 +1,4 @@ +from logging import getLogger from typing import Any from celery import Celery # type: ignore[import-untyped] @@ -6,6 +7,8 @@ from {{cookiecutter.__package_slug}}.services.cache import configure_caches {%- endif %} +logger = getLogger(__name__) + celery = Celery("{{ cookiecutter.__package_slug }}") @@ -19,10 +22,10 @@ def setup_caches(sender: Any, **kwargs: Any) -> None: @celery.task def hello_world() -> None: - print("Hello World!") + logger.info("Hello World!") @celery.on_after_finalize.connect def setup_periodic_tasks(sender: Any, **kwargs: Any) -> None: - print("Enabling Test Task") + logger.info("Enabling Test Task") sender.add_periodic_task(15.0, hello_world.s(), name="Test Task") diff --git a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/qq.py b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/qq.py index 8b5c569..eaaa654 100644 --- a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/qq.py +++ b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/qq.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import AsyncGenerator +from logging import getLogger from quasiqueue import QuasiQueue @@ -8,6 +9,8 @@ from {{cookiecutter.__package_slug}}.services.cache import configure_caches {%- endif %} +logger = getLogger(__name__) + async def writer(desired: int) -> AsyncGenerator[int, None]: """Feeds data to the Queue when it is low. @@ -23,7 +26,7 @@ async def reader(identifier: int|str) -> None: Args: identifier (int | str): Comes from the output of the Writer function """ - print(f"{identifier}") + logger.info(f"{identifier}") runner = QuasiQueue( diff --git a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/www.py b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/www.py index e0695a8..ae38d7a 100644 --- a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/www.py +++ b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/www.py @@ -34,3 +34,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: @app.get("/", include_in_schema=False) async def root() -> RedirectResponse: return RedirectResponse("/docs") + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} From e96d510132bc6ddca9256363300859898765984f Mon Sep 17 00:00:00 2001 From: Robert Hafner Date: Sat, 16 May 2026 12:51:06 -0500 Subject: [PATCH 2/2] Add agent skills, sandboxed templates, fix AGENTS.md violations and skill validations Create 10 agent skills (makefile, pydantic-settings, python-testing, typer-cli, fastapi-routes, sqlalchemy-models, docker-compose, celery-tasks, aiocache, jinja-templates) to replace verbose AGENTS.md sections. Add sandbox_env to jinja.py for untrusted template rendering with 9 sandbox tests. Replace print() with logger.info() in celery.py and qq.py with caplog fixes. Add .env.example and /health endpoint. Validate all skills against official documentation and fix API inaccuracies. --- .gitignore | 1 + hooks/post_gen_project.py | 19 +- .../.agents/skills/aiocache/SKILL.md | 224 ++++++++++++++++++ .../.agents/skills/celery-tasks/SKILL.md | 197 +++++++++++++++ .../.agents/skills/docker-compose/SKILL.md | 134 +++++++++++ .../.agents/skills/fastapi-routes/SKILL.md | 199 ++++++++++++++++ .../.agents/skills/jinja-templates/SKILL.md | 165 +++++++++++++ .../references/sandboxed-templates.md | 91 +++++++ .../.agents/skills/makefile/SKILL.md | 131 ++++++++++ .../.agents/skills/pydantic-settings/SKILL.md | 121 ++++++++++ .../.agents/skills/python-testing/SKILL.md | 121 ++++++++++ .../.agents/skills/sqlalchemy-models/SKILL.md | 185 +++++++++++++++ .../.agents/skills/typer-cli/SKILL.md | 156 ++++++++++++ {{cookiecutter.__package_slug}}/AGENTS.md | 192 ++------------- .../tests/services/test_jinja.py | 57 ++++- .../tests/test_celery.py | 2 + .../tests/test_qq.py | 4 + .../services/jinja.py | 8 + 18 files changed, 1834 insertions(+), 173 deletions(-) create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/aiocache/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/celery-tasks/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/docker-compose/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/fastapi-routes/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/references/sandboxed-templates.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/makefile/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/pydantic-settings/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/python-testing/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/sqlalchemy-models/SKILL.md create mode 100644 {{cookiecutter.__package_slug}}/.agents/skills/typer-cli/SKILL.md diff --git a/.gitignore b/.gitignore index 135ddfd..8bb09b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ workspaces/ .opencode +tmp diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index bcab1f2..651f551 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -20,7 +20,7 @@ remove_paths=set([]) docker_containers=set([]) -CHECK_FOR_EMPTY_DIRS = [f'{PACKAGE_SLUG}/services', 'docs/dev', 'docs'] +CHECK_FOR_EMPTY_DIRS = [f'{PACKAGE_SLUG}/services', 'docs/dev', 'docs', '.agents/skills', '.agents'] if INCLUDE_FASTAPI: docker_containers.add('www') @@ -100,6 +100,23 @@ if not INCLUDE_AGENT_INSTRUCTIONS: remove_paths.add(f'AGENTS.md') + remove_paths.add(f'.agents') +else: + # Remove skills that don't apply to the generated project + if not INCLUDE_CLI: + remove_paths.add(f'.agents/skills/typer-cli') + if not INCLUDE_FASTAPI: + remove_paths.add(f'.agents/skills/fastapi-routes') + if not INCLUDE_SQLALCHEMY: + remove_paths.add(f'.agents/skills/sqlalchemy-models') + if not INCLUDE_DOCKER: + remove_paths.add(f'.agents/skills/docker-compose') + if not INCLUDE_CELERY: + remove_paths.add(f'.agents/skills/celery-tasks') + if not INCLUDE_AIOCACHE: + remove_paths.add(f'.agents/skills/aiocache') + if not INCLUDE_JINJA2: + remove_paths.add(f'.agents/skills/jinja-templates') for path in remove_paths: path = path.strip() diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/aiocache/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/aiocache/SKILL.md new file mode 100644 index 0000000..b4485ff --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/aiocache/SKILL.md @@ -0,0 +1,224 @@ +--- +name: aiocache +description: "Configure or use the aiocache caching layer. Use when: adding cache reads/writes, configuring cache backends, working with TTLs, enabling/disabling caching, or understanding the NoOpCache fallback pattern." +--- + +# aiocache Caching + +> **context7**: If the `context7` tools are available, resolve and load the full `aiocache` documentation before making changes: +> ``` +> context7_resolve-library-id: "aiocache" +> context7_query-docs: /aio-libs/aiocache +> ``` + +The caching layer is defined in `{{cookiecutter.__package_slug}}/services/cache.py`. It provides helper functions and a `NoOpCache` fallback for when caching is disabled. + +--- + +## Cache Aliases + +Three cache backends are configured by `configure_caches()`: + +| Alias | Backend | Default TTL | +| ------------- | ------------------------------- | ----------------- | +| `memory` | Always in-memory | `cache_default_ttl` (300s) | +| `persistent` | Redis if configured, else memory | `cache_persistent_ttl` (3600s) | +| `default` | Same as `memory` | `cache_persistent_ttl` (3600s)* | + +\* *Note: `set_cached()` applies `cache_default_ttl` only when `alias == "memory"`, so `default` falls through to `cache_persistent_ttl`.* + +--- + +## Using the Helpers + +```python +from {{cookiecutter.__package_slug}}.services.cache import get_cached, set_cached, delete_cached, clear_cache + +# Get (returns None on miss) +value = await get_cached("user:123") + +# Set with default TTL +await set_cached("user:123", user_data) + +# Set with custom TTL +await set_cached("user:123", user_data, ttl=600, alias="persistent") + +# Delete +await delete_cached("user:123", alias="persistent") + +# Clear entire cache +await clear_cache(alias="persistent") +``` + +--- + +## Direct Cache Access + +For operations not covered by the helpers: + +```python +from {{cookiecutter.__package_slug}}.services.cache import get_cache + +cache = get_cache("memory") +exists = await cache.exists("key") +``` + +--- + +## Cache Decorator + +Use `@cached` to automatically cache function return values. The decorator takes a cache **instance** (not an alias string) as its first argument. Retrieve the instance with `get_cache()`: + +```python +from aiocache import cached +from {{cookiecutter.__package_slug}}.services.cache import get_cache + +@cached(get_cache("memory"), ttl=300, key_builder=lambda f, *args, **kwargs: f"user:{args[0]}") +async def get_user(user_id: int) -> dict[str, str] | None: + # Expensive DB call — cached for 300s + return await fetch_user_from_db(user_id) +``` + +**Decorator parameters:** +- **First positional arg**: cache instance from `get_cache(alias)` — required +- **`ttl`**: time-to-live in seconds (default: 60) +- **`key`**: static cache key (overrides `key_builder` if set) +- **`key_builder`**: callable that generates the cache key. Signature: `lambda f, *args, **kwargs: str` +- **`noself=True`**: use on class methods to share the cache across instances + +**Default key format:** `namespace__module__func_name(args)[kwargs]` — e.g., `api__main__fetch_user(1,)[]` + +**Key builder patterns:** + +```python +# Default key builder (uses function name + args) +@cached(get_cache("memory"), ttl=300) + +# Static key (no key_builder needed) +@cached(get_cache("memory"), ttl=300, key="singleton:config") + +# Custom key with positional arg +@cached(get_cache("memory"), ttl=300, key_builder=lambda f, *args, **kwargs: f"user:{args[0]}") + +# Custom key with keyword arg +@cached(get_cache("memory"), ttl=300, key_builder=lambda f, *args, **kwargs: f"config:{kwargs.get('name')}") + +# Hashed key for complex arguments +import hashlib +def hash_key_builder(f, *args, **kwargs): + key = f"{f.__name__}:{args}:{sorted(kwargs.items())}" + return hashlib.md5(key.encode()).hexdigest() + +@cached(get_cache("memory"), ttl=300, key_builder=hash_key_builder) +async def search_products(query: str, category: str | None = None, page: int = 1) -> list[str]: + ... +``` + +**Bypassing the cache on individual calls:** + +```python +# Skip cache read (always execute function) +result = await get_user(1, cache_read=False) + +# Skip cache write (don't store result) +result = await get_user(2, cache_write=False) + +# Non-blocking write (don't wait for cache to accept) +result = await get_user(3, aiocache_wait_for_write=False) +``` + +--- + +## Multi-Cache Decorator + +Use `@multi_cached` for functions that return dicts and need to cache individual keys. On subsequent calls, only missing keys are fetched: + +```python +from aiocache import multi_cached +from {{cookiecutter.__package_slug}}.services.cache import get_cache + +@multi_cached(get_cache("memory"), keys_from_attr="user_ids", ttl=300) +async def get_users(user_ids: list[int]) -> dict[int, dict[str, str]]: + # Only uncached user_ids will be passed here on subsequent calls + return {uid: {"id": uid, "name": f"User {uid}"} for uid in user_ids} + +# With custom key builder +@multi_cached( + get_cache("persistent"), + keys_from_attr="product_ids", + key_builder=lambda key, f, *args, **kwargs: f"product:{key}", + ttl=3600 +) +async def get_products(product_ids: list[str]) -> dict[str, dict[str, str]]: + ... + +# Skip caching for certain values +@multi_cached( + get_cache("memory"), + keys_from_attr="ids", + skip_cache_func=lambda key, value: value is None or value.get("inactive"), + ttl=300 +) +async def get_accounts(ids: list[str]) -> dict[str, dict[str, str] | None]: + ... +``` + +**`@multi_cached` parameters:** +- **First positional arg**: cache instance from `get_cache(alias)` — required +- **`keys_from_attr`**: name of the argument containing the list of keys to cache +- **`ttl`**: time-to-live in seconds (default: 60) +- **`key_builder`**: callable for custom key generation. Signature: `lambda key, func, *args, **kwargs: str` +- **`skip_cache_func`**: callable to skip caching specific values. Signature: `lambda key, value: bool` + +--- + +## Initialization + +Caches are automatically initialized by: +- FastAPI lifespan event (on startup) +- Celery `on_after_configure` signal +- QuasiQueue main entry point + +For custom scripts or CLI commands, call manually: + +```python +from {{cookiecutter.__package_slug}}.services.cache import configure_caches +configure_caches() +``` + +--- + +## NoOpCache + +When `CACHE_ENABLED=False`, all caches use `NoOpCache` — a transparent drop-in that satisfies the `BaseCache` interface without storing anything. This means code that uses the cache helpers works identically whether caching is enabled or not. + +--- + +## Key Conventions + +- Use meaningful, namespaced keys: `user:v1:123` not just `123` +- Always check for `None` returns — cache misses are normal +- Use `memory` for request-scoped data, `persistent` for cross-instance sharing +- Set `CACHE_ENABLED=False` in development to debug uncached behavior + +--- + +## Style Checklist + +- [ ] Cache keys use namespaced format (`entity:version:id`) +- [ ] Cache miss returns `None` — always have a fallback +- [ ] `configure_caches()` called before any cache operations in custom scripts +- [ ] `memory` alias for ephemeral data, `persistent` for shared data +- [ ] TTL explicitly set only when different from defaults +- [ ] `@cached` decorator receives a cache instance from `get_cache()`, not an alias string +- [ ] `@cached` only used on `async` functions — never synchronous +- [ ] `noself=True` set on class method decorators +- [ ] `@multi_cached` used for functions returning dicts of individually-cached items +- [ ] `skip_cache_func` set on `@multi_cached` to avoid caching None/inactive values + +--- + +## Further Reading + +- [docs/dev/cache.md](../../docs/dev/cache.md) — Full caching developer guide +- [aiocache Docs](https://aiocache.readthedocs.io/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/celery-tasks/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/celery-tasks/SKILL.md new file mode 100644 index 0000000..5ac6ca1 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/celery-tasks/SKILL.md @@ -0,0 +1,197 @@ +--- +name: celery-tasks +description: "Create or modify Celery tasks and periodic task configuration. Use when: adding new background tasks, setting up periodic/scheduled tasks, configuring Celery workers, or understanding the Celery app setup." +--- + +# Celery Tasks + +> **context7**: If the `mcp_context7` tool is available, resolve and load the full `celery` documentation before making any changes to the task system: +> ``` +> mcp_context7_resolve-library-id: "celery" +> mcp_context7_get-library-docs: +> ``` + +The Celery application is defined in `{{cookiecutter.__package_slug}}/celery.py`. Tasks are exposed via the `@celery.task` decorator. + +--- + +## Defining Tasks + +Import the `celery` app instance and decorate functions: + +```python +from logging import getLogger +from {{cookiecutter.__package_slug}}.celery import celery + +logger = getLogger(__name__) + + +@celery.task +def send_email(to: str, subject: str, body: str) -> dict[str, str]: + """Send an email asynchronously.""" + logger.info(f"Sending email to {to}: {subject}") + return {"status": "sent", "to": to} +``` + +**Rules:** +- Use `logger` (never `print`) for all output +- Pass IDs, not objects — tasks serialize arguments, complex objects can't be serialized reliably +- Return simple types (dict, list, primitives) — not ORM instances + +--- + +## Task Organization + +Organize tasks in separate modules under `{{cookiecutter.__package_slug}}/tasks/`: + +``` +{{cookiecutter.__package_slug}}/ +├── celery.py # Celery app configuration +└── tasks/ + ├── __init__.py + ├── email.py # Email-related tasks + └── reports.py # Report generation tasks +``` + +Import task modules in `{{cookiecutter.__package_slug}}/celery.py` to ensure registration: + +```python +from {{cookiecutter.__package_slug}}.tasks import email, reports +``` + +--- + +## Calling Tasks + +```python +# Fire and forget +send_email.delay("user@example.com", "Welcome", "Thanks for signing up!") + +# With options +send_email.apply_async( + args=["user@example.com", "Welcome", "Body"], + countdown=60, # Execute after 60 seconds + queue='emails', # Route to specific queue +) + +# Get result (blocking) +result = send_email.delay("user@example.com", "Hello", "Body") +output = result.get(timeout=10) +``` + +--- + +## Periodic Tasks + +Use the `on_after_configure` signal in `{{cookiecutter.__package_slug}}/celery.py` (Celery's documented pattern for periodic task registration): + +```python +from celery import Celery +from celery.schedules import crontab + +@celery.on_after_configure.connect +def setup_periodic_tasks(sender: Celery, **kwargs) -> None: + logger.info("Setting up periodic tasks") + sender.add_periodic_task(300.0, cleanup_old_data.s(), name="Cleanup every 5 min") + sender.add_periodic_task( + crontab(hour=2, minute=0), + generate_report.s(), + name="Daily report at 2 AM" + ) +``` + +> **Note:** Use `on_after_finalize` instead of `on_after_configure` only when periodic tasks reference tasks defined in external modules, to ensure the task registry is fully populated before registration. + +--- + +{%- if cookiecutter.include_sqlalchemy == "y" %} + +## Database Access in Tasks + +Use the project's `get_session` with an async context manager. Wrap async work in a single `asyncio.run()` call: + +```python +import asyncio +from {{cookiecutter.__package_slug}}.services.db import get_session + +@celery.task +def process_user_sync(user_id: int) -> dict[str, str]: + return asyncio.run(process_user(user_id)) + +async def process_user(user_id: int) -> dict[str, str]: + async with get_session() as session: + # ... async DB operations + pass + return {"status": "done"} +``` + +> **Warning:** `asyncio.run()` creates a new event loop. It will raise `RuntimeError` if called from within an existing event loop (e.g., when using Celery's `gevent` or `eventlet` worker pool). In those cases, use `asyncio.get_event_loop().run_until_complete()` or configure Celery with the `asynpool` worker. + +--- + +{%- endif %} + +{%- if cookiecutter.include_aiocache == "y" %} + +## Cache Integration + +Caches are automatically initialized on `on_after_configure`. Use them directly in tasks: + +```python +import asyncio +from {{cookiecutter.__package_slug}}.services.cache import get_cached, set_cached + +@celery.task +def cached_task(key: str) -> str | None: + async def _run() -> str | None: + value = await get_cached(key) + if value is None: + value = compute_expensive_value() + await set_cached(key, value, alias="persistent") + return value + return asyncio.run(_run()) +``` + +> **Note:** When calling multiple async functions, batch them in a single `async def` and wrap with one `asyncio.run()` call. Calling `asyncio.run()` multiple times in the same function will raise `RuntimeError`. + +--- + +{%- endif %} + +## Retries + +```python +from logging import getLogger + +logger = getLogger(__name__) + +@celery.task(bind=True, max_retries=3, default_retry_delay=60) +def fragile_task(self) -> None: + try: + do_risky_thing() + except Exception as exc: + logger.exception("Task failed, retrying") + raise self.retry(exc=exc, countdown=2 ** self.request.retries) +``` + +> **Note:** Always pass the caught exception to `self.retry(exc=exc)` so the original traceback is preserved in logs and raised when `max_retries` is exceeded. + +--- + +## Style Checklist + +- [ ] Task uses `logger` (not `print`) for all output +- [ ] Task accepts IDs, not complex objects +- [ ] Task returns simple types (dict, list, primitives) +- [ ] Task module is imported in `{{cookiecutter.__package_slug}}/celery.py` for registration +- [ ] `@celery.task` decorator used (not standalone `@app.task`) +- [ ] Async tasks use a single `asyncio.run()` wrapper (batch multiple awaits in one `async def`) +- [ ] Retries use `bind=True` with `self.retry(exc=exc)` +- [ ] Periodic tasks use `on_after_configure` signal (not `on_after_finalize`) + +--- + +## Further Reading + +- [docs/dev/celery.md](../../docs/dev/celery.md) — Full Celery developer guide +- [Celery Docs](https://docs.celeryq.dev/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/docker-compose/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/docker-compose/SKILL.md new file mode 100644 index 0000000..749fa55 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/docker-compose/SKILL.md @@ -0,0 +1,134 @@ +--- +name: docker-compose +description: "Work with the Docker Compose development environment. Use when: starting or stopping services, inspecting logs, opening a shell in a container, resetting the database, or understanding the service topology." +--- + +# Docker Compose Development Environment + +> **context7**: If the `mcp_context7` tool is available, resolve and load the full Docker Compose documentation before modifying `compose.yaml` or using advanced CLI options: +> ``` +> mcp_context7_resolve-library-id: "docker compose" +> mcp_context7_get-library-docs: /docker/compose +> ``` + +The development environment runs entirely through Docker Compose. All services are defined in `compose.yaml`. + +--- + +## Services + +{%- if cookiecutter.include_fastapi == "y" %} +| `www` | FastAPI application server | +{%- endif %} +{%- if cookiecutter.include_celery == "y" %} +| `celery-scheduler` | Celery Beat scheduler for periodic tasks | +| `celery-node` | Celery worker for background tasks | +{%- endif %} +{%- if cookiecutter.include_quasiqueue == "y" %} +| `qq` | QuasiQueue multiprocessing runner | +{%- endif %} +{%- if cookiecutter.include_sqlalchemy == "y" %} +| `db` | PostgreSQL database | +{%- endif %} +{%- if cookiecutter.include_celery == "y" or cookiecutter.include_aiocache == "y" %} +| `redis` | Redis cache / task broker | +{%- endif %} + +--- + +## Essential Commands + +### Start / Stop + +```bash +# Start all services in the background +docker compose up -d + +# Start with live rebuild on file changes +docker compose up --watch + +# Stop all services (preserves volumes — data is retained) +docker compose down + +# Stop all services AND remove volumes (full reset — destroys all data) +docker compose down --volumes --remove-orphans + +# Restart all services without destroying containers or volumes +docker compose restart + +# Restart a single service +docker compose restart www +``` + +### Logs + +```bash +# View recent logs from all services +docker compose logs + +# Follow (tail) logs from all services in real-time +docker compose logs -f + +# Follow logs from a specific service +docker compose logs -f www +``` + +### Status and Inspection + +```bash +# List running services and their status +docker compose ps + +# Open a bash shell inside a running service +docker compose exec www bash +``` + +--- + +## Common Workflows + +### Start a fresh development environment + +```bash +docker compose up -d +docker compose logs -f # watch until all services are healthy +``` + +### Full reset (wipe all data and restart) + +```bash +docker compose down -v +docker compose up -d +``` + +### Debug a service startup failure + +```bash +docker compose logs --tail 50 www +# or follow real-time: +docker compose logs -f www +``` + +### Run a one-off command inside a service + +```bash +# Run in a new container (use for migrations, one-off scripts) +docker compose run --rm www bash + +# Execute in an already-running container (use for debugging live services) +docker compose exec www bash +``` + +--- + +## Notes + +- All Docker-specific files (Dockerfiles, prestart scripts) live in the `docker/` folder. +- The developer `.env` file is loaded automatically by Compose — make sure it's populated before starting. + +--- + +## Further Reading + +- [docs/dev/docker.md](../../docs/dev/docker.md) — Full Docker developer guide covering service topology, volume management, hot-reload behavior, and multi-service debugging. +- [Docker Compose Docs](https://docs.docker.com/compose/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/fastapi-routes/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/fastapi-routes/SKILL.md new file mode 100644 index 0000000..5718922 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/fastapi-routes/SKILL.md @@ -0,0 +1,199 @@ +--- +name: fastapi-routes +description: "Create or modify FastAPI routes. Use when: adding new API endpoints, creating Pydantic request/response models, registering routers, designing REST APIs, or following route conventions for this project." +--- + +# FastAPI Routes + +> **context7**: If the `context7_query-docs` tool is available, resolve and load the full `fastapi` documentation before proceeding: +> ``` +> context7_resolve-library-id: "fastapi" +> context7_query-docs: /fastapi/fastapi "" +> ``` + +Guidelines and patterns for writing FastAPI routes in this codebase. + +--- + +## REST Principles + +APIs must adhere as closely as possible to REST principles, including appropriate use of HTTP verbs: + +| Verb | Usage | +| -------- | -------------------------------- | +| `GET` | Read one or many resources | +| `POST` | Create a new resource | +| `PUT` | Replace / fully update a resource | +| `PATCH` | Partial update (use sparingly) | +| `DELETE` | Remove a resource | + +--- + +## Pydantic Models + +- **Always** use Pydantic models for both input and output — never use plain `dict` or `Any`. +- **Never** reuse the same model for input and output. Use separate models: + - `PostCreate` — user input for creating a resource + - `PostUpdate` — user input for updating (optional fields) + - `PostRead` — response shape returned to the client +- Parameters in **input** models must use `Field()` with validation constraints and a `description`. +- Output models do not require `Field()` unless you need aliases or serialization control. + +```python +from uuid import UUID +from pydantic import BaseModel, Field + + +class PostCreate(BaseModel): + title: str = Field(min_length=1, max_length=200, description="Post title") + content: str = Field(min_length=1, description="Post content") + + +class PostRead(BaseModel): + id: UUID + title: str + content: str + created_at: str + + +class PostUpdate(BaseModel): + title: str | None = Field(default=None, max_length=200, description="Post title") + content: str | None = Field(default=None, description="Post content") +``` + +--- + +## Router Pattern + +Use `APIRouter` for all routes. Register the router in `{{cookiecutter.__package_slug}}/www.py`. + +```python +from uuid import UUID + +from fastapi import APIRouter, status + +router = APIRouter() + + +@router.post("/posts", response_model=PostRead, status_code=status.HTTP_201_CREATED) +async def create_post(post: PostCreate) -> PostRead: + ... + + +@router.get("/posts/{post_id}", response_model=PostRead) +async def get_post(post_id: UUID) -> PostRead: + ... + + +@router.put("/posts/{post_id}", response_model=PostRead) +async def update_post(post_id: UUID, post: PostUpdate) -> PostRead: + ... + + +@router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_post(post_id: UUID) -> None: + ... +``` + +--- + +## Route Registration + +Register routers in `{{cookiecutter.__package_slug}}/www.py` using `app.include_router()`. + +**Exceptions** (no prefix by convention): +- `/health` — liveness probe +- `/` — root redirect to docs +- `/static` — static file serving + +--- + +## OpenAPI Docs + +The OpenAPI documentation is served at these fixed paths — do not move them: + +| URL | Interface | +| ----------------- | ------------ | +| `/docs` | Swagger UI | +| `/redoc` | ReDoc | +| `/openapi.json` | Raw schema | + +--- + +## Error Handling + +Use `HTTPException` with appropriate status codes: + +```python +from fastapi import HTTPException, status + +@router.get("/posts/{post_id}", response_model=PostRead) +async def get_post(post_id: UUID) -> PostRead: + post = await get_post_from_db(post_id) + if post is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found") + return post +``` + +--- + +## Dependency Injection + +Use `Depends()` to inject shared logic (database sessions, authentication, etc.) into routes. + +```python +from fastapi import Depends + +from {{cookiecutter.__package_slug}} import get_db + + +@router.get("/posts/{post_id}", response_model=PostRead) +async def get_post(post_id: UUID, db = Depends(get_db)) -> PostRead: + ... +``` + +--- + +## Lifespan + +Use the `lifespan` parameter on the `FastAPI` app instance for startup and shutdown logic. Do not use the deprecated `@app.on_event("startup")` / `@app.on_event("shutdown")` decorators. + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup logic + await initialize_database() + yield + # Shutdown logic + await close_database() + + +app = FastAPI(lifespan=lifespan) +``` + +--- + +## Style Checklist + +Before submitting a new route, verify: + +- [ ] HTTP verb matches the operation semantics +- [ ] Separate `Create`, `Update`, and `Read` models defined +- [ ] All input model fields use `Field()` with description +- [ ] Route is `async` +- [ ] Router is registered in `{{cookiecutter.__package_slug}}/www.py` +- [ ] 201 status used for `POST` create endpoints +- [ ] 204 status used for `DELETE` endpoints (no response body) +- [ ] Path parameters use typed UUIDs where appropriate + +--- + +## Further Reading + +- [docs/dev/api.md](../../docs/dev/api.md) — Full FastAPI developer guide covering the application structure, startup events, Swagger UI, ReDoc, OpenAPI schema, middleware, and dependency injection patterns. +- [FastAPI Docs](https://fastapi.tiangolo.com/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/SKILL.md new file mode 100644 index 0000000..195861d --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/SKILL.md @@ -0,0 +1,165 @@ +--- +name: jinja-templates +description: "Create or modify Jinja2 templates. Use when: adding new HTML templates, configuring the Jinja2 environment, adding custom filters or globals, rendering templates outside FastAPI, or working with template inheritance." +--- + +# Jinja2 Templates + +> **context7**: If the `mcp_context7` tool is available, resolve and load the full `jinja2` documentation before making changes: +> ``` +> mcp_context7_resolve-library-id: "pallets/jinja" +> mcp_context7_get-library-docs: +> ``` + +The Jinja2 environment is configured in `{{cookiecutter.__package_slug}}/services/jinja.py`. Templates live in `{{cookiecutter.__package_slug}}/templates/`. + +--- + +## Environment + +The environment uses `PackageLoader` and `autoescape=True`: + +```python +from jinja2 import Environment, PackageLoader + +env = Environment( + loader=PackageLoader("{{cookiecutter.__package_slug}}"), + autoescape=True, +) +``` + +{%- if cookiecutter.include_fastapi == "y" %} + +For FastAPI responses, use `response_templates`: + +```python +from fastapi import Request +from {{cookiecutter.__package_slug}}.services.jinja import response_templates + +@app.get("/page") +async def page(request: Request) -> Response: + return response_templates.TemplateResponse( + "page.html", + {"request": request, "title": "Page"}, + ) +``` + +--- + +{%- endif %} + +## Rendering Templates Outside FastAPI + +Use the raw `env` for emails, tasks, CLI output: + +```python +from {{cookiecutter.__package_slug}}.services.jinja import env + +template = env.get_template("emails/welcome.html") +html = template.render(name="World", year=2026) +``` + +--- + +## Template Structure + +Organize templates in subdirectories: + +``` +{{cookiecutter.__package_slug}}/templates/ +├── base.html # Base layout +├── pages/ +│ └── home.html +├── components/ +│ └── header.html +└── emails/ + └── welcome.html +``` + +--- + +## Custom Filters and Globals + +Add to `{{cookiecutter.__package_slug}}/services/jinja.py`: + +```python +def format_currency(value: float) -> str: + return f"${value:,.2f}" + +env.filters["currency"] = format_currency +env.globals["settings"] = settings +``` + +--- + +## Template Inheritance + +**Base template** (`templates/base.html`): + +```html +{%- raw %} + + +{% block title %}Default{% endblock %} + + {% include "components/header.html" %} +
{% block content %}{% endblock %}
+ + +{% endraw -%} +``` + +**Child template** (`templates/pages/home.html`): + +```html +{%- raw %} +{% extends "base.html" %} +{% block title %}Home{% endblock %} +{% block content %}

Welcome

{% endblock %} +{% endraw -%} +``` + +--- + +## Security + +- Autoescape is enabled — user input is automatically HTML-escaped +- Never use `|safe` on user-provided content +- For trusted HTML, use `markupsafe.Markup` in Python or `|safe` in templates + +--- + +## Sandboxed Templates + +**Any template whose source comes from users, third-party systems, or external APIs must be rendered with `sandbox_env`, not `env`.** + +The standard `env` allows templates to access arbitrary Python attributes and call any function passed to `render()`. A malicious template can exploit this to read secrets, access internals, or execute arbitrary code. The `SandboxedEnvironment` intercepts attribute access, method calls, and operators to prevent these attacks. + +| Source | Environment | +| --- | --- | +| Templates in `templates/` (project code) | `env` | +| User-submitted templates | `sandbox_env` | +| Templates from external APIs or plugins | `sandbox_env` | +| Templates stored in the database by users | `sandbox_env` | + +For usage patterns, blocked/allowed operations, immutable sandboxing, and customization — see [references/sandboxed-templates.md](references/sandboxed-templates.md). + +--- + +## Style Checklist + +- [ ] Templates live in `{{cookiecutter.__package_slug}}/templates/` +- [ ] Custom filters/globals added to `services/jinja.py` +- [ ] Base template used for layout inheritance +- [ ] Components extracted to `components/` subdirectory +- [ ] `|safe` never applied to user input +- [ ] `env` used for non-FastAPI rendering, `response_templates` for FastAPI +- [ ] `sandbox_env` used for ALL user-provided or third-party template content +- [ ] `SecurityError` caught and handled when rendering sandboxed templates + +--- + +## Further Reading + +- [docs/dev/templates.md](../../docs/dev/templates.md) — Full Jinja2 developer guide +- [Jinja2 Docs](https://jinja.palletsprojects.com/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/references/sandboxed-templates.md b/{{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/references/sandboxed-templates.md new file mode 100644 index 0000000..4891a9e --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/jinja-templates/references/sandboxed-templates.md @@ -0,0 +1,91 @@ +--- +name: sandboxed-templates +description: "Full reference for Jinja2 sandboxed template security: usage patterns, blocked/allowed operations, immutable sandboxing, and custom safe attribute/callable overrides." +--- + +# Sandboxed Templates Reference + +Detailed guidance for working with `SandboxedEnvironment` in this project. + +--- + +## Usage + +```python +from jinja2.sandbox import SecurityError +from {{cookiecutter.__package_slug}}.services.jinja import sandbox_env + +# Render a template string from an untrusted source +try: + template = sandbox_env.from_string(user_provided_template_string) + output = template.render(name="World") +except SecurityError: + # Template tried to access something dangerous — reject it + pass +``` + +--- + +## What the Sandbox Blocks + +- Access to any `__*__` attributes (`__class__`, `__code__`, `__dict__`, `__globals__`) +- Access to attributes starting with `_` (private attributes) +- Calling objects marked with `@unsafe` decorator (callables are safe by default) +- Attribute traversal chains that reach into Python internals + +--- + +## What the Sandbox Allows + +{% raw %} +- Variable interpolation: `{{ name }}`, `{{ user.name }}` +- Control structures: `{% if %}`, `{% for %}`, `{% include %}` +{% endraw %} +- Built-in filters: `|upper`, `|lower`, `|length`, `|safe` +- Calling functions passed as render context variables + +--- + +## Immutable Sandboxing + +Use `ImmutableSandboxedEnvironment` when you also need to prevent templates from modifying data structures passed to `render()`. It blocks mutating method calls on built-in mutable objects (`list.append`, `dict.clear`, `set.pop`, `collections.deque.extend`, etc.) using the `modifies_known_mutable()` check: + +```python +from jinja2.sandbox import ImmutableSandboxedEnvironment + +{% raw %} +# Prevents templates from doing: {{ items.append("hacked") }} +{% endraw %} +immutable_env = ImmutableSandboxedEnvironment(autoescape=True) +``` + +--- + +## Customizing the Sandbox + +If you need to allow specific attributes or callables that the sandbox blocks by default, subclass `SandboxedEnvironment` and override its security methods: + +```python +from jinja2.sandbox import SandboxedEnvironment, is_internal_attribute + +class CustomSandboxedEnvironment(SandboxedEnvironment): + def is_safe_attribute(self, obj, attr, value): + # Allow specific attributes that would normally be blocked + if attr == "custom_attr": + return True + # Use the helper to check for internal Python attributes + if is_internal_attribute(obj, attr): + return False + return not attr.startswith("_") + + def is_safe_callable(self, obj): + # Add custom logic to allow specific callables + return super().is_safe_callable(obj) +``` + +--- + +## Reference + +- [Jinja2 Sandbox Docs](https://jinja.palletsprojects.com/en/stable/sandbox/) +- [Jinja2 SandboxedEnvironment API](https://jinja.palletsprojects.com/en/stable/api/#jinja2.sandbox.SandboxedEnvironment) \ No newline at end of file diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/makefile/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/makefile/SKILL.md new file mode 100644 index 0000000..ae67cb8 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/makefile/SKILL.md @@ -0,0 +1,131 @@ +--- +name: makefile +description: "Complete reference for all make targets in the project. Use when: looking up the right make command for any task — setup, testing, linting, formatting, database, packaging, or cleanup." +--- + +# Makefile Reference + +All developer tasks are exposed as `make` targets. Run from the project root. + +--- + +## Setup + +| Target | What it does | +| ------------- | --------------------------------------------------------------- | +| `make install` | Install Python deps, create `.venv` (first-time setup) | +| `make sync` | Sync Python deps with `uv.lock` (after pulling changes) | +| `make pre-commit` | Install pre-commit hooks | +| `make lock` | Upgrade and relock all dependencies | +| `make lock-check` | Verify lock file is up to date without changing it | + +--- + +## Testing + +| Target | What it does | +| --------------- | ----------------------------------------------------------------- | +{%- if cookiecutter.include_sqlalchemy == "y" %} +| `make tests` | Run **everything**: pytest, ruff, black, mypy, prettier, TOML, paracelsus | +{%- else %} +| `make tests` | Run **everything**: pytest, ruff, black, mypy, prettier, TOML formatting | +{%- endif %} +| `make pytest` | Run pytest with coverage report | +| `make pytest_loud` | Run pytest with `DEBUG` log output enabled | + +--- + +## Code Quality Checks + +These check only — they do not auto-fix. + +| Target | What it checks | +| ------------------------------- | ------------------------------------------------- | +| `make ruff_check` | Ruff linter | +| `make black_check` | Ruff formatter (black style) | +| `make mypy_check` | Type checking (mypy) | +| `make prettier_check` | Markdown, JSON, YAML, TOML formatting (prettier) | +| `make tomlsort_check` | TOML file formatting (tombi) | + +{%- if cookiecutter.include_sqlalchemy == "y" %} + +| `make paracelsus_check` | Database schema docs are up to date | +| `make check_ungenerated_migrations` | No pending Alembic migration changes | + +{%- endif %} + +--- + +## Code Formatting (Auto-fix) + +| Target | What it fixes | +| ------------------------ | ------------------------------------------------------ | +{%- if cookiecutter.include_sqlalchemy == "y" %} +| `make chores` | Run **all** auto-fixes: ruff, format, prettier, TOML, schema docs | +{%- else %} +| `make chores` | Run **all** auto-fixes: ruff, format, prettier, TOML | +{%- endif %} +| `make ruff_fixes` | Auto-fix ruff lint issues | +| `make black_fixes` | Auto-format Python code (black style via ruff) | +| `make prettier_fixes` | Auto-format markdown/JSON/YAML/TOML | +| `make tomlsort_fixes` | Auto-format TOML files (tombi) | + +**Typical workflow before committing:** `make chores && make tests` + +--- + +{%- if cookiecutter.include_sqlalchemy == "y" %} + +## Database + +| Target | What it does | +| ----------------------------------------------- | ------------------------------------------------------- | +| `make create_migration MESSAGE="description"` | Generate a new Alembic migration from model changes | +| `make check_ungenerated_migrations` | Fail if there are model changes without a migration | +| `make run_migrations` | Apply all pending migrations (`alembic upgrade head`) | +| `make document_schema` | Regenerate `docs/dev/database.md` from current models | +| `make paracelsus_check` | Verify schema docs are current (read-only check) | +| `make reset_db` | Wipe local SQLite test DB and reapply all migrations | + +`create_migration` requires a `MESSAGE` argument: + +```bash +make create_migration MESSAGE="add email_verified column to users" +``` + +--- + +{%- endif %} + +{%- if cookiecutter.publish_to_pypi == "y" %} + +## Packaging + +| Target | What it does | +| ------------------ | --------------------------------------------- | +| `make build` | Build Python package distribution (sdist + wheel) | + +--- + +{%- endif %} + +## Quick Reference by Task + +| I want to… | Run | +| ----------------------------------------- | ------------------------------------ | +| Set up for the first time | `make install` | +| Run all tests before a PR | `make tests` | +| Fix all formatting issues | `make chores` | +| Check types only | `make mypy_check` | +{%- if cookiecutter.include_sqlalchemy == "y" %} +| Add a database migration | `make create_migration MESSAGE="..."` | +| Regenerate DB docs after model changes | `make document_schema` | +{%- endif %} +| Run only Python tests with verbose output | `make pytest_loud` | +| Update dependencies | `make lock && make sync` | + +--- + +## Further Reading + +- [docs/dev/makefile.md](../../docs/dev/makefile.md) — Full makefile developer guide with detailed explanations of every target, shell autocomplete setup, and usage examples. diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/pydantic-settings/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/pydantic-settings/SKILL.md new file mode 100644 index 0000000..7d1d040 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/pydantic-settings/SKILL.md @@ -0,0 +1,121 @@ +--- +name: pydantic-settings +description: "Add or modify application configuration settings. Use when: adding new environment variables, settings fields, understanding settings conventions, working with secrets, or configuring optional vs required settings." +--- + +# Pydantic Settings + +> **context7**: If the `context7` tools are available, resolve and load the full `pydantic-settings` documentation before adding or modifying any settings: +> ``` +> context7_resolve-library-id: "pydantic-settings" +> context7_query-docs: "your query" +> ``` + +All application configuration is managed through the `pydantic-settings` library. Settings are defined in a single class and loaded from the environment and `.env` file. + +--- + +## Key Rules + +- **One settings class**: The main `Settings` class lives at `{{cookiecutter.__package_slug}}/conf/settings.py`. Always update this existing class — never create a new settings class. +- **Secrets use `SecretStr` or `SecretBytes`**: Any value that is sensitive (passwords, tokens, API keys) must be wrapped in one of these types. +- **Optional settings default to `None`**: Never use empty strings as a sentinel for "not set". +- **All fields use `Field()`**: Include a `description=` for every field so operators know what it does. + +--- + +## Pattern + +```python +# {{cookiecutter.__package_slug}}/conf/settings.py +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Regular setting with a default + project_name: str = Field(default="{{ cookiecutter.__package_slug }}", description="Human-readable project name") + + # Optional sensitive setting — defaults to None, not empty string + api_key: SecretStr | None = Field( + default=None, + description="API key for external service. Leave unset to disable.", + ) + + # Optional non-sensitive setting + max_connections: int = Field( + default=10, + description="Maximum number of connections", + ) +``` + +--- + +## Accessing Secrets + +`SecretStr` wraps the value to prevent accidental logging. Access the actual value explicitly when needed: + +```python +settings.api_key.get_secret_value() if settings.api_key else None +``` + +--- + +## Optional vs Required Fields + +| Pattern | Behavior | +| ------------------------------- | ----------------------------------------------------- | +| `field: str = Field(...)` | Required — raises `ValidationError` if not set | +| `field: str = Field(default=x)` | Optional with default | +| `field: str \| None = Field(default=None)` | Optional, absent means `None` | + +Never use `""` as a default for "not configured": + +```python +# Bad +smtp_host: str = Field(default="") + +# Good +smtp_host: str | None = Field(default=None, description="SMTP server hostname. None disables email.") +``` + +--- + +## Environment Variable Naming + +`pydantic-settings` maps field names to environment variable names automatically using the field name in uppercase: + +``` +project_name → PROJECT_NAME +database_url → DATABASE_URL +``` + +--- + +## Adding a New Setting + +1. Open `{{cookiecutter.__package_slug}}/conf/settings.py`. +2. Add the field to the existing `Settings` class with `Field(description=...)`. +3. Use `SecretStr` if the value is sensitive. +4. Default to `None` (not `""`) if the setting is optional. +5. Update `.env.example` with the new variable name and a placeholder value or explanation. + +--- + +## Developer Environment + +- Settings are loaded from `.env` in the project root (gitignored). +- `.env.example` is the template for new developers — keep it updated with every new setting. + +--- + +## Further Reading + +- [docs/dev/settings.md](../../docs/dev/settings.md) — Full settings developer guide covering all configuration modules, accessing settings in different component types, and environment variable conventions. +- [Pydantic Settings Docs](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/python-testing/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/python-testing/SKILL.md new file mode 100644 index 0000000..e0a957e --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/python-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: python-testing +description: "Write or modify Python tests. Use when: adding new tests, understanding testing conventions, working with fixtures, writing FastAPI route tests, database tests, or following pytest patterns. DO NOT USE FOR RUNNING TESTS. If you are just running tests, not building them, you do not need this." +--- + +# Python Testing + +> **context7**: If the `context7_query-docs` tool is available, resolve and load the full `pytest` documentation before creating new tests or running pytest commands not in the makefile: +> ``` +> context7_resolve-library-id: "pytest" +> context7_query-docs: /pytest-dev/pytest "" +> ``` + +Guidelines and patterns for writing tests in this codebase. + +--- + +## General Rules + +- **No test classes** unless there is a specific technical reason. Prefer standalone functions. +- **All fixtures** must be defined or imported in `conftest.py` so they are automatically available to all tests in that directory. +- **No mocks for simple dataclasses or Pydantic models** — construct an instance directly with the desired parameters instead. +- **Test file structure mirrors the main code** — a test for `{{cookiecutter.__package_slug}}/foo.py` lives at `tests/test_foo.py`. +- **When adding new code, add tests to cover it.** + +--- + +## Running Tests + +```bash +make pytest # Run full test suite with coverage report +make pytest_loud # Run with debug logging enabled +uv run pytest # Run directly — append any pytest options/arguments +uv run pytest tests/test_foo.py -k test_my_function -s +``` + +--- + +{%- if cookiecutter.include_fastapi == "y" %} + +## FastAPI Tests + +Use the FastAPI `TestClient` via a fixture rather than calling router classes directly. + +```python +import pytest +from fastapi.testclient import TestClient +from {{cookiecutter.__package_slug}}.www import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_get_health(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 +``` + +--- + +{%- endif %} + +{%- if cookiecutter.include_sqlalchemy == "y" %} + +## Database Tests + +Use a memory-backed SQLite fixture. Wire it into the FastAPI app via a dependency override so routes use the test database automatically. + +```python +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from {{cookiecutter.__package_slug}}.models.base import Base + + +@pytest_asyncio.fixture +async def db_session() -> AsyncSession: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + yield session + + await engine.dispose() +``` + +--- + +{%- endif %} + +## Fixture Conventions + +- Fixtures shared across multiple test files → `tests/conftest.py` +- Fixtures specific to a subdirectory → `tests//conftest.py` +- Complex fixture content → `tests/fixtures/` + +--- + +## Style Checklist + +- [ ] Test is a standalone function (no wrapping class) +- [ ] Fixtures defined/imported in `conftest.py` +- [ ] No mocks for dataclasses or Pydantic models — use real instances +{%- if cookiecutter.include_sqlalchemy == "y" %} +- [ ] Database tests use memory SQLite with dependency override +{%- endif %} +{%- if cookiecutter.include_fastapi == "y" %} +- [ ] FastAPI tests use `TestClient` fixture +{%- endif %} +- [ ] Test file location mirrors the module being tested + +--- + +## Further Reading + +- [docs/dev/testing.md](../../docs/dev/testing.md) — Full testing developer guide covering pytest configuration, coverage reporting, async test patterns, database test fixtures, and CI integration. +- [pytest Docs](https://docs.pytest.org/) +- [pytest-asyncio Docs](https://pytest-asyncio.readthedocs.io/) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/sqlalchemy-models/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/sqlalchemy-models/SKILL.md new file mode 100644 index 0000000..2212622 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/sqlalchemy-models/SKILL.md @@ -0,0 +1,185 @@ +--- +name: sqlalchemy-models +description: "Create or modify SQLAlchemy models, queries, and Alembic migrations. Use when: defining new database tables, writing queries, creating migrations, checking model conventions, or understanding the database layer." +--- + +# SQLAlchemy Models and Migrations + +> **context7**: If the `mcp_context7` tool is available, load the full `sqlalchemy` and `alembic` documentation before debugging, creating or modifying models or queries, or writing migrations: +> ``` +> mcp_context7_resolve-library-id: "sqlalchemy" +> mcp_context7_get-library-docs: +> mcp_context7_resolve-library-id: "alembic" +> mcp_context7_get-library-docs: +> ``` + +Guidelines and patterns for database models, queries, and Alembic migrations in this codebase. + +--- + +## Core Requirements + +- Always use **async** SQLAlchemy APIs (never synchronous). +- Always use **SQLAlchemy 2.0** syntax. +- Represent database tables with the **declarative class system** (`Base` subclass). +- Use **Alembic** for all schema changes — never alter the schema manually. +- Migrations must be compatible with **both SQLite and PostgreSQL**. + +--- + +## Model Definition + +Models live in `{{cookiecutter.__package_slug}}/models/`. Import and extend the shared `Base` from `{{cookiecutter.__package_slug}}.models.base`. + +```python +from uuid import UUID, uuid4 + +from sqlalchemy.orm import Mapped, mapped_column + +from {{cookiecutter.__package_slug}}.models.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + email: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] + is_active: Mapped[bool] = mapped_column(default=True) +``` + +### Typing Conventions + +| Pattern | Meaning | +| ---------------------------------------- | -------------------------------- | +| `Mapped[str]` | NOT NULL column | +| `Mapped[str \| None]` | NULLable column | +| `mapped_column(default=...)` | Server-side / Python-side default | +| `mapped_column(primary_key=True)` | Primary key | +| `mapped_column(unique=True)` | Unique constraint | +| `mapped_column(index=True)` | Index | + +--- + +## Querying + +Always use `select()` with `await session.execute()`. For multiple criteria, always use `and_()` — never pass multiple comma-separated expressions to `.where()` (it breaks mypy type checking). + +```python +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + + +async def get_active_user(session: AsyncSession, email: str, name: str) -> User | None: + stmt = select(User).where( + and_( + User.email == email, + User.name == name, + User.is_active == True, + ) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() +``` + +### Common Query Patterns + +```python +# Get one or None +result = await session.execute(select(User).where(User.id == user_id)) +user = result.scalar_one_or_none() + +# Get all matching +result = await session.execute(select(User).where(User.is_active == True)) +users = result.scalars().all() + +# Insert +session.add(new_user) +await session.flush() # write to DB within transaction +await session.commit() # or rely on context manager commit + +# Delete +await session.delete(user) +await session.commit() +``` + +--- + +## Migrations + +All schema changes must go through Alembic. Never modify the database schema directly. + +> **CRITICAL**: Never modify an existing migration file that has already been committed. Existing migrations may have already run in production or on other developer machines. Editing them breaks the migration chain and corrupts databases that applied the original version. If a migration needs to be changed, create a new migration that makes the correction instead. **Ask the developer for explicit permission before modifying any existing migration file.** + +### Create a Migration + +```bash +make create_migration MESSAGE="description of changes" +``` + +**Always use `make create_migration` — never run `alembic revision` directly.** The make target spins up a fresh SQLite database, applies all existing migrations to verify the chain is intact, generates the new revision, then formats the output. Running `alembic revision` directly skips all of this. + +Always review the generated migration before committing it — autogenerate is not perfect and may miss or misinterpret changes. + +### Check for Missing Migrations + +```bash +make check_ungenerated_migrations +``` + +Fails if there are model changes that haven't been captured in a migration file. Run this before committing. + +### Update Schema Documentation + +```bash +make document_schema +``` + +Regenerates the Paracelsus schema docs. Run after adding or modifying models. + +### SQLite + PostgreSQL Compatibility + +Migrations must work on both databases. Common pitfalls: + +- **`server_default`**: Use `sa.text()` for SQL literals (e.g., `server_default=sa.text('false')`) +- **`Boolean` columns**: Name the type (e.g., `sa.Boolean(name="my_col_bool")`) so Alembic batch mode can properly regenerate its CHECK constraint during SQLite migrations +- **`Enum` types**: PostgreSQL creates a named enum type; SQLite does not. Use `native_enum=False` for cross-DB enums +- **`ALTER COLUMN`**: SQLite does not support `ALTER COLUMN`. Use `batch_alter_table` in Alembic for SQLite compatibility + +```python +# In migration file — using batch for SQLite compatibility +def upgrade() -> None: + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("email", existing_type=sa.String(), nullable=False) +``` + +--- + +## File Placement + +- Models → `{{cookiecutter.__package_slug}}/models/.py` +- Base class → `{{cookiecutter.__package_slug}}/models/base.py` (do not modify) +- Migrations → `db/versions/_.py` (auto-generated by Alembic) + +--- + +## Style Checklist + +Before submitting model or migration changes: + +- [ ] Model extends `Base` from `{{cookiecutter.__package_slug}}.models.base` +- [ ] Model lives in `{{cookiecutter.__package_slug}}/models/` +- [ ] All columns explicitly typed with `Mapped[...]` +- [ ] All queries use `and_()` for multiple criteria +- [ ] Migration created with `make create_migration` +- [ ] `make check_ungenerated_migrations` passes +- [ ] `make document_schema` run after model changes +- [ ] Migration tested compatible with both SQLite and PostgreSQL + +--- + +## Further Reading + +- [docs/dev/database.md](../../docs/dev/database.md) — Full database developer guide covering session management, CRUD patterns, relationships, SQLite/PostgreSQL compatibility, and development vs. production configuration. +- [SQLAlchemy 2.0 Async Docs](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +- [Alembic Tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html) diff --git a/{{cookiecutter.__package_slug}}/.agents/skills/typer-cli/SKILL.md b/{{cookiecutter.__package_slug}}/.agents/skills/typer-cli/SKILL.md new file mode 100644 index 0000000..93a2a01 --- /dev/null +++ b/{{cookiecutter.__package_slug}}/.agents/skills/typer-cli/SKILL.md @@ -0,0 +1,156 @@ +--- +name: typer-cli +description: "Add or modify CLI commands using Typer. Use when: adding new CLI subcommands, wrapping async functions for CLI use, understanding the CLI entrypoint structure, or following the @syncify pattern for async commands." +--- + +# Typer CLI + +> **context7**: If documentation tools are available, resolve and load the full `typer` documentation before making any changes to the CLI system: +> ``` +> context7_resolve-library-id: "typer" +> context7_query-docs: "your query here" +> ``` + +Any command or script that must be accessible to users must be exposed through the Typer library. + +For full developer documentation see [docs/dev/cli.md](../../docs/dev/cli.md). + +--- + +## CLI Structure + +The CLI is registered in `pyproject.toml` as a project script pointing to `{{cookiecutter.__package_slug}}.cli:app`. + +The main app is defined in `{{cookiecutter.__package_slug}}/cli.py`. Domain-specific subcommand groups live in separate `{{cookiecutter.__package_slug}}/cli_.py` files and are mounted onto the main app. + +--- + +## Async Commands — `@syncify` + +Typer runs commands synchronously, but this project uses async throughout (database access, HTTP calls, etc.). Use the `@syncify` decorator from `{{cookiecutter.__package_slug}}/cli.py` to bridge them: + +```python +from {{cookiecutter.__package_slug}}.cli import syncify + +@app.command() +@syncify +async def my_command(name: str) -> None: + """This async function will run correctly from the CLI.""" + result = await some_async_operation(name) + typer.echo(result) +``` + +**Critical**: `@app.command()` must appear **before** `@syncify` in decorator order. Do **not** use `asyncio.run()` directly — `syncify` handles the event loop correctly. + +--- + +{%- if cookiecutter.include_sqlalchemy == "y" %} + +### Database Access in CLI Commands + +Use `get_session` from `{{cookiecutter.__package_slug}}.services.db` for database-backed commands. Always use it as an async context manager: + +```python +from {{cookiecutter.__package_slug}}.services.db import get_session + +@app.command() +@syncify +async def my_db_command() -> None: + """Example command using database access.""" + async with get_session() as session: + result = await session.execute(select(MyModel)) + items = result.scalars().all() + for item in items: + typer.echo(item.name) +``` + +--- + +{%- endif %} + +## Parameter Pattern + +Use `Annotated[]` for all arguments and options — it keeps the signature clean and is the Typer-recommended style: + +```python +import typer +from typing import Annotated + + +app = typer.Typer() + + +@app.command() +def process( + input_file: Annotated[str, typer.Argument(help="Path to the input file")], + output_file: Annotated[str | None, typer.Option(help="Path to the output file")] = None, + verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, +) -> None: + """Process the input file and generate output.""" + if verbose: + typer.echo(f"Processing {input_file}...") + typer.echo("Done!") +``` + +### Arguments vs Options + +| Type | Typer class | CLI usage | +| ------------------------ | ------------------ | -------------------------- | +| Positional (required) | `typer.Argument()` | `cmd my-cmd value` | +| Named flag/option | `typer.Option()` | `cmd my-cmd --flag x` | +| Boolean flag pair | `typer.Option(None, "--yes/--no")` | `cmd my-cmd --yes` | + +--- + +## Adding a New Command Group + +Create `{{cookiecutter.__package_slug}}/cli_.py`: + +```python +# {{cookiecutter.__package_slug}}/cli_reports.py +import typer +from typing import Annotated + +reports_app = typer.Typer( + help="Report generation commands.", + no_args_is_help=True, +) + + +@reports_app.command("generate") +def generate_report( + output: Annotated[str, typer.Argument(help="Output file path")], +) -> None: + """Generate a report and write it to the output path.""" + ... +``` + +Then mount it in `{{cookiecutter.__package_slug}}/cli.py`: + +```python +from .cli_reports import reports_app + +app.add_typer(reports_app, name="reports") +``` + +Use `no_args_is_help=True` on subapp `Typer()` instances so that `cmd reports` without arguments shows help rather than an error. + +--- + +## Error Handling Pattern + +For error output, write to stderr: `typer.echo(..., err=True)`. Exit with a non-zero code on failure: `raise typer.Exit(code=1)`. For clean exits: `raise typer.Exit()`. + +--- + +## Style Checklist + +- [ ] New commands in `{{cookiecutter.__package_slug}}/cli.py` or `{{cookiecutter.__package_slug}}/cli_.py` +- [ ] Async commands use `@syncify` (not `asyncio.run()`) +- [ ] `@app.command()` decorator appears **before** `@syncify` +- [ ] All parameters use `Annotated[type, typer.Argument/Option(...)]` +- [ ] Every command has a docstring (used as `--help` text) +- [ ] Subapp `Typer()` instances use `no_args_is_help=True` +- [ ] New subapp mounted in `{{cookiecutter.__package_slug}}/cli.py` with `app.add_typer(...)` +- [ ] Error output goes to `err=True` +- [ ] Failures raise `typer.Exit(code=1)` (not `sys.exit(1)` or bare exceptions) diff --git a/{{cookiecutter.__package_slug}}/AGENTS.md b/{{cookiecutter.__package_slug}}/AGENTS.md index 5258789..8b72639 100644 --- a/{{cookiecutter.__package_slug}}/AGENTS.md +++ b/{{cookiecutter.__package_slug}}/AGENTS.md @@ -20,7 +20,7 @@ make pre-commit # Install pre-commit hooks git mv old_path new_path # ALWAYS use git mv for moving or renaming files, never use mv or file manipulation tools ``` -**CRITICAL**: When moving or renaming files in a git repository, you MUST use `git mv` instead of regular `mv` or file manipulation tools. This ensures git properly tracks the file history and prevents issues with version control. The only exception to this is if you are moving files which are not tracked in git, as in that case `git mv` will have no effect. +**CRITICAL**: When moving or renaming files in a git repository, you MUST use `git mv` instead of regular `mv` or file manipulation tools. This ensures git properly tracks file history and prevents issues with version control. The only exception to this is if you are moving files which are not tracked in git, as in that case `git mv` will have no effect. ### Testing and Validation @@ -31,6 +31,8 @@ make pytest_loud # Run pytest with debug logging enabled uv run pytest # Run pytest directly with uv, adding any arguments and options needed ``` +For full testing conventions, fixture patterns, and database test strategies, see the `python-testing` skill. + ### Code Quality Checks ```bash @@ -71,6 +73,8 @@ make check_ungenerated_migrations # Check for ungenerated migrations make document_schema # Update database schema documentation ``` +For full model conventions, query patterns, and migration workflows, see the `sqlalchemy-models` skill. + {%- endif %} {%- if cookiecutter.publish_to_pypi == "y" %} @@ -91,14 +95,10 @@ make build # Build package distribution docker compose up -d # Start development environment and detach session docker compose down # Stop development environment (preserves volumes) docker compose down -v # Stop and remove development environment (including volumes) -docker compose restart # Restart all services without destroying containers or volumes -docker compose logs # View logs from all services -docker compose logs -f # Follow logs in real-time from all services -docker compose logs -f service_name # Follow logs for a specific service -docker compose ps # List running services and their status -docker compose exec service_name bash # Open a bash shell in a running service container ``` +For the full Docker Compose command reference and common workflows, see the `docker-compose` skill. + {%- endif %} ## Best Practices @@ -214,92 +214,17 @@ def process_users_bad(users: list[dict], config: dict) -> list: ### Settings -* Manage application settings with the `pydantic-settings` library. -* The main Settings class is located in `PACKAGE_NAME/conf/settings.py` - update this existing class rather than creating new ones. -* Sensitive configuration data must always use Pydantic `SecretStr` or `SecretBytes` types. -* Settings that are allowed to be unset must default to `None` instead of empty strings. -* Define settings with the Pydantic `Field` function and include descriptions for users. +Manage application settings with `pydantic-settings` in `{{cookiecutter.__package_slug}}/conf/settings.py` — update the existing class, never create a new one. Use `SecretStr` / `SecretBytes` for sensitive data. Optional settings default to `None`, never `""`. All fields use `Field(description=...)`. -```python -# File: {{cookiecutter.__package_slug}}/conf/settings.py -from pydantic import Field, SecretStr -from pydantic_settings import BaseSettings, SettingsConfigDict - -class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - ) - - project_name: str = Field(default="MyProject", description="Project name") - - # Good: Using SecretStr for sensitive data - database_password: SecretStr = Field( - description="Database password" - ) - - # Good: Optional field defaults to None - api_key: str | None = Field( - default=None, - description="Optional API key for external service" - ) - - # Good: Using Field with description - max_connections: int = Field( - default=10, - description="Maximum number of database connections" - ) -``` +See the `pydantic-settings` skill for full conventions and examples. {%- if cookiecutter.include_fastapi == "y" %} ### FastAPI -* APIs must adhere as closely as possible to REST principles, including appropriate use of GET/PUT/POST/DELETE HTTP verbs. -* All routes must use Pydantic models for input and output. -* Use different Pydantic models for inputs and outputs (i.e., creating a `Post` must require a `PostCreate` and return a `PostRead` model, not reuse the same model). -* Parameters in Pydantic models for user input must use the Field function with validation and descriptions. - -```python -from uuid import UUID - -from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel, Field - -router = APIRouter() - -class PostCreate(BaseModel): - title: str = Field(min_length=1, max_length=200, description="Post title") - content: str = Field(min_length=1, description="Post content") - -class PostRead(BaseModel): - id: UUID - title: str - content: str - created_at: str +Follow REST principles with appropriate HTTP verbs (GET/POST/PUT/DELETE). Use separate Pydantic models for input and output (`PostCreate` / `PostRead` / `PostUpdate`). Never reuse the same model. All input model fields use `Field()` with validation and a description. -class PostUpdate(BaseModel): - title: str | None = Field(default=None, max_length=200) - content: str | None = None - -@router.post("/posts", response_model=PostRead, status_code=status.HTTP_201_CREATED) -async def create_post(post: PostCreate) -> PostRead: - # Use different model for input (PostCreate) and output (PostRead) - pass - -@router.get("/posts/{post_id}", response_model=PostRead) -async def get_post(post_id: UUID) -> PostRead: - pass - -@router.put("/posts/{post_id}", response_model=PostRead) -async def update_post(post_id: UUID, post: PostUpdate) -> PostRead: - pass - -@router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_post(post_id: UUID) -> None: - pass -``` +See the `fastapi-routes` skill for full conventions, the router pattern, and code examples. {%- endif %} @@ -307,47 +232,9 @@ async def delete_post(post_id: UUID) -> None: ### SQLAlchemy -* Always use async SQLAlchemy APIs with SQLAlchemy 2.0 syntax. -* Represent database tables with the declarative class system. -* Use Alembic to define migrations. -* Migrations must be compatible with both SQLite and PostgreSQL. -* When creating queries, do not use implicit `and`: instead use the `and_` function (instead of `where(Model.parameter_a == A, Model.parameter_b == B)` do `where(and_(Model.parameter_a == A, Model.parameter_b == B))`). +Use async SQLAlchemy 2.0 with the declarative class system. Models live in `{{cookiecutter.__package_slug}}/models/`. Use Alembic for all schema changes (migrations must work on both SQLite and PostgreSQL). Always use explicit `and_()` in queries — no implicit AND. -```python -from uuid import UUID, uuid4 - -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, mapped_column - -from {{cookiecutter.__package_slug}}.models.base import Base - -class User(Base): - __tablename__ = "users" - - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - email: Mapped[str] = mapped_column(unique=True) - name: Mapped[str] - is_active: Mapped[bool] = mapped_column(default=True) - -# Good: Async query with explicit and_() -async def get_active_user(session: AsyncSession, email: str, name: str) -> User | None: - stmt = select(User).where( - and_( - User.email == email, - User.name == name, - User.is_active == True - ) - ) - result = await session.execute(stmt) - return result.scalar_one_or_none() - -# Bad: Implicit and (avoid this) -async def get_user_bad(session: AsyncSession, email: str, name: str) -> User | None: - stmt = select(User).where(User.email == email, User.name == name) - result = await session.execute(stmt) - return result.scalar_one_or_none() -``` +See the `sqlalchemy-models` skill for full conventions, model patterns, migration commands, and SQLite/PostgreSQL compatibility notes. {%- endif %} @@ -355,54 +242,17 @@ async def get_user_bad(session: AsyncSession, email: str, name: str) -> User | N ### Typer -* Any CLI command or script that must be accessible to users must be exposed via the Typer library. -* The main CLI entrypoint must be `PACKAGE_NAME/cli.py`. -* For async commands, use the `@syncify` decorator provided in `cli.py` to convert async functions to sync for Typer compatibility. +Expose user-facing commands via Typer. The main CLI entrypoint is `{{cookiecutter.__package_slug}}/cli.py`. Use the `@syncify` decorator (from `{{cookiecutter.__package_slug}}/cli.py`) for async commands — never use `asyncio.run()` directly. Use `Annotated[]` for all arguments and options. -```python -import typer -from typing import Annotated - -from {{cookiecutter.__package_slug}}.cli import syncify - -app = typer.Typer() - -@app.command() -def process( - input_file: Annotated[str, typer.Argument(help="Path to input file")], - output_file: Annotated[str | None, typer.Option(help="Path to output file")] = None, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, -) -> None: - """Process the input file and generate output.""" - if verbose: - typer.echo(f"Processing {input_file}...") - # Processing logic here - typer.echo("Done!") - -@app.command() -@syncify -async def fetch( - url: Annotated[str, typer.Argument(help="URL to fetch data from")], -) -> None: - """Fetch data from a URL asynchronously.""" - # Async operations here (database queries, HTTP requests, etc.) - typer.echo(f"Fetching from {url}") - -if __name__ == "__main__": - app() -``` +See the `typer-cli` skill for full patterns and examples. {%- endif %} ### Testing -* Do not wrap test functions in classes unless there is a specific technical reason: instead prefer single functions. -* All fixtures must be defined or imported in `conftest.py` so they are available to all tests. -* Do not use mocks to replace simple dataclasses or Pydantic models unless absolutely necessary: instead create an instance of the appropriate class with desired parameters. -* Use the FastAPI Test Client (preferably with a fixture) rather than calling FastAPI router classes directly. -* Use a test database fixture with memory-backed SQLite for tests requiring a database. Including a dependency override for this test database as part of the FastAPI App fixture is extremely useful. -* When adding new code, you must also add appropriate tests to cover that new code. -* The test suite file structure must mirror the main code file structure. +Prefer standalone test functions over test classes. All fixtures must be in `conftest.py`. Test file structure mirrors the main code structure. Do not mock simple dataclasses or Pydantic models — construct real instances. When adding new code, add tests to cover it. + +See the `python-testing` skill for FastAPI test client patterns, memory SQLite database fixtures, and testing conventions. ### Files @@ -411,12 +261,12 @@ if __name__ == "__main__": * Developer documentation must live in `docs/dev`. * New developer documents must be added to the table of contents in `docs/dev/README.md`. * Files only meant for building containers must live in the `docker/` folder. -* Database models must live in `PACKAGE_NAME/models/`. -* The primary settings file must live in `PACKAGE_NAME/conf/settings.py`. +* Database models must live in `{{cookiecutter.__package_slug}}/models/`. +* The primary settings file must live in `{{cookiecutter.__package_slug}}/conf/settings.py`. ### Developer Environments -* Common developer tasks must be defined in the `makefile` to easy reuse. +* Common developer tasks must be defined in the `makefile` to ease reuse. * Developers must always be able to start a fully functional developer instance with `docker compose up`. * Developer environments must be initialized with fake data for easy use. * Developer settings must live in the `.env` file, which must be in `.gitignore`. diff --git a/{{cookiecutter.__package_slug}}/tests/services/test_jinja.py b/{{cookiecutter.__package_slug}}/tests/services/test_jinja.py index a50a865..b29d5ef 100644 --- a/{{cookiecutter.__package_slug}}/tests/services/test_jinja.py +++ b/{{cookiecutter.__package_slug}}/tests/services/test_jinja.py @@ -1,8 +1,9 @@ """Tests for Jinja2 template service.""" import pytest from jinja2 import Environment +from jinja2.sandbox import SandboxedEnvironment, SecurityError from fastapi.templating import Jinja2Templates -from {{cookiecutter.__package_slug}}.services.jinja import env, response_templates +from {{cookiecutter.__package_slug}}.services.jinja import env, sandbox_env, response_templates class TestJinja2Environment: @@ -145,3 +146,57 @@ def test_response_templates_directory_configured(self): assert hasattr(response_templates, "env") # The loader should have the templates configured assert response_templates.env.loader is not None + + +class TestSandboxedEnvironment: + """Test sandboxed environment for untrusted templates.""" + + def test_sandbox_env_exists(self): + """Test that sandbox_env is properly instantiated.""" + assert sandbox_env is not None + assert isinstance(sandbox_env, SandboxedEnvironment) + + def test_sandbox_env_has_loader(self): + """Test that sandbox_env has a loader configured.""" + assert sandbox_env.loader is not None + + def test_sandbox_env_autoescape_enabled(self): + """Test that autoescape is enabled on sandbox_env.""" + assert sandbox_env.autoescape is True or callable(sandbox_env.autoescape) + + def test_sandbox_blocks_class_subclasses_chain(self): + """Test that the sandbox blocks __class__.__subclasses__ chain attacks.""" + template = sandbox_env.from_string("{% raw %}{{ func.__class__.__subclasses__ }}{% endraw %}") + with pytest.raises(SecurityError): + template.render(func=lambda: None) + + def test_sandbox_blocks_list_class_mro_chain(self): + """Test that the sandbox blocks __class__.__mro__ chain attacks.""" + template = sandbox_env.from_string("{% raw %}{{ items.__class__.__mro__ }}{% endraw %}") + with pytest.raises(SecurityError): + template.render(items=[1, 2, 3]) + + def test_sandbox_allows_safe_rendering(self): + """Test that the sandbox allows normal variable interpolation.""" + template = sandbox_env.from_string("{% raw %}Hello {{ name }}!{% endraw %}") + result = template.render(name="World") + assert result == "Hello World!" + + def test_sandbox_allows_control_structures(self): + """Test that the sandbox allows conditionals and loops.""" + template = sandbox_env.from_string("{% raw %}{% if show %}yes{% else %}no{% endif %}{% endraw %}") + assert template.render(show=True) == "yes" + assert template.render(show=False) == "no" + + def test_sandbox_allows_function_calls(self): + """Test that the sandbox allows calling functions passed to render.""" + template = sandbox_env.from_string("{% raw %}{{ greet(name) }}{% endraw %}") + result = template.render(greet=lambda n: f"Hello, {n}!", name="World") + assert result == "Hello, World!" + + def test_sandbox_silently_blocks_dunder_access(self): + """Test that the sandbox silently blocks single-level dunder access.""" + template = sandbox_env.from_string("{% raw %}{{ func.__class__ }}{% endraw %}") + result = template.render(func=lambda: None) + # Single-level dunder access returns empty string (safe default) + assert result == "" diff --git a/{{cookiecutter.__package_slug}}/tests/test_celery.py b/{{cookiecutter.__package_slug}}/tests/test_celery.py index f1f1df1..aaa5275 100644 --- a/{{cookiecutter.__package_slug}}/tests/test_celery.py +++ b/{{cookiecutter.__package_slug}}/tests/test_celery.py @@ -1,4 +1,5 @@ """Tests for Celery task queue configuration.""" +import logging import pytest from {{cookiecutter.__package_slug}}.celery import celery, hello_world @@ -39,6 +40,7 @@ def test_hello_world_task_name(): def test_hello_world_execution(caplog): """Test that hello_world task executes without error.""" + caplog.set_level(logging.INFO) # Run the task directly (not async) hello_world() diff --git a/{{cookiecutter.__package_slug}}/tests/test_qq.py b/{{cookiecutter.__package_slug}}/tests/test_qq.py index 692be71..566f232 100644 --- a/{{cookiecutter.__package_slug}}/tests/test_qq.py +++ b/{{cookiecutter.__package_slug}}/tests/test_qq.py @@ -1,4 +1,5 @@ """Tests for QuasiQueue configuration and functionality.""" +import logging import pytest from {{cookiecutter.__package_slug}}.qq import runner, writer, reader @@ -81,6 +82,7 @@ def test_reader_is_async(): @pytest.mark.asyncio async def test_reader_with_integer(caplog): """Test reader with an integer identifier.""" + caplog.set_level(logging.INFO) await reader(42) assert "42" in caplog.text @@ -88,6 +90,7 @@ async def test_reader_with_integer(caplog): @pytest.mark.asyncio async def test_reader_with_string(caplog): """Test reader with a string identifier.""" + caplog.set_level(logging.INFO) await reader("test_value") assert "test_value" in caplog.text @@ -95,6 +98,7 @@ async def test_reader_with_string(caplog): @pytest.mark.asyncio async def test_reader_prints_output(caplog): """Test that reader logs its identifier.""" + caplog.set_level(logging.INFO) test_id = "test_123" await reader(test_id) assert test_id in caplog.text diff --git a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/services/jinja.py b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/services/jinja.py index 95cf333..b70de62 100644 --- a/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/services/jinja.py +++ b/{{cookiecutter.__package_slug}}/{{cookiecutter.__package_slug}}/services/jinja.py @@ -2,12 +2,20 @@ from fastapi.templating import Jinja2Templates {%- endif %} from jinja2 import Environment, PackageLoader, select_autoescape +from jinja2.sandbox import SandboxedEnvironment env = Environment( loader=PackageLoader("{{cookiecutter.__package_slug}}"), autoescape=True, ) +# Sandboxed environment for untrusted templates (user content, third-party systems). +# Prevents access to Python internals, attribute traversal, and dangerous operations. +sandbox_env = SandboxedEnvironment( + loader=PackageLoader("{{cookiecutter.__package_slug}}"), + autoescape=True, +) + {%- if cookiecutter.include_fastapi == "y" %} response_templates = Jinja2Templates(directory="{{cookiecutter.__package_slug}}/templates")