Explorar el Código

refactor: Streamline python-expert agent with skill routing

- Reduce python-expert from 1,844 to 647 lines (65% reduction)
- Remove duplicated content (async, testing patterns now in skills)
- Add decision frameworks (async vs sync, dataclasses vs Pydantic)
- Add skill routing table for detailed patterns
- Keep unique content: profiling, stdlib, graceful shutdown

Add dependency metadata to all Python skills:
- python-env: foundation, no deps
- python-typing-patterns: foundation, no deps
- python-async-patterns: depends on typing
- python-cli-patterns: no deps
- python-pytest-patterns: no deps
- python-observability-patterns: depends on async
- python-database-patterns: depends on typing + async
- python-fastapi-patterns: depends on typing + async

Each skill now has:
- depends-on metadata in frontmatter
- related-skills metadata in frontmatter
- See Also section with cross-references

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0xDarkMatter hace 5 meses
padre
commit
8c04593bb1
Se han modificado 59 ficheros con 14628 adiciones y 1027 borrados
  1. 99 881
      agents/python-expert.md
  2. 160 0
      skills/python-async-patterns/SKILL.md
  3. 139 0
      skills/python-async-patterns/assets/async-project-template.py
  4. 259 0
      skills/python-async-patterns/references/aiohttp-patterns.md
  5. 277 0
      skills/python-async-patterns/references/concurrency-patterns.md
  6. 301 0
      skills/python-async-patterns/references/debugging-async.md
  7. 426 0
      skills/python-async-patterns/references/error-handling.md
  8. 271 0
      skills/python-async-patterns/references/mixing-sync-async.md
  9. 395 0
      skills/python-async-patterns/references/performance.md
  10. 370 0
      skills/python-async-patterns/references/production-patterns.md
  11. 46 0
      skills/python-async-patterns/scripts/find-blocking-calls.sh
  12. 171 0
      skills/python-cli-patterns/SKILL.md
  13. 256 0
      skills/python-cli-patterns/assets/cli-template.py
  14. 294 0
      skills/python-cli-patterns/references/configuration.md
  15. 293 0
      skills/python-cli-patterns/references/rich-output.md
  16. 303 0
      skills/python-cli-patterns/references/typer-patterns.md
  17. 184 0
      skills/python-database-patterns/SKILL.md
  18. 59 0
      skills/python-database-patterns/assets/alembic.ini.template
  19. 297 0
      skills/python-database-patterns/references/connection-pooling.md
  20. 342 0
      skills/python-database-patterns/references/migrations.md
  21. 299 0
      skills/python-database-patterns/references/sqlalchemy-async.md
  22. 286 0
      skills/python-database-patterns/references/transactions.md
  23. 33 146
      skills/python-env/SKILL.md
  24. 297 0
      skills/python-env/references/dependency-management.md
  25. 270 0
      skills/python-env/references/publishing.md
  26. 334 0
      skills/python-env/references/pyproject-patterns.md
  27. 206 0
      skills/python-fastapi-patterns/SKILL.md
  28. 180 0
      skills/python-fastapi-patterns/assets/fastapi-template.py
  29. 324 0
      skills/python-fastapi-patterns/references/background-tasks.md
  30. 301 0
      skills/python-fastapi-patterns/references/dependency-injection.md
  31. 321 0
      skills/python-fastapi-patterns/references/middleware-patterns.md
  32. 320 0
      skills/python-fastapi-patterns/references/validation-serialization.md
  33. 122 0
      skills/python-fastapi-patterns/scripts/scaffold-api.sh
  34. 186 0
      skills/python-observability-patterns/SKILL.md
  35. 114 0
      skills/python-observability-patterns/assets/logging-config.py
  36. 328 0
      skills/python-observability-patterns/references/metrics.md
  37. 299 0
      skills/python-observability-patterns/references/structured-logging.md
  38. 281 0
      skills/python-observability-patterns/references/tracing.md
  39. 201 0
      skills/python-pytest-patterns/SKILL.md
  40. 203 0
      skills/python-pytest-patterns/assets/conftest.py.template
  41. 50 0
      skills/python-pytest-patterns/assets/pytest.ini.template
  42. 298 0
      skills/python-pytest-patterns/references/async-testing.md
  43. 300 0
      skills/python-pytest-patterns/references/coverage-strategies.md
  44. 221 0
      skills/python-pytest-patterns/references/fixtures-advanced.md
  45. 338 0
      skills/python-pytest-patterns/references/integration-testing.md
  46. 303 0
      skills/python-pytest-patterns/references/mocking-patterns.md
  47. 332 0
      skills/python-pytest-patterns/references/property-testing.md
  48. 366 0
      skills/python-pytest-patterns/references/test-architecture.md
  49. 229 0
      skills/python-pytest-patterns/scripts/generate-conftest.sh
  50. 90 0
      skills/python-pytest-patterns/scripts/run-tests.sh
  51. 232 0
      skills/python-typing-patterns/SKILL.md
  52. 117 0
      skills/python-typing-patterns/assets/pyproject-typing.toml
  53. 282 0
      skills/python-typing-patterns/references/generics-advanced.md
  54. 317 0
      skills/python-typing-patterns/references/mypy-config.md
  55. 271 0
      skills/python-typing-patterns/references/overloads.md
  56. 316 0
      skills/python-typing-patterns/references/protocols-patterns.md
  57. 297 0
      skills/python-typing-patterns/references/runtime-validation.md
  58. 271 0
      skills/python-typing-patterns/references/type-narrowing.md
  59. 151 0
      skills/python-typing-patterns/scripts/check-types.sh

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 99 - 881
agents/python-expert.md


+ 160 - 0
skills/python-async-patterns/SKILL.md

@@ -0,0 +1,160 @@
+---
+name: python-async-patterns
+description: "Python asyncio patterns for concurrent programming. Triggers on: asyncio, async, await, coroutine, gather, semaphore, TaskGroup, event loop, aiohttp, concurrent."
+compatibility: "Python 3.10+ recommended. Some patterns require 3.11+ (TaskGroup, timeout)."
+allowed-tools: "Read Write"
+depends-on: [python-typing-patterns]
+related-skills: [python-fastapi-patterns, python-observability-patterns]
+---
+
+# Python Async Patterns
+
+Asyncio patterns for concurrent Python programming.
+
+## Core Concepts
+
+```python
+import asyncio
+
+# Coroutine (must be awaited)
+async def fetch(url: str) -> str:
+    async with aiohttp.ClientSession() as session:
+        async with session.get(url) as response:
+            return await response.text()
+
+# Entry point
+async def main():
+    result = await fetch("https://example.com")
+    return result
+
+asyncio.run(main())
+```
+
+## Pattern 1: Concurrent with gather
+
+```python
+async def fetch_all(urls: list[str]) -> list[str]:
+    """Fetch multiple URLs concurrently."""
+    async with aiohttp.ClientSession() as session:
+        tasks = [fetch_one(session, url) for url in urls]
+        return await asyncio.gather(*tasks, return_exceptions=True)
+```
+
+## Pattern 2: Bounded Concurrency
+
+```python
+async def fetch_with_limit(urls: list[str], limit: int = 10):
+    """Limit concurrent requests."""
+    semaphore = asyncio.Semaphore(limit)
+
+    async def bounded_fetch(url):
+        async with semaphore:
+            return await fetch_one(url)
+
+    return await asyncio.gather(*[bounded_fetch(url) for url in urls])
+```
+
+## Pattern 3: TaskGroup (Python 3.11+)
+
+```python
+async def process_items(items):
+    """Structured concurrency with automatic cleanup."""
+    async with asyncio.TaskGroup() as tg:
+        for item in items:
+            tg.create_task(process_one(item))
+    # All tasks complete here, or exception raised
+```
+
+## Pattern 4: Timeout
+
+```python
+async def with_timeout():
+    try:
+        async with asyncio.timeout(5.0):  # Python 3.11+
+            result = await slow_operation()
+    except asyncio.TimeoutError:
+        result = None
+    return result
+```
+
+## Critical Warnings
+
+```python
+# WRONG - blocks event loop
+async def bad():
+    time.sleep(5)         # Never use time.sleep!
+    requests.get(url)     # Blocking I/O!
+
+# CORRECT
+async def good():
+    await asyncio.sleep(5)
+    async with aiohttp.ClientSession() as s:
+        await s.get(url)
+```
+
+```python
+# WRONG - orphaned task
+async def bad():
+    asyncio.create_task(work())  # May be garbage collected!
+
+# CORRECT - keep reference
+async def good():
+    task = asyncio.create_task(work())
+    await task
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `gather(*tasks)` | Multiple independent operations |
+| `Semaphore(n)` | Rate limiting, resource constraints |
+| `TaskGroup()` | Structured concurrency (3.11+) |
+| `Queue()` | Producer-consumer |
+| `timeout(s)` | Timeout wrapper (3.11+) |
+| `Lock()` | Shared mutable state |
+
+## Async Context Manager
+
+```python
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def managed_connection():
+    conn = await create_connection()
+    try:
+        yield conn
+    finally:
+        await conn.close()
+```
+
+## Additional Resources
+
+For detailed patterns, load:
+- `./references/concurrency-patterns.md` - Queue, Lock, producer-consumer
+- `./references/aiohttp-patterns.md` - HTTP client/server patterns
+- `./references/mixing-sync-async.md` - run_in_executor, thread pools
+- `./references/debugging-async.md` - Debug mode, profiling, finding issues
+- `./references/production-patterns.md` - Graceful shutdown, health checks, signal handling
+- `./references/error-handling.md` - Retry with backoff, circuit breakers, partial failures
+- `./references/performance.md` - uvloop, connection pooling, buffer sizing
+
+## Scripts
+
+- `./scripts/find-blocking-calls.sh` - Scan code for blocking calls in async functions
+
+## Assets
+
+- `./assets/async-project-template.py` - Production-ready async app skeleton
+
+---
+
+## See Also
+
+**Prerequisites:**
+- `python-typing-patterns` - Type hints for async functions
+
+**Related Skills:**
+- `python-fastapi-patterns` - Async web APIs
+- `python-observability-patterns` - Async logging and tracing
+- `python-database-patterns` - Async database access

+ 139 - 0
skills/python-async-patterns/assets/async-project-template.py

@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+"""
+Async Python Project Template
+
+Production-ready async application structure with:
+- Proper session management
+- Graceful shutdown
+- Structured concurrency
+- Error handling
+- Logging
+"""
+
+import asyncio
+import logging
+import signal
+from contextlib import asynccontextmanager
+from typing import Any
+
+import aiohttp
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+)
+logger = logging.getLogger(__name__)
+
+
+class AsyncApp:
+    """Main application class with lifecycle management."""
+
+    def __init__(self):
+        self.session: aiohttp.ClientSession | None = None
+        self.running = False
+        self._tasks: set[asyncio.Task] = set()
+
+    async def start(self):
+        """Initialize resources."""
+        logger.info("Starting application...")
+        self.session = aiohttp.ClientSession(
+            timeout=aiohttp.ClientTimeout(total=30),
+            connector=aiohttp.TCPConnector(limit=100),
+        )
+        self.running = True
+        logger.info("Application started")
+
+    async def stop(self):
+        """Cleanup resources."""
+        logger.info("Stopping application...")
+        self.running = False
+
+        # Cancel background tasks
+        for task in self._tasks:
+            task.cancel()
+        if self._tasks:
+            await asyncio.gather(*self._tasks, return_exceptions=True)
+
+        # Close session
+        if self.session:
+            await self.session.close()
+
+        logger.info("Application stopped")
+
+    def create_task(self, coro) -> asyncio.Task:
+        """Create a tracked background task."""
+        task = asyncio.create_task(coro)
+        self._tasks.add(task)
+        task.add_done_callback(self._tasks.discard)
+        return task
+
+    async def fetch(self, url: str) -> dict[str, Any] | None:
+        """Fetch URL with error handling."""
+        if not self.session:
+            raise RuntimeError("App not started")
+
+        try:
+            async with self.session.get(url) as response:
+                response.raise_for_status()
+                return await response.json()
+        except aiohttp.ClientError as e:
+            logger.error(f"Request failed: {e}")
+            return None
+
+    async def fetch_many(
+        self,
+        urls: list[str],
+        concurrency: int = 10
+    ) -> list[dict[str, Any] | None]:
+        """Fetch multiple URLs with bounded concurrency."""
+        semaphore = asyncio.Semaphore(concurrency)
+
+        async def bounded_fetch(url: str):
+            async with semaphore:
+                return await self.fetch(url)
+
+        return await asyncio.gather(*[bounded_fetch(url) for url in urls])
+
+
+@asynccontextmanager
+async def create_app():
+    """Context manager for app lifecycle."""
+    app = AsyncApp()
+    try:
+        await app.start()
+        yield app
+    finally:
+        await app.stop()
+
+
+async def main():
+    """Main entry point."""
+    # Setup signal handlers for graceful shutdown
+    loop = asyncio.get_running_loop()
+    stop_event = asyncio.Event()
+
+    def signal_handler():
+        logger.info("Received shutdown signal")
+        stop_event.set()
+
+    for sig in (signal.SIGTERM, signal.SIGINT):
+        loop.add_signal_handler(sig, signal_handler)
+
+    async with create_app() as app:
+        # Example: Fetch some URLs
+        urls = [
+            "https://httpbin.org/json",
+            "https://httpbin.org/uuid",
+        ]
+
+        results = await app.fetch_many(urls)
+        for url, result in zip(urls, results):
+            logger.info(f"{url}: {result}")
+
+        # Keep running until shutdown signal
+        # await stop_event.wait()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 259 - 0
skills/python-async-patterns/references/aiohttp-patterns.md

@@ -0,0 +1,259 @@
+# aiohttp Patterns
+
+HTTP client and server patterns with aiohttp.
+
+## Client Session Best Practices
+
+```python
+import aiohttp
+
+# WRONG - creates session per request
+async def bad_fetch(url):
+    async with aiohttp.ClientSession() as session:
+        async with session.get(url) as response:
+            return await response.text()
+
+# CORRECT - reuse session
+async def fetch_all(urls: list[str]) -> list[str]:
+    async with aiohttp.ClientSession() as session:
+        tasks = [fetch_one(session, url) for url in urls]
+        return await asyncio.gather(*tasks)
+
+async def fetch_one(session: aiohttp.ClientSession, url: str) -> str:
+    async with session.get(url) as response:
+        return await response.text()
+```
+
+## Connection Pooling
+
+```python
+import aiohttp
+
+# Configure connection pool
+connector = aiohttp.TCPConnector(
+    limit=100,           # Max connections
+    limit_per_host=10,   # Max per host
+    ttl_dns_cache=300,   # DNS cache TTL
+)
+
+async with aiohttp.ClientSession(connector=connector) as session:
+    # Use session
+    pass
+```
+
+## Timeout Configuration
+
+```python
+import aiohttp
+
+timeout = aiohttp.ClientTimeout(
+    total=30,        # Total timeout
+    connect=10,      # Connection timeout
+    sock_read=10,    # Read timeout
+    sock_connect=10, # Socket connect timeout
+)
+
+async with aiohttp.ClientSession(timeout=timeout) as session:
+    async with session.get(url) as response:
+        return await response.text()
+```
+
+## Request Methods
+
+```python
+async with aiohttp.ClientSession() as session:
+    # GET
+    async with session.get(url, params={'key': 'value'}) as r:
+        data = await r.json()
+
+    # POST JSON
+    async with session.post(url, json={'key': 'value'}) as r:
+        data = await r.json()
+
+    # POST form data
+    async with session.post(url, data={'key': 'value'}) as r:
+        data = await r.text()
+
+    # PUT
+    async with session.put(url, json={'key': 'value'}) as r:
+        pass
+
+    # DELETE
+    async with session.delete(url) as r:
+        pass
+
+    # With headers
+    headers = {'Authorization': 'Bearer token'}
+    async with session.get(url, headers=headers) as r:
+        pass
+```
+
+## Response Handling
+
+```python
+async with session.get(url) as response:
+    # Status
+    print(response.status)  # 200
+    print(response.reason)  # OK
+
+    # Headers
+    print(response.headers['Content-Type'])
+
+    # Body
+    text = await response.text()
+    json_data = await response.json()
+    bytes_data = await response.read()
+
+    # Streaming
+    async for chunk in response.content.iter_chunked(1024):
+        process(chunk)
+```
+
+## Error Handling
+
+```python
+import aiohttp
+
+async def safe_fetch(session, url):
+    try:
+        async with session.get(url) as response:
+            response.raise_for_status()
+            return await response.json()
+    except aiohttp.ClientResponseError as e:
+        print(f"HTTP error: {e.status}")
+    except aiohttp.ClientConnectionError:
+        print("Connection error")
+    except aiohttp.ClientTimeout:
+        print("Request timed out")
+    except Exception as e:
+        print(f"Unexpected error: {e}")
+    return None
+```
+
+## Retry with Backoff
+
+```python
+async def fetch_with_retry(
+    session: aiohttp.ClientSession,
+    url: str,
+    max_retries: int = 3
+) -> dict | None:
+    for attempt in range(max_retries):
+        try:
+            async with session.get(url) as response:
+                response.raise_for_status()
+                return await response.json()
+        except (aiohttp.ClientError, asyncio.TimeoutError):
+            if attempt == max_retries - 1:
+                raise
+            await asyncio.sleep(2 ** attempt)  # Exponential backoff
+    return None
+```
+
+## File Upload
+
+```python
+async def upload_file(session, url, file_path):
+    with open(file_path, 'rb') as f:
+        data = aiohttp.FormData()
+        data.add_field('file', f, filename='upload.txt')
+        async with session.post(url, data=data) as response:
+            return await response.json()
+```
+
+## File Download
+
+```python
+async def download_file(session, url, dest_path):
+    async with session.get(url) as response:
+        with open(dest_path, 'wb') as f:
+            async for chunk in response.content.iter_chunked(8192):
+                f.write(chunk)
+```
+
+## WebSocket Client
+
+```python
+async def websocket_client(url):
+    async with aiohttp.ClientSession() as session:
+        async with session.ws_connect(url) as ws:
+            # Send message
+            await ws.send_str("Hello")
+
+            # Receive messages
+            async for msg in ws:
+                if msg.type == aiohttp.WSMsgType.TEXT:
+                    print(f"Received: {msg.data}")
+                elif msg.type == aiohttp.WSMsgType.ERROR:
+                    break
+```
+
+## Simple aiohttp Server
+
+```python
+from aiohttp import web
+
+async def handle_get(request):
+    name = request.match_info.get('name', 'World')
+    return web.json_response({'message': f'Hello, {name}'})
+
+async def handle_post(request):
+    data = await request.json()
+    return web.json_response({'received': data})
+
+app = web.Application()
+app.router.add_get('/', handle_get)
+app.router.add_get('/{name}', handle_get)
+app.router.add_post('/data', handle_post)
+
+if __name__ == '__main__':
+    web.run_app(app, port=8080)
+```
+
+## Server Middleware
+
+```python
+from aiohttp import web
+
+@web.middleware
+async def error_middleware(request, handler):
+    try:
+        response = await handler(request)
+        return response
+    except web.HTTPException:
+        raise
+    except Exception as e:
+        return web.json_response(
+            {'error': str(e)},
+            status=500
+        )
+
+@web.middleware
+async def logging_middleware(request, handler):
+    print(f"{request.method} {request.path}")
+    response = await handler(request)
+    print(f"Response: {response.status}")
+    return response
+
+app = web.Application(middlewares=[logging_middleware, error_middleware])
+```
+
+## Session State
+
+```python
+from aiohttp import web
+
+async def init_db(app):
+    app['db'] = await create_db_pool()
+
+async def cleanup_db(app):
+    await app['db'].close()
+
+app = web.Application()
+app.on_startup.append(init_db)
+app.on_cleanup.append(cleanup_db)
+
+async def handler(request):
+    db = request.app['db']
+    # Use db connection
+```

+ 277 - 0
skills/python-async-patterns/references/concurrency-patterns.md

@@ -0,0 +1,277 @@
+# Async Concurrency Patterns
+
+Advanced concurrency patterns for Python asyncio.
+
+## Producer-Consumer with Queue
+
+```python
+import asyncio
+
+async def producer(queue: asyncio.Queue, items):
+    """Produce items to queue."""
+    for item in items:
+        await queue.put(item)
+    await queue.put(None)  # Sentinel to signal completion
+
+async def consumer(queue: asyncio.Queue, name: str):
+    """Consume items from queue."""
+    while True:
+        item = await queue.get()
+        if item is None:
+            queue.task_done()
+            break
+        await process(item)
+        queue.task_done()
+
+async def main():
+    queue = asyncio.Queue(maxsize=100)  # Backpressure
+
+    # Run producer and multiple consumers
+    await asyncio.gather(
+        producer(queue, items),
+        consumer(queue, "worker-1"),
+        consumer(queue, "worker-2"),
+        consumer(queue, "worker-3"),
+    )
+```
+
+## Sharing State with Lock
+
+```python
+import asyncio
+
+# WRONG - race condition even in async!
+counter = 0
+async def increment():
+    global counter
+    temp = counter
+    await asyncio.sleep(0)  # Context switch point!
+    counter = temp + 1
+
+# CORRECT - use Lock
+lock = asyncio.Lock()
+
+async def safe_increment():
+    global counter
+    async with lock:
+        counter += 1
+```
+
+## Event Signaling
+
+```python
+import asyncio
+
+async def waiter(event: asyncio.Event):
+    print("Waiting for event...")
+    await event.wait()
+    print("Event received!")
+
+async def setter(event: asyncio.Event):
+    await asyncio.sleep(2)
+    event.set()
+    print("Event set!")
+
+async def main():
+    event = asyncio.Event()
+    await asyncio.gather(
+        waiter(event),
+        setter(event),
+    )
+```
+
+## Condition Variable
+
+```python
+import asyncio
+
+async def consumer(condition: asyncio.Condition, data: list):
+    async with condition:
+        await condition.wait_for(lambda: len(data) > 0)
+        item = data.pop(0)
+        return item
+
+async def producer(condition: asyncio.Condition, data: list):
+    async with condition:
+        data.append("new item")
+        condition.notify()
+```
+
+## Barrier (Python 3.11+)
+
+```python
+import asyncio
+
+async def worker(barrier: asyncio.Barrier, name: str):
+    print(f"{name}: Starting work")
+    await asyncio.sleep(1)
+    print(f"{name}: Waiting at barrier")
+    await barrier.wait()
+    print(f"{name}: Continuing after barrier")
+
+async def main():
+    barrier = asyncio.Barrier(3)
+    await asyncio.gather(
+        worker(barrier, "A"),
+        worker(barrier, "B"),
+        worker(barrier, "C"),
+    )
+```
+
+## Cancellation Handling
+
+```python
+async def cancellable_task():
+    try:
+        while True:
+            await do_work()
+    except asyncio.CancelledError:
+        # Cleanup on cancellation
+        await cleanup()
+        raise  # Re-raise to propagate
+
+# Cancel a task
+task = asyncio.create_task(cancellable_task())
+task.cancel()
+try:
+    await task
+except asyncio.CancelledError:
+    print("Task was cancelled")
+```
+
+## Task Completion Callbacks
+
+```python
+def on_complete(task: asyncio.Task):
+    if task.exception():
+        print(f"Task failed: {task.exception()}")
+    else:
+        print(f"Task result: {task.result()}")
+
+task = asyncio.create_task(some_work())
+task.add_done_callback(on_complete)
+```
+
+## Running Tasks in Background
+
+```python
+# Keep track of background tasks
+background_tasks = set()
+
+async def start_background_task(coro):
+    task = asyncio.create_task(coro)
+    background_tasks.add(task)
+    task.add_done_callback(background_tasks.discard)
+    return task
+
+async def cleanup():
+    for task in background_tasks:
+        task.cancel()
+    await asyncio.gather(*background_tasks, return_exceptions=True)
+```
+
+## Async Iterator/Generator
+
+```python
+async def async_range(n: int):
+    """Async generator."""
+    for i in range(n):
+        await asyncio.sleep(0.1)
+        yield i
+
+# Usage
+async for value in async_range(10):
+    print(value)
+
+# Async comprehension
+results = [x async for x in async_range(10)]
+```
+
+## Streaming with AsyncIterator
+
+```python
+class AsyncIterator:
+    def __init__(self, items):
+        self.items = items
+        self.index = 0
+
+    def __aiter__(self):
+        return self
+
+    async def __anext__(self):
+        if self.index >= len(self.items):
+            raise StopAsyncIteration
+        item = self.items[self.index]
+        self.index += 1
+        await asyncio.sleep(0)  # Yield control
+        return item
+```
+
+## Shield from Cancellation
+
+```python
+async def critical_operation():
+    # This operation must complete even if outer task is cancelled
+    try:
+        result = await asyncio.shield(important_work())
+    except asyncio.CancelledError:
+        # Shield was cancelled, but important_work continues
+        result = await important_work()  # Wait for it
+    return result
+```
+
+## Wait with First Completed
+
+```python
+async def first_response(urls: list[str]):
+    """Return first successful response."""
+    tasks = [asyncio.create_task(fetch(url)) for url in urls]
+
+    done, pending = await asyncio.wait(
+        tasks,
+        return_when=asyncio.FIRST_COMPLETED
+    )
+
+    # Cancel remaining tasks
+    for task in pending:
+        task.cancel()
+
+    return done.pop().result()
+```
+
+## Debounce Pattern
+
+```python
+class Debouncer:
+    def __init__(self, delay: float):
+        self.delay = delay
+        self.task: asyncio.Task | None = None
+
+    async def debounce(self, coro):
+        if self.task:
+            self.task.cancel()
+            try:
+                await self.task
+            except asyncio.CancelledError:
+                pass
+
+        async def delayed():
+            await asyncio.sleep(self.delay)
+            await coro
+
+        self.task = asyncio.create_task(delayed())
+```
+
+## Retry Pattern
+
+```python
+async def retry(coro_func, max_retries: int = 3, delay: float = 1.0):
+    """Retry coroutine with exponential backoff."""
+    for attempt in range(max_retries):
+        try:
+            return await coro_func()
+        except Exception as e:
+            if attempt == max_retries - 1:
+                raise
+            await asyncio.sleep(delay * (2 ** attempt))
+```

+ 301 - 0
skills/python-async-patterns/references/debugging-async.md

@@ -0,0 +1,301 @@
+# Debugging Async Python
+
+Techniques for debugging asyncio applications.
+
+## Enable Debug Mode
+
+```python
+import asyncio
+
+# Option 1: Environment variable
+# PYTHONASYNCIODEBUG=1 python script.py
+
+# Option 2: In code
+asyncio.run(main(), debug=True)
+
+# Option 3: On running loop
+loop = asyncio.get_running_loop()
+loop.set_debug(True)
+```
+
+## Debug Mode Features
+
+When enabled:
+- Slow callbacks (>100ms) are logged
+- Unawaited coroutines are detected
+- Resource warnings for unclosed resources
+- More detailed tracebacks
+
+## Finding Slow Callbacks
+
+```python
+import asyncio
+import logging
+
+# Enable asyncio debug logging
+logging.getLogger("asyncio").setLevel(logging.DEBUG)
+
+# Custom slow callback threshold
+loop = asyncio.get_event_loop()
+loop.slow_callback_duration = 0.05  # 50ms
+```
+
+## Detecting Unawaited Coroutines
+
+```python
+import warnings
+warnings.filterwarnings("error", category=RuntimeWarning)
+
+# Now this will raise instead of warn:
+async def main():
+    some_coroutine()  # RuntimeWarning -> Exception!
+```
+
+## Task Introspection
+
+```python
+import asyncio
+
+async def debug_tasks():
+    # Get all tasks
+    all_tasks = asyncio.all_tasks()
+    print(f"Total tasks: {len(all_tasks)}")
+
+    for task in all_tasks:
+        print(f"Task: {task.get_name()}")
+        print(f"  Done: {task.done()}")
+        print(f"  Cancelled: {task.cancelled()}")
+
+        # Get stack
+        if not task.done():
+            stack = task.get_stack()
+            for frame in stack:
+                print(f"  {frame}")
+
+# Get current task
+current = asyncio.current_task()
+```
+
+## Tracing Coroutines
+
+```python
+import sys
+
+def trace_coroutines(frame, event, arg):
+    if event == "call" and frame.f_code.co_flags & 0x80:  # CO_COROUTINE
+        print(f"Coroutine called: {frame.f_code.co_name}")
+    return trace_coroutines
+
+sys.settrace(trace_coroutines)
+```
+
+## asyncio Debug Logger
+
+```python
+import logging
+
+# Detailed asyncio logging
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger("asyncio")
+logger.setLevel(logging.DEBUG)
+
+# Custom handler
+handler = logging.StreamHandler()
+handler.setFormatter(logging.Formatter(
+    "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+))
+logger.addHandler(handler)
+```
+
+## Profiling Async Code
+
+### With cProfile
+
+```python
+import asyncio
+import cProfile
+import pstats
+
+async def main():
+    await some_work()
+
+# Profile
+profiler = cProfile.Profile()
+profiler.enable()
+asyncio.run(main())
+profiler.disable()
+
+# Print stats
+stats = pstats.Stats(profiler)
+stats.sort_stats("cumtime")
+stats.print_stats(20)
+```
+
+### With yappi (async-aware)
+
+```python
+import yappi
+import asyncio
+
+yappi.set_clock_type("wall")  # or "cpu"
+yappi.start()
+
+asyncio.run(main())
+
+yappi.stop()
+
+# Get stats for coroutines
+func_stats = yappi.get_func_stats()
+func_stats.print_all()
+
+# Async-specific stats
+asyncio_stats = yappi.get_func_stats(
+    filter_callback=lambda x: asyncio.iscoroutinefunction(x.full_name)
+)
+```
+
+## Finding Memory Leaks
+
+```python
+import asyncio
+import gc
+import tracemalloc
+
+tracemalloc.start()
+
+async def main():
+    # ... your code ...
+    pass
+
+asyncio.run(main())
+
+# Get memory snapshot
+snapshot = tracemalloc.take_snapshot()
+top_stats = snapshot.statistics("lineno")
+
+print("Top 10 memory allocations:")
+for stat in top_stats[:10]:
+    print(stat)
+
+# Find leaking tasks
+gc.collect()
+for obj in gc.get_objects():
+    if isinstance(obj, asyncio.Task):
+        print(f"Leaked task: {obj}")
+```
+
+## Common Issues and Solutions
+
+### Issue: "Task was destroyed but it is pending"
+
+```python
+# WRONG
+async def bad():
+    asyncio.create_task(background_work())  # Orphaned!
+
+# CORRECT
+background_tasks = set()
+
+async def good():
+    task = asyncio.create_task(background_work())
+    background_tasks.add(task)
+    task.add_done_callback(background_tasks.discard)
+```
+
+### Issue: "Event loop is closed"
+
+```python
+# WRONG - reusing closed loop
+loop = asyncio.get_event_loop()
+loop.run_until_complete(coro1())
+loop.close()
+loop.run_until_complete(coro2())  # Error!
+
+# CORRECT - use asyncio.run()
+asyncio.run(coro1())
+asyncio.run(coro2())  # New loop each time
+```
+
+### Issue: "Cannot schedule new futures after shutdown"
+
+```python
+# Happens when creating tasks during shutdown
+async def cleanup():
+    # DON'T create new tasks here
+    await existing_task
+```
+
+### Issue: Hung program (blocked event loop)
+
+```python
+# Find the blocking call
+import asyncio
+
+async def debug_blocking():
+    loop = asyncio.get_running_loop()
+    loop.slow_callback_duration = 0.001  # 1ms threshold
+
+    # Enable debug mode
+    loop.set_debug(True)
+
+    # Your code here
+```
+
+## Testing Async Code
+
+```python
+import pytest
+import asyncio
+
+@pytest.mark.asyncio
+async def test_async_function():
+    result = await async_function()
+    assert result == expected
+
+# Test timeouts
+@pytest.mark.asyncio
+async def test_with_timeout():
+    with pytest.raises(asyncio.TimeoutError):
+        async with asyncio.timeout(0.1):
+            await slow_function()
+
+# Mock async functions
+from unittest.mock import AsyncMock
+
+async def test_with_mock():
+    mock = AsyncMock(return_value="mocked")
+    result = await mock()
+    assert result == "mocked"
+```
+
+## Visualization Tools
+
+### aiomonitor
+
+```python
+import aiomonitor
+
+async def main():
+    with aiomonitor.start_monitor():
+        # Connect via: nc localhost 50101
+        # or: python -m aiomonitor.cli
+        await long_running_task()
+```
+
+### aiodebug
+
+```python
+from aiodebug import log_slow_callbacks
+
+log_slow_callbacks.enable(0.05)  # Log callbacks > 50ms
+```
+
+## Quick Debug Checklist
+
+1. [ ] Enable debug mode: `asyncio.run(main(), debug=True)`
+2. [ ] Check for unawaited coroutines: warnings -> errors
+3. [ ] Look for blocking calls: time.sleep, requests, open()
+4. [ ] Verify all tasks are awaited or tracked
+5. [ ] Check for proper resource cleanup (sessions, connections)
+6. [ ] Monitor task count: `len(asyncio.all_tasks())`
+7. [ ] Profile with yappi for async-aware profiling

+ 426 - 0
skills/python-async-patterns/references/error-handling.md

@@ -0,0 +1,426 @@
+# Async Error Handling Patterns
+
+Error handling patterns for resilient async applications.
+
+## Retry with Exponential Backoff
+
+```python
+import asyncio
+import random
+from typing import TypeVar, Callable, Awaitable
+
+T = TypeVar("T")
+
+async def retry_with_backoff(
+    func: Callable[[], Awaitable[T]],
+    max_retries: int = 3,
+    base_delay: float = 1.0,
+    max_delay: float = 60.0,
+    exponential_base: float = 2.0,
+    jitter: bool = True,
+    retryable_exceptions: tuple = (Exception,),
+) -> T:
+    """
+    Retry async function with exponential backoff.
+
+    Args:
+        func: Async function to retry
+        max_retries: Maximum number of retry attempts
+        base_delay: Initial delay between retries (seconds)
+        max_delay: Maximum delay between retries (seconds)
+        exponential_base: Base for exponential calculation
+        jitter: Add randomness to prevent thundering herd
+        retryable_exceptions: Exceptions that trigger retry
+    """
+    last_exception = None
+
+    for attempt in range(max_retries + 1):
+        try:
+            return await func()
+        except retryable_exceptions as e:
+            last_exception = e
+
+            if attempt == max_retries:
+                raise
+
+            # Calculate delay with exponential backoff
+            delay = min(
+                base_delay * (exponential_base ** attempt),
+                max_delay
+            )
+
+            # Add jitter (±25%)
+            if jitter:
+                delay *= 0.75 + random.random() * 0.5
+
+            await asyncio.sleep(delay)
+
+    raise last_exception  # Should never reach here
+
+
+# Usage
+async def fetch_with_retry(url: str) -> str:
+    return await retry_with_backoff(
+        lambda: fetch(url),
+        max_retries=3,
+        retryable_exceptions=(aiohttp.ClientError, asyncio.TimeoutError)
+    )
+```
+
+## Retry Decorator
+
+```python
+import functools
+from typing import Type
+
+def async_retry(
+    max_retries: int = 3,
+    base_delay: float = 1.0,
+    exceptions: tuple[Type[Exception], ...] = (Exception,)
+):
+    """Decorator for async retry with backoff."""
+    def decorator(func):
+        @functools.wraps(func)
+        async def wrapper(*args, **kwargs):
+            return await retry_with_backoff(
+                lambda: func(*args, **kwargs),
+                max_retries=max_retries,
+                base_delay=base_delay,
+                retryable_exceptions=exceptions
+            )
+        return wrapper
+    return decorator
+
+
+# Usage
+@async_retry(max_retries=3, exceptions=(aiohttp.ClientError,))
+async def fetch_data(url: str) -> dict:
+    async with aiohttp.ClientSession() as session:
+        async with session.get(url) as response:
+            return await response.json()
+```
+
+## Circuit Breaker
+
+```python
+import asyncio
+import time
+from enum import Enum
+from dataclasses import dataclass
+
+class CircuitState(Enum):
+    CLOSED = "closed"       # Normal operation
+    OPEN = "open"           # Failing, reject calls
+    HALF_OPEN = "half_open" # Testing if recovered
+
+@dataclass
+class CircuitBreakerConfig:
+    failure_threshold: int = 5      # Failures before opening
+    success_threshold: int = 3      # Successes to close
+    timeout: float = 60.0           # Seconds before half-open
+    half_open_max_calls: int = 1    # Calls allowed in half-open
+
+class CircuitBreaker:
+    """
+    Circuit breaker pattern for async operations.
+
+    States:
+    - CLOSED: Normal operation, tracking failures
+    - OPEN: Rejecting calls, waiting for timeout
+    - HALF_OPEN: Testing with limited calls
+    """
+
+    def __init__(self, config: CircuitBreakerConfig | None = None):
+        self.config = config or CircuitBreakerConfig()
+        self._state = CircuitState.CLOSED
+        self._failure_count = 0
+        self._success_count = 0
+        self._last_failure_time: float = 0
+        self._half_open_calls = 0
+        self._lock = asyncio.Lock()
+
+    @property
+    def state(self) -> CircuitState:
+        return self._state
+
+    async def call(self, func, *args, **kwargs):
+        """Execute function through circuit breaker."""
+        async with self._lock:
+            self._check_state_transition()
+
+            if self._state == CircuitState.OPEN:
+                raise CircuitBreakerOpen(
+                    f"Circuit open, retry after {self._retry_after():.1f}s"
+                )
+
+            if self._state == CircuitState.HALF_OPEN:
+                if self._half_open_calls >= self.config.half_open_max_calls:
+                    raise CircuitBreakerOpen("Half-open limit reached")
+                self._half_open_calls += 1
+
+        try:
+            result = await func(*args, **kwargs)
+            await self._record_success()
+            return result
+        except Exception as e:
+            await self._record_failure()
+            raise
+
+    def _check_state_transition(self):
+        """Check if state should transition."""
+        if self._state == CircuitState.OPEN:
+            if time.time() - self._last_failure_time >= self.config.timeout:
+                self._state = CircuitState.HALF_OPEN
+                self._half_open_calls = 0
+                self._success_count = 0
+
+    async def _record_success(self):
+        async with self._lock:
+            if self._state == CircuitState.HALF_OPEN:
+                self._success_count += 1
+                if self._success_count >= self.config.success_threshold:
+                    self._state = CircuitState.CLOSED
+                    self._failure_count = 0
+            else:
+                self._failure_count = 0
+
+    async def _record_failure(self):
+        async with self._lock:
+            self._failure_count += 1
+            self._last_failure_time = time.time()
+
+            if self._state == CircuitState.HALF_OPEN:
+                self._state = CircuitState.OPEN
+            elif self._failure_count >= self.config.failure_threshold:
+                self._state = CircuitState.OPEN
+
+    def _retry_after(self) -> float:
+        elapsed = time.time() - self._last_failure_time
+        return max(0, self.config.timeout - elapsed)
+
+class CircuitBreakerOpen(Exception):
+    """Raised when circuit breaker is open."""
+    pass
+
+
+# Usage
+breaker = CircuitBreaker(CircuitBreakerConfig(
+    failure_threshold=5,
+    timeout=30.0
+))
+
+async def fetch_with_breaker(url: str):
+    return await breaker.call(fetch, url)
+```
+
+## Partial Failure Handling
+
+```python
+async def fetch_all_with_partial_failure(
+    urls: list[str],
+    max_failures: int | None = None
+) -> tuple[list[str], list[Exception]]:
+    """
+    Fetch all URLs, collecting both successes and failures.
+
+    Args:
+        urls: URLs to fetch
+        max_failures: If set, abort after this many failures
+
+    Returns:
+        Tuple of (successful_results, exceptions)
+    """
+    results = await asyncio.gather(
+        *[fetch(url) for url in urls],
+        return_exceptions=True
+    )
+
+    successes = []
+    failures = []
+
+    for result in results:
+        if isinstance(result, Exception):
+            failures.append(result)
+            if max_failures and len(failures) >= max_failures:
+                # Cancel remaining work if too many failures
+                break
+        else:
+            successes.append(result)
+
+    return successes, failures
+
+
+# With structured handling
+@dataclass
+class FetchResult:
+    url: str
+    data: str | None = None
+    error: Exception | None = None
+
+    @property
+    def success(self) -> bool:
+        return self.error is None
+
+async def fetch_with_result(url: str) -> FetchResult:
+    """Wrap fetch in result object."""
+    try:
+        data = await fetch(url)
+        return FetchResult(url=url, data=data)
+    except Exception as e:
+        return FetchResult(url=url, error=e)
+
+async def fetch_all_structured(urls: list[str]) -> list[FetchResult]:
+    """Fetch all URLs with structured results."""
+    return await asyncio.gather(*[fetch_with_result(url) for url in urls])
+```
+
+## Exception Groups (Python 3.11+)
+
+```python
+async def process_with_exception_groups():
+    """Handle multiple exceptions from TaskGroup."""
+    try:
+        async with asyncio.TaskGroup() as tg:
+            tg.create_task(task1())
+            tg.create_task(task2())
+            tg.create_task(task3())
+    except* ValueError as eg:
+        # Handle all ValueError instances
+        for exc in eg.exceptions:
+            logger.error(f"ValueError: {exc}")
+    except* TypeError as eg:
+        # Handle all TypeError instances
+        for exc in eg.exceptions:
+            logger.error(f"TypeError: {exc}")
+
+
+# Filtering exception groups
+def handle_exception_group(eg: ExceptionGroup):
+    """Process exception group by type."""
+    critical = []
+    recoverable = []
+
+    for exc in eg.exceptions:
+        if isinstance(exc, (ConnectionError, TimeoutError)):
+            recoverable.append(exc)
+        else:
+            critical.append(exc)
+
+    # Retry recoverable errors
+    for exc in recoverable:
+        logger.warning(f"Recoverable error: {exc}")
+
+    # Raise critical errors
+    if critical:
+        raise ExceptionGroup("Critical errors", critical)
+```
+
+## Fallback Pattern
+
+```python
+async def with_fallback(
+    primary: Callable[[], Awaitable[T]],
+    fallback: Callable[[], Awaitable[T]],
+    exceptions: tuple = (Exception,)
+) -> T:
+    """Try primary, fall back on failure."""
+    try:
+        return await primary()
+    except exceptions as e:
+        logger.warning(f"Primary failed, using fallback: {e}")
+        return await fallback()
+
+
+# With multiple fallbacks
+async def with_fallback_chain(
+    *funcs: Callable[[], Awaitable[T]]
+) -> T:
+    """Try functions in order until one succeeds."""
+    last_error = None
+
+    for func in funcs:
+        try:
+            return await func()
+        except Exception as e:
+            last_error = e
+            continue
+
+    raise last_error or RuntimeError("No fallbacks provided")
+
+
+# Usage
+result = await with_fallback_chain(
+    lambda: fetch_from_primary_api(),
+    lambda: fetch_from_secondary_api(),
+    lambda: fetch_from_cache(),
+)
+```
+
+## Bulkhead Pattern
+
+```python
+class Bulkhead:
+    """
+    Bulkhead pattern to isolate failures.
+    Limits concurrent calls to protect resources.
+    """
+
+    def __init__(
+        self,
+        max_concurrent: int,
+        max_waiting: int = 0,
+        timeout: float | None = None
+    ):
+        self._semaphore = asyncio.Semaphore(max_concurrent)
+        self._max_waiting = max_waiting
+        self._waiting = 0
+        self._timeout = timeout
+        self._lock = asyncio.Lock()
+
+    async def call(self, func, *args, **kwargs):
+        """Execute function within bulkhead."""
+        async with self._lock:
+            if self._waiting >= self._max_waiting:
+                raise BulkheadFull("Bulkhead queue full")
+            self._waiting += 1
+
+        try:
+            if self._timeout:
+                async with asyncio.timeout(self._timeout):
+                    async with self._semaphore:
+                        return await func(*args, **kwargs)
+            else:
+                async with self._semaphore:
+                    return await func(*args, **kwargs)
+        finally:
+            async with self._lock:
+                self._waiting -= 1
+
+class BulkheadFull(Exception):
+    """Raised when bulkhead cannot accept more calls."""
+    pass
+
+
+# Usage - isolate external service calls
+external_api_bulkhead = Bulkhead(
+    max_concurrent=10,  # Max 10 concurrent calls
+    max_waiting=50,     # Max 50 in queue
+    timeout=30.0        # 30s timeout
+)
+
+async def call_external_api(data):
+    return await external_api_bulkhead.call(
+        lambda: http_client.post("/api", json=data)
+    )
+```
+
+## Quick Reference
+
+| Pattern | Use Case | Behavior |
+|---------|----------|----------|
+| Retry + backoff | Transient failures | Retry with increasing delays |
+| Circuit breaker | Cascading failures | Fast-fail when service down |
+| Fallback | Degraded operation | Use backup on failure |
+| Bulkhead | Resource isolation | Limit concurrent access |
+| Exception groups | Multiple failures | Handle 3.11+ TaskGroup errors |
+| Partial failure | Best-effort batch | Collect successes and failures |

+ 271 - 0
skills/python-async-patterns/references/mixing-sync-async.md

@@ -0,0 +1,271 @@
+# Mixing Sync and Async
+
+Patterns for bridging synchronous and asynchronous Python code.
+
+## Running Sync Code from Async
+
+### run_in_executor
+
+```python
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+
+async def run_blocking():
+    """Run blocking I/O in thread pool."""
+    loop = asyncio.get_running_loop()
+
+    # Using default executor (ThreadPoolExecutor)
+    result = await loop.run_in_executor(
+        None,  # Default executor
+        blocking_function,
+        arg1, arg2
+    )
+    return result
+
+# With custom executor
+executor = ThreadPoolExecutor(max_workers=4)
+
+async def run_with_custom_executor():
+    loop = asyncio.get_running_loop()
+    result = await loop.run_in_executor(
+        executor,
+        blocking_function,
+        arg1
+    )
+    return result
+```
+
+### CPU-bound with ProcessPoolExecutor
+
+```python
+from concurrent.futures import ProcessPoolExecutor
+
+executor = ProcessPoolExecutor(max_workers=4)
+
+async def run_cpu_bound():
+    """Run CPU-bound code in process pool."""
+    loop = asyncio.get_running_loop()
+    result = await loop.run_in_executor(
+        executor,
+        cpu_intensive_function,
+        data
+    )
+    return result
+```
+
+### Decorator Pattern
+
+```python
+import asyncio
+import functools
+
+def run_in_executor(func):
+    """Decorator to run sync function in executor."""
+    @functools.wraps(func)
+    async def wrapper(*args, **kwargs):
+        loop = asyncio.get_running_loop()
+        return await loop.run_in_executor(
+            None,
+            functools.partial(func, *args, **kwargs)
+        )
+    return wrapper
+
+@run_in_executor
+def blocking_io_operation(path):
+    with open(path) as f:
+        return f.read()
+
+# Usage
+async def main():
+    content = await blocking_io_operation("file.txt")
+```
+
+## Running Async Code from Sync
+
+### asyncio.run()
+
+```python
+import asyncio
+
+async def async_function():
+    await asyncio.sleep(1)
+    return "done"
+
+# From sync code
+def sync_wrapper():
+    return asyncio.run(async_function())
+```
+
+### Nested Event Loops (nest_asyncio)
+
+```python
+# For Jupyter notebooks or nested contexts
+import nest_asyncio
+nest_asyncio.apply()
+
+# Now asyncio.run() works even if event loop is running
+```
+
+### Thread with Event Loop
+
+```python
+import asyncio
+import threading
+
+def run_in_new_thread(coro):
+    """Run coroutine in a new thread with its own event loop."""
+    result = None
+    exception = None
+
+    def runner():
+        nonlocal result, exception
+        try:
+            result = asyncio.run(coro)
+        except Exception as e:
+            exception = e
+
+    thread = threading.Thread(target=runner)
+    thread.start()
+    thread.join()
+
+    if exception:
+        raise exception
+    return result
+```
+
+## Common Pitfalls
+
+### DON'T: Call asyncio.run() from async
+
+```python
+# WRONG - nested asyncio.run()
+async def bad():
+    result = asyncio.run(other_async())  # RuntimeError!
+
+# CORRECT - just await
+async def good():
+    result = await other_async()
+```
+
+### DON'T: Use time.sleep() in async
+
+```python
+# WRONG - blocks event loop
+async def bad():
+    time.sleep(5)  # Blocks entire event loop!
+
+# CORRECT
+async def good():
+    await asyncio.sleep(5)
+```
+
+### DON'T: Use blocking I/O directly
+
+```python
+# WRONG - blocks event loop
+async def bad():
+    with open("file.txt") as f:  # Blocking!
+        return f.read()
+
+# CORRECT - use executor
+async def good():
+    loop = asyncio.get_running_loop()
+    return await loop.run_in_executor(None, read_file, "file.txt")
+
+# OR use async file library
+import aiofiles
+async def better():
+    async with aiofiles.open("file.txt") as f:
+        return await f.read()
+```
+
+## Synchronization Primitives
+
+### Threading Lock vs asyncio Lock
+
+```python
+import threading
+import asyncio
+
+# For sync code
+sync_lock = threading.Lock()
+
+# For async code
+async_lock = asyncio.Lock()
+
+# DON'T mix them!
+# threading.Lock() in async code blocks event loop
+# asyncio.Lock() in sync code doesn't work
+```
+
+### Thread-Safe Queue for Sync/Async Bridge
+
+```python
+import asyncio
+import queue
+import threading
+
+def sync_producer(q: queue.Queue):
+    """Sync code putting items."""
+    for i in range(10):
+        q.put(i)
+    q.put(None)  # Sentinel
+
+async def async_consumer(q: queue.Queue):
+    """Async code getting items from sync queue."""
+    loop = asyncio.get_running_loop()
+    while True:
+        # Non-blocking get in executor
+        item = await loop.run_in_executor(None, q.get)
+        if item is None:
+            break
+        await process(item)
+
+async def main():
+    q = queue.Queue()
+
+    # Start sync producer in thread
+    thread = threading.Thread(target=sync_producer, args=(q,))
+    thread.start()
+
+    # Consume async
+    await async_consumer(q)
+    thread.join()
+```
+
+## Async-First Database Access
+
+```python
+# Instead of sync database drivers, use async versions
+
+# SQLite
+import aiosqlite
+async def query_db():
+    async with aiosqlite.connect("db.sqlite") as db:
+        async with db.execute("SELECT * FROM users") as cursor:
+            return await cursor.fetchall()
+
+# PostgreSQL
+import asyncpg
+async def query_postgres():
+    conn = await asyncpg.connect("postgresql://...")
+    rows = await conn.fetch("SELECT * FROM users")
+    await conn.close()
+    return rows
+
+# HTTP
+import aiohttp
+async def fetch_api():
+    async with aiohttp.ClientSession() as session:
+        async with session.get(url) as response:
+            return await response.json()
+```
+
+## Best Practices
+
+1. **Prefer async libraries** - Use aiohttp, aiosqlite, asyncpg over sync versions
+2. **Use run_in_executor for blocking** - Never block the event loop
+3. **Keep sync/async boundaries clean** - Don't mix unnecessarily
+4. **Use ProcessPoolExecutor for CPU-bound** - ThreadPool for I/O
+5. **Don't nest event loops** - Use a single asyncio.run() entry point
+6. **Profile before threading** - Async is often enough

+ 395 - 0
skills/python-async-patterns/references/performance.md

@@ -0,0 +1,395 @@
+# Async Performance Optimization
+
+Performance patterns for high-throughput async applications.
+
+## uvloop - Drop-in Event Loop Replacement
+
+```python
+# Install: pip install uvloop
+
+# Option 1: Install as default (before any asyncio calls)
+import uvloop
+uvloop.install()
+
+# Then use asyncio normally
+import asyncio
+
+async def main():
+    # Now using uvloop
+    pass
+
+asyncio.run(main())
+
+
+# Option 2: Use explicitly
+import asyncio
+import uvloop
+
+async def main():
+    pass
+
+with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
+    runner.run(main())
+
+
+# Option 3: Check if available
+def get_event_loop_policy():
+    try:
+        import uvloop
+        return uvloop.EventLoopPolicy()
+    except ImportError:
+        return asyncio.DefaultEventLoopPolicy()
+
+asyncio.set_event_loop_policy(get_event_loop_policy())
+```
+
+**Performance gains:**
+- 2-4x faster than default asyncio event loop
+- Significant improvement for I/O-bound workloads
+- Based on libuv (same as Node.js)
+
+## Connection Pool Tuning
+
+```python
+import aiohttp
+
+# Optimal connector settings
+connector = aiohttp.TCPConnector(
+    limit=100,              # Total connection limit
+    limit_per_host=30,      # Per-host limit (prevents overwhelming one server)
+    ttl_dns_cache=300,      # DNS cache TTL (seconds)
+    use_dns_cache=True,     # Enable DNS caching
+    keepalive_timeout=30,   # Keep connections alive (seconds)
+    enable_cleanup_closed=True,  # Clean up closed connections
+)
+
+async with aiohttp.ClientSession(connector=connector) as session:
+    # Use session
+    pass
+
+
+# Database connection pool (asyncpg)
+import asyncpg
+
+pool = await asyncpg.create_pool(
+    dsn="postgresql://user:pass@localhost/db",
+    min_size=5,        # Minimum connections to keep
+    max_size=20,       # Maximum connections allowed
+    max_inactive_connection_lifetime=300.0,  # Close idle connections
+    command_timeout=60.0,  # Query timeout
+)
+
+
+# Redis connection pool (aioredis/redis-py)
+import redis.asyncio as redis
+
+pool = redis.ConnectionPool.from_url(
+    "redis://localhost",
+    max_connections=50,
+    decode_responses=True,
+)
+client = redis.Redis(connection_pool=pool)
+```
+
+### Pool Sizing Guidelines
+
+| Service Type | Min Size | Max Size | Notes |
+|--------------|----------|----------|-------|
+| Database (heavy) | 10 | 50 | Match CPU cores × 2-4 |
+| Database (light) | 5 | 20 | Standard web apps |
+| HTTP external API | N/A | 100 | Limited by rate limits |
+| HTTP per-host | N/A | 30 | Prevent overwhelming |
+| Redis | 10 | 50 | Very fast, less critical |
+
+## Buffer Sizing
+
+```python
+# aiohttp response reading
+async def fetch_large(session, url):
+    async with session.get(url) as response:
+        # Default: reads entire response into memory
+        data = await response.read()
+
+        # For large responses, stream:
+        chunks = []
+        async for chunk in response.content.iter_chunked(8192):
+            chunks.append(chunk)
+
+
+# Custom buffer sizes for TCP
+import asyncio
+
+async def create_connection():
+    reader, writer = await asyncio.open_connection(
+        "localhost", 8888,
+        limit=2**20,  # 1MB read buffer (default is 64KB)
+    )
+    return reader, writer
+
+
+# aiohttp server with custom limits
+from aiohttp import web
+
+app = web.Application(
+    client_max_size=1024 * 1024 * 100,  # 100MB max request body
+)
+```
+
+## Batching Requests
+
+```python
+import asyncio
+from collections import defaultdict
+from typing import TypeVar, Callable
+
+T = TypeVar("T")
+
+class BatchProcessor:
+    """Batch multiple requests into single operations."""
+
+    def __init__(
+        self,
+        batch_func: Callable[[list[str]], dict[str, T]],
+        max_batch_size: int = 100,
+        max_delay: float = 0.01  # 10ms
+    ):
+        self._batch_func = batch_func
+        self._max_batch_size = max_batch_size
+        self._max_delay = max_delay
+        self._pending: dict[str, asyncio.Future] = {}
+        self._batch: list[str] = []
+        self._lock = asyncio.Lock()
+        self._timer: asyncio.Task | None = None
+
+    async def get(self, key: str) -> T:
+        """Get single item (batched with other requests)."""
+        async with self._lock:
+            if key in self._pending:
+                return await self._pending[key]
+
+            future = asyncio.get_event_loop().create_future()
+            self._pending[key] = future
+            self._batch.append(key)
+
+            if len(self._batch) >= self._max_batch_size:
+                await self._flush()
+            elif not self._timer:
+                self._timer = asyncio.create_task(self._delayed_flush())
+
+        return await future
+
+    async def _delayed_flush(self):
+        await asyncio.sleep(self._max_delay)
+        async with self._lock:
+            await self._flush()
+
+    async def _flush(self):
+        if not self._batch:
+            return
+
+        batch = self._batch
+        pending = self._pending
+        self._batch = []
+        self._pending = {}
+        self._timer = None
+
+        try:
+            results = await self._batch_func(batch)
+            for key in batch:
+                if key in results:
+                    pending[key].set_result(results[key])
+                else:
+                    pending[key].set_exception(KeyError(key))
+        except Exception as e:
+            for key in batch:
+                pending[key].set_exception(e)
+
+
+# Usage
+async def batch_fetch_users(user_ids: list[str]) -> dict[str, User]:
+    # Single database query for multiple users
+    return {u.id: u for u in await db.fetch_users(user_ids)}
+
+user_batcher = BatchProcessor(batch_fetch_users, max_batch_size=50)
+
+# These will be batched together:
+user1 = await user_batcher.get("user-1")
+user2 = await user_batcher.get("user-2")
+```
+
+## Task Prioritization
+
+```python
+import asyncio
+import heapq
+from dataclasses import dataclass, field
+from typing import Any
+
+@dataclass(order=True)
+class PrioritizedTask:
+    priority: int
+    item: Any = field(compare=False)
+
+class PriorityQueue:
+    """Async priority queue for task ordering."""
+
+    def __init__(self):
+        self._queue: list[PrioritizedTask] = []
+        self._condition = asyncio.Condition()
+
+    async def put(self, priority: int, item: Any):
+        async with self._condition:
+            heapq.heappush(self._queue, PrioritizedTask(priority, item))
+            self._condition.notify()
+
+    async def get(self) -> Any:
+        async with self._condition:
+            while not self._queue:
+                await self._condition.wait()
+            return heapq.heappop(self._queue).item
+
+
+# Usage
+queue = PriorityQueue()
+
+# Lower number = higher priority
+await queue.put(1, "critical task")
+await queue.put(10, "low priority task")
+await queue.put(5, "normal task")
+```
+
+## Memory Optimization
+
+```python
+import asyncio
+from weakref import WeakValueDictionary
+
+# Use weak references for caches
+class AsyncCache:
+    """Memory-efficient async cache using weak references."""
+
+    def __init__(self, fetch_func):
+        self._cache = WeakValueDictionary()
+        self._fetch_func = fetch_func
+        self._locks: dict[str, asyncio.Lock] = {}
+
+    async def get(self, key: str):
+        if key in self._cache:
+            return self._cache[key]
+
+        if key not in self._locks:
+            self._locks[key] = asyncio.Lock()
+
+        async with self._locks[key]:
+            if key in self._cache:
+                return self._cache[key]
+
+            value = await self._fetch_func(key)
+            self._cache[key] = value
+            return value
+
+
+# Limit concurrent operations to prevent memory spikes
+async def process_large_dataset(items: list, concurrency: int = 10):
+    """Process items with limited concurrency."""
+    semaphore = asyncio.Semaphore(concurrency)
+
+    async def process_one(item):
+        async with semaphore:
+            result = await heavy_processing(item)
+            return result
+
+    # Process in chunks to avoid memory issues with huge lists
+    chunk_size = 1000
+    all_results = []
+
+    for i in range(0, len(items), chunk_size):
+        chunk = items[i:i + chunk_size]
+        results = await asyncio.gather(*[process_one(item) for item in chunk])
+        all_results.extend(results)
+
+    return all_results
+```
+
+## Profiling Async Code
+
+```python
+import asyncio
+import time
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def async_timer(name: str):
+    """Context manager to time async operations."""
+    start = time.perf_counter()
+    try:
+        yield
+    finally:
+        elapsed = time.perf_counter() - start
+        print(f"{name}: {elapsed:.3f}s")
+
+
+# Usage
+async with async_timer("fetch_all"):
+    results = await fetch_all(urls)
+
+
+# Detailed profiling
+class AsyncProfiler:
+    def __init__(self):
+        self.timings: dict[str, list[float]] = {}
+
+    @asynccontextmanager
+    async def profile(self, name: str):
+        start = time.perf_counter()
+        try:
+            yield
+        finally:
+            elapsed = time.perf_counter() - start
+            if name not in self.timings:
+                self.timings[name] = []
+            self.timings[name].append(elapsed)
+
+    def report(self):
+        for name, times in self.timings.items():
+            avg = sum(times) / len(times)
+            total = sum(times)
+            print(f"{name}: avg={avg:.3f}s, total={total:.3f}s, count={len(times)}")
+
+
+# Use yappi for comprehensive profiling
+# pip install yappi
+import yappi
+
+yappi.set_clock_type("wall")  # For async code
+yappi.start()
+
+asyncio.run(main())
+
+yappi.stop()
+yappi.get_func_stats().print_all()
+```
+
+## Quick Reference
+
+| Optimization | Impact | When to Use |
+|--------------|--------|-------------|
+| uvloop | 2-4x throughput | Always (production) |
+| Connection pooling | Reduce latency | Any external service |
+| Request batching | N requests → 1 | Database, APIs |
+| Semaphore limiting | Memory control | Large datasets |
+| Streaming | Memory efficiency | Large responses |
+| Priority queue | Latency SLAs | Mixed workloads |
+
+## Performance Checklist
+
+```markdown
+- [ ] uvloop installed and configured
+- [ ] Connection pools properly sized
+- [ ] Timeouts on all external calls
+- [ ] Semaphores limiting concurrency
+- [ ] Large responses streamed
+- [ ] DNS caching enabled
+- [ ] Connection keep-alive configured
+- [ ] Profiling in place for hot paths
+```

+ 370 - 0
skills/python-async-patterns/references/production-patterns.md

@@ -0,0 +1,370 @@
+# Production Async Patterns
+
+Production-ready patterns for deploying async Python applications.
+
+## Graceful Shutdown
+
+```python
+import asyncio
+import signal
+from contextlib import asynccontextmanager
+
+class GracefulShutdown:
+    """Handle graceful shutdown with signal handlers."""
+
+    def __init__(self):
+        self._shutdown = asyncio.Event()
+        self._tasks: set[asyncio.Task] = set()
+
+    @property
+    def should_exit(self) -> bool:
+        return self._shutdown.is_set()
+
+    async def wait_for_shutdown(self):
+        """Block until shutdown signal received."""
+        await self._shutdown.wait()
+
+    def trigger_shutdown(self):
+        """Signal shutdown to all waiting coroutines."""
+        self._shutdown.set()
+
+    def register_task(self, task: asyncio.Task):
+        """Track task for cleanup on shutdown."""
+        self._tasks.add(task)
+        task.add_done_callback(self._tasks.discard)
+
+    async def cleanup(self, timeout: float = 30.0):
+        """Cancel and await all tracked tasks."""
+        for task in self._tasks:
+            task.cancel()
+
+        if self._tasks:
+            await asyncio.wait(
+                self._tasks,
+                timeout=timeout,
+                return_when=asyncio.ALL_COMPLETED
+            )
+
+
+async def main():
+    shutdown = GracefulShutdown()
+    loop = asyncio.get_running_loop()
+
+    # Register signal handlers
+    for sig in (signal.SIGTERM, signal.SIGINT):
+        loop.add_signal_handler(sig, shutdown.trigger_shutdown)
+
+    try:
+        # Start background services
+        worker = asyncio.create_task(background_worker(shutdown))
+        shutdown.register_task(worker)
+
+        # Run until shutdown
+        await shutdown.wait_for_shutdown()
+    finally:
+        # Cleanup
+        await shutdown.cleanup(timeout=30.0)
+
+        # Remove signal handlers
+        for sig in (signal.SIGTERM, signal.SIGINT):
+            loop.remove_signal_handler(sig)
+
+
+async def background_worker(shutdown: GracefulShutdown):
+    """Worker that respects shutdown signals."""
+    while not shutdown.should_exit:
+        try:
+            await process_next_item()
+        except asyncio.CancelledError:
+            # Finish current work before exiting
+            await finish_current_work()
+            raise
+```
+
+## Lifespan Context Manager
+
+```python
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def lifespan():
+    """Application lifespan manager for startup/shutdown."""
+    # Startup
+    db_pool = await create_db_pool()
+    redis = await create_redis_client()
+
+    try:
+        yield {"db": db_pool, "redis": redis}
+    finally:
+        # Shutdown (always runs)
+        await redis.close()
+        await db_pool.close()
+
+
+# Usage with FastAPI
+from fastapi import FastAPI
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # Startup
+    app.state.db = await create_db_pool()
+    yield
+    # Shutdown
+    await app.state.db.close()
+
+app = FastAPI(lifespan=lifespan)
+```
+
+## Health Check Endpoints
+
+```python
+import asyncio
+from dataclasses import dataclass
+from enum import Enum
+
+class HealthStatus(str, Enum):
+    HEALTHY = "healthy"
+    DEGRADED = "degraded"
+    UNHEALTHY = "unhealthy"
+
+@dataclass
+class ComponentHealth:
+    name: str
+    status: HealthStatus
+    latency_ms: float | None = None
+    error: str | None = None
+
+async def check_database(pool) -> ComponentHealth:
+    """Check database connectivity."""
+    try:
+        start = asyncio.get_event_loop().time()
+        async with pool.acquire() as conn:
+            await conn.execute("SELECT 1")
+        latency = (asyncio.get_event_loop().time() - start) * 1000
+        return ComponentHealth("database", HealthStatus.HEALTHY, latency)
+    except Exception as e:
+        return ComponentHealth("database", HealthStatus.UNHEALTHY, error=str(e))
+
+async def check_redis(client) -> ComponentHealth:
+    """Check Redis connectivity."""
+    try:
+        start = asyncio.get_event_loop().time()
+        await client.ping()
+        latency = (asyncio.get_event_loop().time() - start) * 1000
+        return ComponentHealth("redis", HealthStatus.HEALTHY, latency)
+    except Exception as e:
+        return ComponentHealth("redis", HealthStatus.UNHEALTHY, error=str(e))
+
+async def health_check(pool, redis) -> dict:
+    """Aggregate health check for all components."""
+    checks = await asyncio.gather(
+        check_database(pool),
+        check_redis(redis),
+        return_exceptions=True
+    )
+
+    components = []
+    overall = HealthStatus.HEALTHY
+
+    for check in checks:
+        if isinstance(check, Exception):
+            components.append(ComponentHealth("unknown", HealthStatus.UNHEALTHY, error=str(check)))
+            overall = HealthStatus.UNHEALTHY
+        else:
+            components.append(check)
+            if check.status == HealthStatus.UNHEALTHY:
+                overall = HealthStatus.UNHEALTHY
+            elif check.status == HealthStatus.DEGRADED and overall == HealthStatus.HEALTHY:
+                overall = HealthStatus.DEGRADED
+
+    return {
+        "status": overall.value,
+        "components": [
+            {"name": c.name, "status": c.status.value, "latency_ms": c.latency_ms, "error": c.error}
+            for c in components
+        ]
+    }
+```
+
+## Liveness vs Readiness Probes
+
+```python
+class HealthProbes:
+    """Kubernetes-style health probes."""
+
+    def __init__(self):
+        self._ready = asyncio.Event()
+        self._alive = True
+
+    def set_ready(self):
+        """Mark application as ready to receive traffic."""
+        self._ready.set()
+
+    def set_not_ready(self):
+        """Mark application as not ready (drain traffic)."""
+        self._ready.clear()
+
+    def set_not_alive(self):
+        """Mark application as dead (trigger restart)."""
+        self._alive = False
+
+    async def liveness(self) -> bool:
+        """
+        Liveness probe - is the process healthy?
+        Failing this triggers a container restart.
+        """
+        return self._alive
+
+    async def readiness(self) -> bool:
+        """
+        Readiness probe - can the app handle traffic?
+        Failing this removes the pod from service.
+        """
+        return self._ready.is_set()
+
+    async def startup(self) -> bool:
+        """
+        Startup probe - has the app finished initializing?
+        Prevents liveness checks during slow startup.
+        """
+        return self._ready.is_set()
+
+
+# Usage
+probes = HealthProbes()
+
+async def startup():
+    await initialize_db()
+    await warm_caches()
+    probes.set_ready()  # Now accept traffic
+
+async def shutdown():
+    probes.set_not_ready()  # Stop accepting new requests
+    await drain_connections()  # Finish in-flight requests
+```
+
+## Resource Cleanup on Cancellation
+
+```python
+async def process_with_cleanup():
+    """Ensure cleanup even when cancelled."""
+    resource = await acquire_resource()
+    try:
+        await do_work(resource)
+    except asyncio.CancelledError:
+        # Perform essential cleanup before re-raising
+        await resource.flush()
+        raise
+    finally:
+        # Always close resource
+        await resource.close()
+
+
+async def shielded_cleanup():
+    """Protect critical cleanup from cancellation."""
+    resource = await acquire_resource()
+    try:
+        await do_work(resource)
+    finally:
+        # Shield cleanup from cancellation
+        await asyncio.shield(resource.close())
+```
+
+## Background Task Management
+
+```python
+class BackgroundTaskManager:
+    """Manage long-running background tasks."""
+
+    def __init__(self):
+        self._tasks: dict[str, asyncio.Task] = {}
+        self._shutdown = asyncio.Event()
+
+    def start(self, name: str, coro):
+        """Start a named background task."""
+        if name in self._tasks:
+            raise ValueError(f"Task {name} already running")
+
+        task = asyncio.create_task(coro, name=name)
+        task.add_done_callback(lambda t: self._task_done(name, t))
+        self._tasks[name] = task
+        return task
+
+    def _task_done(self, name: str, task: asyncio.Task):
+        """Handle task completion."""
+        self._tasks.pop(name, None)
+
+        if not task.cancelled():
+            exc = task.exception()
+            if exc:
+                # Log error, potentially restart
+                logger.error(f"Task {name} failed: {exc}")
+
+    async def stop(self, name: str, timeout: float = 10.0):
+        """Stop a specific task."""
+        if task := self._tasks.get(name):
+            task.cancel()
+            try:
+                await asyncio.wait_for(task, timeout=timeout)
+            except (asyncio.CancelledError, asyncio.TimeoutError):
+                pass
+
+    async def shutdown(self, timeout: float = 30.0):
+        """Stop all background tasks."""
+        self._shutdown.set()
+
+        for task in self._tasks.values():
+            task.cancel()
+
+        if self._tasks:
+            await asyncio.wait(
+                self._tasks.values(),
+                timeout=timeout,
+                return_when=asyncio.ALL_COMPLETED
+            )
+```
+
+## Periodic Tasks
+
+```python
+async def periodic_task(
+    interval: float,
+    coro_func,
+    shutdown_event: asyncio.Event | None = None
+):
+    """Run a coroutine periodically."""
+    while True:
+        if shutdown_event and shutdown_event.is_set():
+            break
+
+        try:
+            await coro_func()
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            logger.error(f"Periodic task error: {e}")
+
+        # Wait for interval or shutdown
+        if shutdown_event:
+            try:
+                await asyncio.wait_for(
+                    shutdown_event.wait(),
+                    timeout=interval
+                )
+                break  # Shutdown signaled
+            except asyncio.TimeoutError:
+                pass  # Continue loop
+        else:
+            await asyncio.sleep(interval)
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `GracefulShutdown` | SIGTERM/SIGINT handling |
+| `lifespan` context | Startup/shutdown resources |
+| `HealthProbes` | Kubernetes health checks |
+| `asyncio.shield()` | Protect critical cleanup |
+| `BackgroundTaskManager` | Long-running task lifecycle |
+| `periodic_task` | Scheduled background work |

+ 46 - 0
skills/python-async-patterns/scripts/find-blocking-calls.sh

@@ -0,0 +1,46 @@
+#!/bin/bash
+# Find potentially blocking calls in async Python code
+# Usage: ./find-blocking-calls.sh [directory]
+
+DIR="${1:-.}"
+
+echo "=== Scanning for blocking calls in async code ==="
+echo "Directory: $DIR"
+echo
+
+# time.sleep() in async functions
+echo "--- time.sleep() in async functions ---"
+rg -n "async def" -A 20 "$DIR" | rg "time\.sleep\(" || echo "None found"
+echo
+
+# requests library (blocking HTTP)
+echo "--- requests library usage ---"
+rg -n "import requests|from requests" "$DIR" --type py || echo "None found"
+echo
+
+# Blocking file operations
+echo "--- Blocking file operations in async ---"
+rg -n "async def" -A 30 "$DIR" | rg "open\(|\.read\(\)|\.write\(" || echo "None found"
+echo
+
+# subprocess without asyncio
+echo "--- Blocking subprocess calls ---"
+rg -n "subprocess\.(run|call|check_output)" "$DIR" --type py || echo "None found"
+echo
+
+# socket operations
+echo "--- Blocking socket operations ---"
+rg -n "socket\.(socket|create_connection)" "$DIR" --type py || echo "None found"
+echo
+
+# input() calls
+echo "--- Blocking input() calls ---"
+rg -n "\binput\(" "$DIR" --type py || echo "None found"
+echo
+
+echo "=== Recommendations ==="
+echo "- Replace time.sleep() with asyncio.sleep()"
+echo "- Replace requests with aiohttp or httpx"
+echo "- Replace open() with aiofiles"
+echo "- Replace subprocess with asyncio.create_subprocess_exec()"
+echo "- Use asyncio.get_event_loop().run_in_executor() for blocking code"

+ 171 - 0
skills/python-cli-patterns/SKILL.md

@@ -0,0 +1,171 @@
+---
+name: python-cli-patterns
+description: "CLI application patterns for Python. Triggers on: cli, command line, typer, click, argparse, terminal, rich, console, terminal ui."
+compatibility: "Python 3.10+. Requires typer and rich for modern CLI development."
+allowed-tools: "Read Write Bash"
+depends-on: []
+related-skills: [python-typing-patterns, python-observability-patterns]
+---
+
+# Python CLI Patterns
+
+Modern CLI development with Typer and Rich.
+
+## Basic Typer App
+
+```python
+import typer
+
+app = typer.Typer(
+    name="myapp",
+    help="My awesome CLI application",
+    add_completion=True,
+)
+
+@app.command()
+def hello(
+    name: str = typer.Argument(..., help="Name to greet"),
+    count: int = typer.Option(1, "--count", "-c", help="Times to greet"),
+    loud: bool = typer.Option(False, "--loud", "-l", help="Uppercase"),
+):
+    """Say hello to someone."""
+    message = f"Hello, {name}!"
+    if loud:
+        message = message.upper()
+    for _ in range(count):
+        typer.echo(message)
+
+if __name__ == "__main__":
+    app()
+```
+
+## Command Groups
+
+```python
+import typer
+
+app = typer.Typer()
+users_app = typer.Typer(help="User management commands")
+app.add_typer(users_app, name="users")
+
+@users_app.command("list")
+def list_users():
+    """List all users."""
+    typer.echo("Listing users...")
+
+@users_app.command("create")
+def create_user(name: str, email: str):
+    """Create a new user."""
+    typer.echo(f"Creating user: {name} <{email}>")
+
+@app.command()
+def version():
+    """Show version."""
+    typer.echo("1.0.0")
+
+# Usage: myapp users list
+#        myapp users create "John" "john@example.com"
+#        myapp version
+```
+
+## Rich Output
+
+```python
+from rich.console import Console
+from rich.table import Table
+from rich.progress import track
+from rich.panel import Panel
+import typer
+
+console = Console()
+
+@app.command()
+def show_users():
+    """Display users in a table."""
+    table = Table(title="Users")
+    table.add_column("ID", style="cyan")
+    table.add_column("Name", style="green")
+    table.add_column("Email")
+
+    users = [
+        (1, "Alice", "alice@example.com"),
+        (2, "Bob", "bob@example.com"),
+    ]
+    for id, name, email in users:
+        table.add_row(str(id), name, email)
+
+    console.print(table)
+
+@app.command()
+def process():
+    """Process items with progress bar."""
+    items = list(range(100))
+    for item in track(items, description="Processing..."):
+        do_something(item)
+    console.print("[green]Done![/green]")
+```
+
+## Error Handling
+
+```python
+import typer
+from rich.console import Console
+
+console = Console()
+
+def error(message: str, code: int = 1):
+    """Print error and exit."""
+    console.print(f"[red]Error:[/red] {message}")
+    raise typer.Exit(code)
+
+@app.command()
+def process(file: str):
+    """Process a file."""
+    if not os.path.exists(file):
+        error(f"File not found: {file}")
+
+    try:
+        result = process_file(file)
+        console.print(f"[green]Success:[/green] {result}")
+    except ValueError as e:
+        error(str(e))
+```
+
+## Quick Reference
+
+| Feature | Typer Syntax |
+|---------|--------------|
+| Required arg | `name: str` |
+| Optional arg | `name: str = "default"` |
+| Option | `typer.Option(default, "--flag", "-f")` |
+| Argument | `typer.Argument(..., help="...")` |
+| Boolean flag | `verbose: bool = False` |
+| Enum choice | `color: Color = Color.red` |
+
+| Rich Feature | Usage |
+|--------------|-------|
+| Table | `Table()` + `add_column/row` |
+| Progress | `track(items)` |
+| Colors | `[red]text[/red]` |
+| Panel | `Panel("content", title="Title")` |
+
+## Additional Resources
+
+- `./references/typer-patterns.md` - Advanced Typer patterns
+- `./references/rich-output.md` - Rich tables, progress, formatting
+- `./references/configuration.md` - Config files, environment variables
+
+## Assets
+
+- `./assets/cli-template.py` - Full CLI application template
+
+---
+
+## See Also
+
+**Related Skills:**
+- `python-typing-patterns` - Type hints for CLI arguments
+- `python-observability-patterns` - Logging for CLI applications
+
+**Complementary Skills:**
+- `python-env` - Package CLI for distribution

+ 256 - 0
skills/python-cli-patterns/assets/cli-template.py

@@ -0,0 +1,256 @@
+"""
+CLI Application Template
+
+A production-ready CLI application structure.
+
+Usage:
+    python cli.py --help
+    python cli.py greet "World"
+    python cli.py config init
+"""
+
+import sys
+from pathlib import Path
+from typing import Annotated, Optional
+
+import typer
+from rich.console import Console
+from rich.table import Table
+from rich.panel import Panel
+from rich.progress import track
+
+# =============================================================================
+# App Setup
+# =============================================================================
+
+app = typer.Typer(
+    name="myapp",
+    help="My awesome CLI application",
+    no_args_is_help=True,
+    add_completion=True,
+    rich_markup_mode="rich",
+)
+
+console = Console()
+err_console = Console(stderr=True)
+
+# Sub-applications
+config_app = typer.Typer(help="Configuration commands")
+app.add_typer(config_app, name="config")
+
+
+# =============================================================================
+# State and Configuration
+# =============================================================================
+
+class AppState:
+    """Application state shared across commands."""
+
+    def __init__(self):
+        self.verbose: bool = False
+        self.config_dir: Path = Path.home() / ".config" / "myapp"
+        self.config_file: Path = self.config_dir / "config.toml"
+
+
+state = AppState()
+
+
+@app.callback()
+def main(
+    verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
+    config: Optional[Path] = typer.Option(
+        None, "--config", "-c", help="Config file path"
+    ),
+):
+    """
+    [bold blue]MyApp[/bold blue] - A sample CLI application.
+
+    Use [green]--help[/green] on any command for more info.
+    """
+    state.verbose = verbose
+    if config:
+        state.config_file = config
+
+
+# =============================================================================
+# Utility Functions
+# =============================================================================
+
+def log(message: str, style: str = ""):
+    """Log message if verbose mode is enabled."""
+    if state.verbose:
+        console.print(f"[dim]{message}[/dim]", style=style)
+
+
+def error(message: str, code: int = 1) -> None:
+    """Print error and exit."""
+    err_console.print(f"[red]Error:[/red] {message}")
+    raise typer.Exit(code)
+
+
+def success(message: str) -> None:
+    """Print success message."""
+    console.print(f"[green]✓[/green] {message}")
+
+
+# =============================================================================
+# Commands
+# =============================================================================
+
+@app.command()
+def greet(
+    name: Annotated[str, typer.Argument(help="Name to greet")],
+    count: Annotated[int, typer.Option("--count", "-n", help="Times to greet")] = 1,
+    loud: Annotated[bool, typer.Option("--loud", "-l", help="Uppercase")] = False,
+):
+    """
+    Say hello to someone.
+
+    Example:
+        myapp greet World
+        myapp greet World --count 3 --loud
+    """
+    message = f"Hello, {name}!"
+    if loud:
+        message = message.upper()
+
+    for _ in range(count):
+        console.print(message)
+
+
+@app.command()
+def process(
+    files: Annotated[
+        list[Path],
+        typer.Argument(
+            help="Files to process",
+            exists=True,
+            readable=True,
+        ),
+    ],
+    output: Annotated[
+        Optional[Path],
+        typer.Option("--output", "-o", help="Output file"),
+    ] = None,
+):
+    """
+    Process one or more files.
+
+    Example:
+        myapp process file1.txt file2.txt -o output.txt
+    """
+    log(f"Processing {len(files)} files")
+
+    results = []
+    for file in track(files, description="Processing..."):
+        log(f"Processing: {file}")
+        # Simulate processing
+        results.append(f"Processed: {file.name}")
+
+    if output:
+        output.write_text("\n".join(results))
+        success(f"Results written to {output}")
+    else:
+        for result in results:
+            console.print(result)
+
+
+@app.command()
+def status():
+    """Show application status."""
+    table = Table(title="Application Status")
+    table.add_column("Setting", style="cyan")
+    table.add_column("Value", style="green")
+
+    table.add_row("Config Dir", str(state.config_dir))
+    table.add_row("Config File", str(state.config_file))
+    table.add_row("Verbose", str(state.verbose))
+    table.add_row(
+        "Config Exists",
+        "✓" if state.config_file.exists() else "✗"
+    )
+
+    console.print(table)
+
+
+# =============================================================================
+# Config Subcommands
+# =============================================================================
+
+@config_app.command("init")
+def config_init(
+    force: Annotated[
+        bool,
+        typer.Option("--force", "-f", help="Overwrite existing"),
+    ] = False,
+):
+    """Initialize configuration file."""
+    if state.config_file.exists() and not force:
+        if not typer.confirm(f"Config exists at {state.config_file}. Overwrite?"):
+            raise typer.Abort()
+
+    state.config_dir.mkdir(parents=True, exist_ok=True)
+
+    default_config = """
+# MyApp Configuration
+# See documentation for all options
+
+[general]
+verbose = false
+
+[server]
+host = "localhost"
+port = 8080
+""".strip()
+
+    state.config_file.write_text(default_config)
+    success(f"Created config: {state.config_file}")
+
+
+@config_app.command("show")
+def config_show():
+    """Show current configuration."""
+    if not state.config_file.exists():
+        error(f"Config not found: {state.config_file}")
+
+    content = state.config_file.read_text()
+    console.print(Panel(content, title=str(state.config_file), border_style="blue"))
+
+
+@config_app.command("path")
+def config_path():
+    """Print config file path."""
+    typer.echo(state.config_file)
+
+
+# =============================================================================
+# Version
+# =============================================================================
+
+def version_callback(value: bool):
+    if value:
+        console.print("myapp version [bold]1.0.0[/bold]")
+        raise typer.Exit()
+
+
+@app.callback()
+def version_option(
+    version: Annotated[
+        bool,
+        typer.Option(
+            "--version",
+            callback=version_callback,
+            is_eager=True,
+            help="Show version",
+        ),
+    ] = False,
+):
+    pass
+
+
+# =============================================================================
+# Entry Point
+# =============================================================================
+
+if __name__ == "__main__":
+    app()

+ 294 - 0
skills/python-cli-patterns/references/configuration.md

@@ -0,0 +1,294 @@
+# CLI Configuration Patterns
+
+Configuration file and environment variable handling.
+
+## Environment Variables
+
+```python
+import os
+import typer
+
+app = typer.Typer()
+
+@app.command()
+def connect(
+    # Read from env var with fallback
+    host: str = typer.Option(
+        "localhost",
+        envvar="DB_HOST",
+        help="Database host",
+    ),
+    port: int = typer.Option(
+        5432,
+        envvar="DB_PORT",
+        help="Database port",
+    ),
+    # Multiple envvars (first found wins)
+    password: str = typer.Option(
+        ...,  # Required
+        envvar=["DB_PASSWORD", "DATABASE_PASSWORD", "PGPASSWORD"],
+        help="Database password",
+    ),
+):
+    """Connect to database."""
+    typer.echo(f"Connecting to {host}:{port}")
+```
+
+## Configuration File with TOML
+
+```python
+import tomllib  # Python 3.11+
+from pathlib import Path
+from dataclasses import dataclass
+from typing import Optional
+
+@dataclass
+class Config:
+    host: str = "localhost"
+    port: int = 8080
+    debug: bool = False
+    log_level: str = "INFO"
+
+    @classmethod
+    def load(cls, path: Path | None = None) -> "Config":
+        """Load config from TOML file."""
+        if path is None:
+            # Search default locations
+            for p in [
+                Path("config.toml"),
+                Path.home() / ".config" / "myapp" / "config.toml",
+            ]:
+                if p.exists():
+                    path = p
+                    break
+
+        if path and path.exists():
+            with open(path, "rb") as f:
+                data = tomllib.load(f)
+                return cls(**data)
+
+        return cls()
+
+
+# Usage in CLI
+@app.callback()
+def main(
+    ctx: typer.Context,
+    config: Path = typer.Option(
+        None,
+        "--config", "-c",
+        exists=True,
+        help="Config file path",
+    ),
+):
+    ctx.obj = Config.load(config)
+
+
+@app.command()
+def serve(ctx: typer.Context):
+    config = ctx.obj
+    typer.echo(f"Starting on {config.host}:{config.port}")
+```
+
+## Config with Pydantic Settings
+
+```python
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from pydantic import Field
+from pathlib import Path
+
+class Settings(BaseSettings):
+    """Application settings from env vars and config file."""
+
+    model_config = SettingsConfigDict(
+        env_file=".env",
+        env_file_encoding="utf-8",
+        env_prefix="MYAPP_",  # MYAPP_HOST, MYAPP_PORT
+        case_sensitive=False,
+    )
+
+    host: str = "localhost"
+    port: int = 8080
+    debug: bool = False
+    database_url: str = Field(
+        default="sqlite:///app.db",
+        validation_alias="DATABASE_URL",  # Also check DATABASE_URL without prefix
+    )
+    api_key: str = Field(default="")
+
+
+# Load once
+settings = Settings()
+
+@app.command()
+def serve():
+    typer.echo(f"Host: {settings.host}")
+    typer.echo(f"Debug: {settings.debug}")
+```
+
+## XDG Config Directories
+
+```python
+from pathlib import Path
+import os
+
+def get_config_dir(app_name: str) -> Path:
+    """Get XDG-compliant config directory."""
+    if os.name == "nt":  # Windows
+        base = Path(os.environ.get("APPDATA", Path.home()))
+    else:  # Linux/macOS
+        base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
+
+    config_dir = base / app_name
+    config_dir.mkdir(parents=True, exist_ok=True)
+    return config_dir
+
+
+def get_data_dir(app_name: str) -> Path:
+    """Get XDG-compliant data directory."""
+    if os.name == "nt":
+        base = Path(os.environ.get("LOCALAPPDATA", Path.home()))
+    else:
+        base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
+
+    data_dir = base / app_name
+    data_dir.mkdir(parents=True, exist_ok=True)
+    return data_dir
+
+
+def get_cache_dir(app_name: str) -> Path:
+    """Get XDG-compliant cache directory."""
+    if os.name == "nt":
+        base = Path(os.environ.get("LOCALAPPDATA", Path.home())) / "cache"
+    else:
+        base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
+
+    cache_dir = base / app_name
+    cache_dir.mkdir(parents=True, exist_ok=True)
+    return cache_dir
+```
+
+## Config Init Command
+
+```python
+import typer
+from pathlib import Path
+
+@app.command()
+def init(
+    force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing"),
+):
+    """Initialize configuration file."""
+    config_dir = get_config_dir("myapp")
+    config_file = config_dir / "config.toml"
+
+    if config_file.exists() and not force:
+        typer.echo(f"Config already exists: {config_file}")
+        if not typer.confirm("Overwrite?"):
+            raise typer.Abort()
+
+    default_config = """
+# MyApp Configuration
+
+[server]
+host = "localhost"
+port = 8080
+
+[logging]
+level = "INFO"
+format = "json"
+
+[database]
+url = "sqlite:///app.db"
+""".strip()
+
+    config_file.write_text(default_config)
+    typer.echo(f"Created config: {config_file}")
+```
+
+## Layered Configuration
+
+```python
+from dataclasses import dataclass, field, asdict
+import tomllib
+from pathlib import Path
+import os
+
+@dataclass
+class Config:
+    """Config with layered loading: defaults < file < env vars < CLI."""
+
+    host: str = "localhost"
+    port: int = 8080
+    debug: bool = False
+
+    @classmethod
+    def load(
+        cls,
+        config_file: Path | None = None,
+        **cli_overrides,
+    ) -> "Config":
+        # Start with defaults
+        config = cls()
+
+        # Layer 2: Config file
+        if config_file and config_file.exists():
+            with open(config_file, "rb") as f:
+                file_config = tomllib.load(f)
+                for key, value in file_config.items():
+                    if hasattr(config, key):
+                        setattr(config, key, value)
+
+        # Layer 3: Environment variables
+        env_mapping = {
+            "MYAPP_HOST": "host",
+            "MYAPP_PORT": "port",
+            "MYAPP_DEBUG": "debug",
+        }
+        for env_var, attr in env_mapping.items():
+            if value := os.environ.get(env_var):
+                if attr == "port":
+                    value = int(value)
+                elif attr == "debug":
+                    value = value.lower() in ("true", "1", "yes")
+                setattr(config, attr, value)
+
+        # Layer 4: CLI overrides (highest priority)
+        for key, value in cli_overrides.items():
+            if value is not None and hasattr(config, key):
+                setattr(config, key, value)
+
+        return config
+
+
+@app.command()
+def serve(
+    config: Path = typer.Option(None, "--config", "-c"),
+    host: str = typer.Option(None, "--host", "-h"),
+    port: int = typer.Option(None, "--port", "-p"),
+    debug: bool = typer.Option(None, "--debug", "-d"),
+):
+    """Start server with layered config."""
+    cfg = Config.load(
+        config_file=config,
+        host=host,
+        port=port,
+        debug=debug,
+    )
+    typer.echo(f"Starting on {cfg.host}:{cfg.port}")
+```
+
+## Quick Reference
+
+| Source | Priority | Example |
+|--------|----------|---------|
+| Defaults | Lowest | `host="localhost"` |
+| Config file | Low | `config.toml` |
+| Env vars | Medium | `MYAPP_HOST=0.0.0.0` |
+| CLI args | Highest | `--host 0.0.0.0` |
+
+| XDG Directory | Purpose | Default |
+|---------------|---------|---------|
+| `XDG_CONFIG_HOME` | Config files | `~/.config` |
+| `XDG_DATA_HOME` | Persistent data | `~/.local/share` |
+| `XDG_CACHE_HOME` | Cache | `~/.cache` |

+ 293 - 0
skills/python-cli-patterns/references/rich-output.md

@@ -0,0 +1,293 @@
+# Rich Terminal Output
+
+Beautiful CLI output with Rich.
+
+## Console Basics
+
+```python
+from rich.console import Console
+from rich.text import Text
+
+console = Console()
+
+# Basic printing
+console.print("Hello, World!")
+
+# With styling
+console.print("Hello", style="bold red")
+console.print("[bold blue]Bold blue[/bold blue] and [green]green[/green]")
+
+# Print objects (auto-formatting)
+console.print({"key": "value", "list": [1, 2, 3]})
+
+# Print to stderr
+console.print("Error!", style="red", file=sys.stderr)
+
+# Width control
+console.print("Text", width=40, justify="center")
+```
+
+## Tables
+
+```python
+from rich.table import Table
+from rich.console import Console
+
+console = Console()
+
+# Basic table
+table = Table(title="Users")
+table.add_column("ID", style="cyan", justify="right")
+table.add_column("Name", style="green")
+table.add_column("Email")
+table.add_column("Active", justify="center")
+
+table.add_row("1", "Alice", "alice@example.com", "✓")
+table.add_row("2", "Bob", "bob@example.com", "✓")
+table.add_row("3", "Charlie", "charlie@example.com", "✗")
+
+console.print(table)
+
+
+# Table with styling
+table = Table(
+    title="Report",
+    show_header=True,
+    header_style="bold magenta",
+    border_style="blue",
+    box=box.DOUBLE,
+)
+
+
+# Dynamic table from data
+def print_users(users: list[dict]):
+    table = Table()
+    table.add_column("ID")
+    table.add_column("Name")
+    table.add_column("Status")
+
+    for user in users:
+        status = "[green]Active[/green]" if user["active"] else "[red]Inactive[/red]"
+        table.add_row(str(user["id"]), user["name"], status)
+
+    console.print(table)
+```
+
+## Progress Bars
+
+```python
+from rich.progress import (
+    Progress,
+    SpinnerColumn,
+    TextColumn,
+    BarColumn,
+    TaskProgressColumn,
+    TimeRemainingColumn,
+    track,
+)
+from rich.console import Console
+
+console = Console()
+
+# Simple progress with track()
+for item in track(items, description="Processing..."):
+    process(item)
+
+
+# Customizable progress
+with Progress(
+    SpinnerColumn(),
+    TextColumn("[bold blue]{task.description}"),
+    BarColumn(),
+    TaskProgressColumn(),
+    TimeRemainingColumn(),
+    console=console,
+) as progress:
+    task = progress.add_task("Downloading...", total=100)
+
+    for i in range(100):
+        do_work()
+        progress.update(task, advance=1)
+
+
+# Multiple tasks
+with Progress() as progress:
+    download_task = progress.add_task("Downloading", total=1000)
+    process_task = progress.add_task("Processing", total=500)
+
+    while not progress.finished:
+        progress.update(download_task, advance=10)
+        progress.update(process_task, advance=5)
+        time.sleep(0.01)
+
+
+# Indeterminate spinner
+with console.status("[bold green]Working...") as status:
+    while not done:
+        do_something()
+        status.update("[bold green]Still working...")
+```
+
+## Panels and Layout
+
+```python
+from rich.panel import Panel
+from rich.layout import Layout
+from rich.console import Console
+
+console = Console()
+
+# Basic panel
+console.print(Panel("Hello, World!", title="Greeting", border_style="green"))
+
+# Panel with rich content
+console.print(Panel(
+    "[bold]Important Message[/bold]\n\n"
+    "This is a [red]warning[/red] message.",
+    title="Alert",
+    subtitle="Action Required",
+    border_style="red",
+))
+
+
+# Layout for complex UIs
+layout = Layout()
+layout.split(
+    Layout(name="header", size=3),
+    Layout(name="main"),
+    Layout(name="footer", size=3),
+)
+
+layout["header"].update(Panel("My CLI App", style="bold"))
+layout["main"].split_row(
+    Layout(name="left"),
+    Layout(name="right"),
+)
+layout["footer"].update(Panel("Press Ctrl+C to exit"))
+
+console.print(layout)
+```
+
+## Markdown and Syntax
+
+```python
+from rich.markdown import Markdown
+from rich.syntax import Syntax
+from rich.console import Console
+
+console = Console()
+
+# Render markdown
+md = Markdown("""
+# Title
+
+This is **bold** and *italic*.
+
+- Item 1
+- Item 2
+
+```python
+print("Hello")
+```
+""")
+console.print(md)
+
+
+# Syntax highlighting
+code = '''
+def hello(name: str) -> str:
+    """Say hello."""
+    return f"Hello, {name}!"
+'''
+
+syntax = Syntax(code, "python", theme="monokai", line_numbers=True)
+console.print(syntax)
+
+
+# From file
+syntax = Syntax.from_path("script.py", line_numbers=True)
+console.print(syntax)
+```
+
+## Trees
+
+```python
+from rich.tree import Tree
+from rich.console import Console
+
+console = Console()
+
+tree = Tree("[bold]Project Structure")
+src = tree.add("[blue]src/")
+src.add("main.py")
+src.add("utils.py")
+src.add("[blue]models/").add("user.py")
+
+tests = tree.add("[blue]tests/")
+tests.add("test_main.py")
+
+console.print(tree)
+```
+
+## Live Display
+
+```python
+from rich.live import Live
+from rich.table import Table
+from rich.console import Console
+import time
+
+console = Console()
+
+def generate_table(count: int) -> Table:
+    table = Table()
+    table.add_column("Count")
+    table.add_column("Status")
+    table.add_row(str(count), "Processing...")
+    return table
+
+with Live(generate_table(0), console=console, refresh_per_second=4) as live:
+    for i in range(100):
+        time.sleep(0.1)
+        live.update(generate_table(i))
+```
+
+## Logging Integration
+
+```python
+from rich.logging import RichHandler
+import logging
+
+logging.basicConfig(
+    level="INFO",
+    format="%(message)s",
+    datefmt="[%X]",
+    handlers=[RichHandler(rich_tracebacks=True)],
+)
+
+logger = logging.getLogger("my_app")
+logger.info("Hello, World!")
+logger.warning("This is a warning")
+logger.error("Something went wrong")
+```
+
+## Quick Reference
+
+| Component | Usage |
+|-----------|-------|
+| `console.print()` | Print with styling |
+| `Table()` | Tabular data |
+| `track()` | Simple progress bar |
+| `Progress()` | Custom progress |
+| `Panel()` | Bordered content |
+| `Syntax()` | Code highlighting |
+| `Markdown()` | Render markdown |
+| `Tree()` | Hierarchical data |
+| `Live()` | Dynamic updates |
+
+| Markup | Effect |
+|--------|--------|
+| `[bold]text[/bold]` | Bold |
+| `[red]text[/red]` | Red color |
+| `[link=url]text[/link]` | Hyperlink |
+| `[dim]text[/dim]` | Dimmed |

+ 303 - 0
skills/python-cli-patterns/references/typer-patterns.md

@@ -0,0 +1,303 @@
+# Advanced Typer Patterns
+
+Modern CLI development patterns with Typer.
+
+## Application Structure
+
+```python
+import typer
+from typing import Optional
+from enum import Enum
+
+# Create app with metadata
+app = typer.Typer(
+    name="myapp",
+    help="My CLI application",
+    add_completion=True,
+    no_args_is_help=True,  # Show help if no command given
+    rich_markup_mode="rich",  # Enable Rich formatting in help
+)
+
+# State object for shared options
+class State:
+    def __init__(self):
+        self.verbose: bool = False
+        self.config_path: str = ""
+
+state = State()
+
+
+@app.callback()
+def main(
+    verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
+    config: str = typer.Option("config.yaml", "--config", "-c", help="Config file"),
+):
+    """
+    My awesome CLI application.
+
+    Use --help on any command for more info.
+    """
+    state.verbose = verbose
+    state.config_path = config
+```
+
+## Type-Safe Arguments
+
+```python
+from typing import Annotated
+from enum import Enum
+from pathlib import Path
+
+class OutputFormat(str, Enum):
+    json = "json"
+    yaml = "yaml"
+    table = "table"
+
+@app.command()
+def export(
+    # Required argument
+    query: Annotated[str, typer.Argument(help="Search query")],
+
+    # Optional argument with default
+    limit: Annotated[int, typer.Argument()] = 10,
+
+    # Path validation
+    output: Annotated[
+        Path,
+        typer.Option(
+            "--output", "-o",
+            help="Output file path",
+            exists=False,  # Must not exist
+            file_okay=True,
+            dir_okay=False,
+            writable=True,
+            resolve_path=True,
+        )
+    ] = None,
+
+    # Input file (must exist)
+    input_file: Annotated[
+        Path,
+        typer.Option(
+            "--input", "-i",
+            exists=True,  # Must exist
+            readable=True,
+        )
+    ] = None,
+
+    # Enum choices
+    format: Annotated[
+        OutputFormat,
+        typer.Option("--format", "-f", case_sensitive=False)
+    ] = OutputFormat.table,
+
+    # Multiple values
+    tags: Annotated[
+        list[str],
+        typer.Option("--tag", "-t", help="Tags to filter")
+    ] = None,
+):
+    """Export data with various options."""
+    typer.echo(f"Query: {query}, Format: {format.value}")
+```
+
+## Interactive Prompts
+
+```python
+import typer
+
+@app.command()
+def create_user():
+    """Create a new user interactively."""
+    # Text prompt
+    name = typer.prompt("What's your name?")
+
+    # With default
+    email = typer.prompt("Email", default=f"{name.lower()}@example.com")
+
+    # Hidden input (password)
+    password = typer.prompt("Password", hide_input=True)
+
+    # Confirmation
+    password_confirm = typer.prompt("Confirm password", hide_input=True)
+    if password != password_confirm:
+        typer.echo("Passwords don't match!")
+        raise typer.Abort()
+
+    # Yes/No confirmation
+    if typer.confirm("Create this user?"):
+        typer.echo(f"Creating user: {name}")
+    else:
+        typer.echo("Cancelled")
+        raise typer.Abort()
+
+
+# Non-interactive with --yes flag
+@app.command()
+def delete_all(
+    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
+):
+    """Delete all items."""
+    if not yes:
+        yes = typer.confirm("Are you sure?")
+    if yes:
+        typer.echo("Deleting...")
+    else:
+        raise typer.Abort()
+```
+
+## Context and Dependency Injection
+
+```python
+import typer
+from typing import Annotated
+
+# Create a context type
+class Context:
+    def __init__(self, db_url: str, debug: bool):
+        self.db_url = db_url
+        self.debug = debug
+        self.db = None
+
+    def connect(self):
+        self.db = create_connection(self.db_url)
+
+# Store in typer context
+@app.callback()
+def main(
+    ctx: typer.Context,
+    db_url: str = typer.Option("sqlite:///app.db", envvar="DATABASE_URL"),
+    debug: bool = typer.Option(False, "--debug"),
+):
+    """Initialize application context."""
+    ctx.obj = Context(db_url=db_url, debug=debug)
+    ctx.obj.connect()
+
+
+@app.command()
+def query(
+    ctx: typer.Context,
+    sql: str,
+):
+    """Run a SQL query."""
+    result = ctx.obj.db.execute(sql)
+    for row in result:
+        typer.echo(row)
+```
+
+## Subcommands and Nested Apps
+
+```python
+import typer
+
+# Main app
+app = typer.Typer()
+
+# Sub-applications
+db_app = typer.Typer(help="Database operations")
+cache_app = typer.Typer(help="Cache operations")
+
+# Register sub-apps
+app.add_typer(db_app, name="db")
+app.add_typer(cache_app, name="cache")
+
+@db_app.command("migrate")
+def db_migrate():
+    """Run database migrations."""
+    typer.echo("Running migrations...")
+
+@db_app.command("seed")
+def db_seed():
+    """Seed database with test data."""
+    typer.echo("Seeding database...")
+
+@cache_app.command("clear")
+def cache_clear():
+    """Clear cache."""
+    typer.echo("Clearing cache...")
+
+# Usage:
+# myapp db migrate
+# myapp db seed
+# myapp cache clear
+```
+
+## Async Commands
+
+```python
+import typer
+import asyncio
+
+app = typer.Typer()
+
+async def async_operation():
+    await asyncio.sleep(1)
+    return "Done"
+
+@app.command()
+def fetch():
+    """Fetch data asynchronously."""
+    result = asyncio.run(async_main())
+    typer.echo(result)
+
+async def async_main():
+    results = await asyncio.gather(
+        async_operation(),
+        async_operation(),
+    )
+    return results
+```
+
+## Testing CLI Apps
+
+```python
+from typer.testing import CliRunner
+import pytest
+
+runner = CliRunner()
+
+def test_hello():
+    result = runner.invoke(app, ["hello", "World"])
+    assert result.exit_code == 0
+    assert "Hello, World!" in result.stdout
+
+def test_hello_with_options():
+    result = runner.invoke(app, ["hello", "World", "--count", "3", "--loud"])
+    assert result.exit_code == 0
+    assert "HELLO, WORLD!" in result.stdout
+    assert result.stdout.count("HELLO") == 3
+
+def test_invalid_input():
+    result = runner.invoke(app, ["process", "nonexistent.txt"])
+    assert result.exit_code == 1
+    assert "not found" in result.stdout.lower()
+
+
+# With environment variables
+def test_with_env():
+    result = runner.invoke(
+        app,
+        ["connect"],
+        env={"DATABASE_URL": "sqlite:///test.db"}
+    )
+    assert result.exit_code == 0
+```
+
+## Quick Reference
+
+| Pattern | Syntax |
+|---------|--------|
+| App callback | `@app.callback()` for global options |
+| Context | `ctx: typer.Context` + `ctx.obj` |
+| Envvar | `typer.Option(envvar="VAR_NAME")` |
+| Prompt | `typer.prompt("Question")` |
+| Confirm | `typer.confirm("Sure?")` |
+| Abort | `raise typer.Abort()` |
+| Exit | `raise typer.Exit(code=1)` |
+| Progress | Use Rich `track()` |
+
+| Decorator | Purpose |
+|-----------|---------|
+| `@app.command()` | Define a command |
+| `@app.callback()` | App initialization |
+| `@sub_app.command()` | Subcommand |

+ 184 - 0
skills/python-database-patterns/SKILL.md

@@ -0,0 +1,184 @@
+---
+name: python-database-patterns
+description: "SQLAlchemy and database patterns for Python. Triggers on: sqlalchemy, database, orm, migration, alembic, async database, connection pool, repository pattern, unit of work."
+compatibility: "SQLAlchemy 2.0+, Python 3.10+. Async requires asyncpg (PostgreSQL) or aiosqlite."
+allowed-tools: "Read Write Bash"
+depends-on: [python-typing-patterns, python-async-patterns]
+related-skills: [python-fastapi-patterns]
+---
+
+# Python Database Patterns
+
+SQLAlchemy 2.0 and database best practices.
+
+## SQLAlchemy 2.0 Basics
+
+```python
+from sqlalchemy import create_engine, select
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
+
+class Base(DeclarativeBase):
+    pass
+
+class User(Base):
+    __tablename__ = "users"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    email: Mapped[str] = mapped_column(String(255), unique=True)
+    is_active: Mapped[bool] = mapped_column(default=True)
+
+# Create engine and tables
+engine = create_engine("postgresql://user:pass@localhost/db")
+Base.metadata.create_all(engine)
+
+# Query with 2.0 style
+with Session(engine) as session:
+    stmt = select(User).where(User.is_active == True)
+    users = session.execute(stmt).scalars().all()
+```
+
+## Async SQLAlchemy
+
+```python
+from sqlalchemy.ext.asyncio import (
+    AsyncSession,
+    async_sessionmaker,
+    create_async_engine,
+)
+from sqlalchemy import select
+
+# Async engine
+engine = create_async_engine(
+    "postgresql+asyncpg://user:pass@localhost/db",
+    echo=False,
+    pool_size=5,
+    max_overflow=10,
+)
+
+# Session factory
+async_session = async_sessionmaker(engine, expire_on_commit=False)
+
+# Usage
+async with async_session() as session:
+    result = await session.execute(select(User).where(User.id == 1))
+    user = result.scalar_one_or_none()
+```
+
+## Model Relationships
+
+```python
+from sqlalchemy import ForeignKey
+from sqlalchemy.orm import relationship, Mapped, mapped_column
+
+class User(Base):
+    __tablename__ = "users"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str]
+
+    # One-to-many
+    posts: Mapped[list["Post"]] = relationship(back_populates="author")
+
+class Post(Base):
+    __tablename__ = "posts"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    title: Mapped[str]
+    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+
+    # Many-to-one
+    author: Mapped["User"] = relationship(back_populates="posts")
+```
+
+## Common Query Patterns
+
+```python
+from sqlalchemy import select, and_, or_, func
+
+# Basic select
+stmt = select(User).where(User.is_active == True)
+
+# Multiple conditions
+stmt = select(User).where(
+    and_(
+        User.is_active == True,
+        User.age >= 18
+    )
+)
+
+# OR conditions
+stmt = select(User).where(
+    or_(User.role == "admin", User.role == "moderator")
+)
+
+# Ordering and limiting
+stmt = select(User).order_by(User.created_at.desc()).limit(10)
+
+# Aggregates
+stmt = select(func.count(User.id)).where(User.is_active == True)
+
+# Joins
+stmt = select(User, Post).join(Post, User.id == Post.author_id)
+
+# Eager loading
+from sqlalchemy.orm import selectinload
+stmt = select(User).options(selectinload(User.posts))
+```
+
+## FastAPI Integration
+
+```python
+from fastapi import Depends, FastAPI
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Annotated
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+    async with async_session() as session:
+        yield session
+
+DB = Annotated[AsyncSession, Depends(get_db)]
+
+@app.get("/users/{user_id}")
+async def get_user(user_id: int, db: DB):
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(status_code=404)
+    return user
+```
+
+## Quick Reference
+
+| Operation | SQLAlchemy 2.0 Style |
+|-----------|---------------------|
+| Select all | `select(User)` |
+| Filter | `.where(User.id == 1)` |
+| First | `.scalar_one_or_none()` |
+| All | `.scalars().all()` |
+| Count | `select(func.count(User.id))` |
+| Join | `.join(Post)` |
+| Eager load | `.options(selectinload(User.posts))` |
+
+## Additional Resources
+
+- `./references/sqlalchemy-async.md` - Async patterns, session management
+- `./references/connection-pooling.md` - Pool configuration, health checks
+- `./references/transactions.md` - Transaction patterns, isolation levels
+- `./references/migrations.md` - Alembic setup, migration strategies
+
+## Assets
+
+- `./assets/alembic.ini.template` - Alembic configuration template
+
+---
+
+## See Also
+
+**Prerequisites:**
+- `python-typing-patterns` - Mapped types and annotations
+- `python-async-patterns` - Async database sessions
+
+**Related Skills:**
+- `python-fastapi-patterns` - Dependency injection for DB sessions
+- `python-pytest-patterns` - Database fixtures and testing

+ 59 - 0
skills/python-database-patterns/assets/alembic.ini.template

@@ -0,0 +1,59 @@
+# Alembic Configuration Template
+# Copy to alembic.ini and customize
+
+[alembic]
+# Path to migration scripts
+script_location = alembic
+
+# Template for new migration files
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
+
+# Prepend sys.path for model imports
+prepend_sys_path = .
+
+# Timezone for file timestamps
+# timezone =
+
+# Max length for autogenerate identifiers
+# truncate_slug_length = 40
+
+# Post-write hooks
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -q
+
+# Logging
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 297 - 0
skills/python-database-patterns/references/connection-pooling.md

@@ -0,0 +1,297 @@
+# Connection Pool Configuration
+
+Database connection pool patterns for production.
+
+## SQLAlchemy Pool Settings
+
+```python
+from sqlalchemy import create_engine
+from sqlalchemy.ext.asyncio import create_async_engine
+
+# Sync engine with pool config
+engine = create_engine(
+    "postgresql://user:pass@localhost/db",
+
+    # Pool size
+    pool_size=5,           # Persistent connections (default: 5)
+    max_overflow=10,       # Extra connections when pool exhausted
+    # Total max connections = pool_size + max_overflow = 15
+
+    # Timeouts
+    pool_timeout=30,       # Wait for connection (seconds)
+    pool_recycle=3600,     # Recycle connections after N seconds
+    pool_pre_ping=True,    # Test connections before use
+
+    # Connection args
+    connect_args={
+        "connect_timeout": 10,
+        "options": "-c statement_timeout=30000",  # 30s query timeout
+    },
+)
+
+
+# Async engine
+async_engine = create_async_engine(
+    "postgresql+asyncpg://user:pass@localhost/db",
+    pool_size=5,
+    max_overflow=10,
+    pool_timeout=30,
+    pool_recycle=3600,
+    pool_pre_ping=True,
+)
+```
+
+## Pool Sizing Guidelines
+
+```python
+"""
+Connection Pool Sizing
+
+Rule of thumb:
+    pool_size = (CPU cores × 2) + disk spindles
+
+For async applications:
+    pool_size = expected_concurrent_requests / avg_queries_per_request
+
+Examples:
+    - Web app, 4 cores, SSD:  pool_size=10, max_overflow=10
+    - Worker, 4 cores, HDD:   pool_size=12, max_overflow=5
+    - High-traffic API:       pool_size=20, max_overflow=30
+"""
+
+import os
+
+def calculate_pool_size() -> tuple[int, int]:
+    """Calculate pool size based on environment."""
+    cpu_count = os.cpu_count() or 4
+
+    if os.getenv("ENV") == "production":
+        pool_size = cpu_count * 2 + 4
+        max_overflow = pool_size
+    else:
+        pool_size = 5
+        max_overflow = 5
+
+    return pool_size, max_overflow
+
+pool_size, max_overflow = calculate_pool_size()
+```
+
+## Pool Events and Monitoring
+
+```python
+from sqlalchemy import event
+from sqlalchemy.pool import Pool
+import logging
+
+logger = logging.getLogger(__name__)
+
+@event.listens_for(Pool, "connect")
+def on_connect(dbapi_conn, connection_record):
+    """Called when a new connection is created."""
+    logger.debug("New database connection created")
+
+@event.listens_for(Pool, "checkout")
+def on_checkout(dbapi_conn, connection_record, connection_proxy):
+    """Called when a connection is retrieved from pool."""
+    logger.debug("Connection checked out from pool")
+
+@event.listens_for(Pool, "checkin")
+def on_checkin(dbapi_conn, connection_record):
+    """Called when a connection is returned to pool."""
+    logger.debug("Connection returned to pool")
+
+@event.listens_for(Pool, "invalidate")
+def on_invalidate(dbapi_conn, connection_record, exception):
+    """Called when a connection is invalidated."""
+    logger.warning(f"Connection invalidated: {exception}")
+
+
+# Pool statistics
+def log_pool_status(engine):
+    """Log current pool status."""
+    pool = engine.pool
+    logger.info(
+        f"Pool status: "
+        f"size={pool.size()}, "
+        f"checked_out={pool.checkedout()}, "
+        f"overflow={pool.overflow()}, "
+        f"checkedin={pool.checkedin()}"
+    )
+```
+
+## Health Check Endpoint
+
+```python
+from fastapi import FastAPI, HTTPException
+from sqlalchemy import text
+import asyncio
+
+app = FastAPI()
+
+async def check_database_health(timeout: float = 5.0) -> dict:
+    """Check database connectivity and response time."""
+    try:
+        start = asyncio.get_event_loop().time()
+
+        async with async_session_factory() as session:
+            await asyncio.wait_for(
+                session.execute(text("SELECT 1")),
+                timeout=timeout
+            )
+
+        latency = (asyncio.get_event_loop().time() - start) * 1000
+
+        return {
+            "status": "healthy",
+            "latency_ms": round(latency, 2),
+            "pool_size": async_engine.pool.size(),
+            "pool_checked_out": async_engine.pool.checkedout(),
+        }
+    except asyncio.TimeoutError:
+        return {"status": "unhealthy", "error": "timeout"}
+    except Exception as e:
+        return {"status": "unhealthy", "error": str(e)}
+
+
+@app.get("/health/db")
+async def database_health():
+    health = await check_database_health()
+    if health["status"] != "healthy":
+        raise HTTPException(status_code=503, detail=health)
+    return health
+```
+
+## Connection Pool per Service
+
+```python
+from dataclasses import dataclass
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+
+@dataclass
+class DatabaseConfig:
+    url: str
+    pool_size: int = 5
+    max_overflow: int = 10
+    pool_timeout: int = 30
+    pool_recycle: int = 3600
+
+class DatabasePool:
+    """Manage multiple database connections."""
+
+    def __init__(self):
+        self._engines: dict[str, AsyncEngine] = {}
+
+    def add_database(self, name: str, config: DatabaseConfig):
+        """Add a database connection pool."""
+        self._engines[name] = create_async_engine(
+            config.url,
+            pool_size=config.pool_size,
+            max_overflow=config.max_overflow,
+            pool_timeout=config.pool_timeout,
+            pool_recycle=config.pool_recycle,
+            pool_pre_ping=True,
+        )
+
+    def get_engine(self, name: str) -> AsyncEngine:
+        return self._engines[name]
+
+    async def close_all(self):
+        """Close all connection pools."""
+        for engine in self._engines.values():
+            await engine.dispose()
+
+
+# Usage
+db_pool = DatabasePool()
+
+db_pool.add_database("primary", DatabaseConfig(
+    url="postgresql+asyncpg://user:pass@primary/db",
+    pool_size=10,
+))
+
+db_pool.add_database("replica", DatabaseConfig(
+    url="postgresql+asyncpg://user:pass@replica/db",
+    pool_size=20,  # More connections for read replica
+))
+```
+
+## Read/Write Splitting
+
+```python
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+# Separate session factories for read/write
+write_engine = create_async_engine(
+    "postgresql+asyncpg://user:pass@primary/db",
+    pool_size=10,
+)
+
+read_engine = create_async_engine(
+    "postgresql+asyncpg://user:pass@replica/db",
+    pool_size=20,
+)
+
+write_session = async_sessionmaker(write_engine, expire_on_commit=False)
+read_session = async_sessionmaker(read_engine, expire_on_commit=False)
+
+
+# FastAPI dependencies
+async def get_write_db():
+    async with write_session() as session:
+        yield session
+
+async def get_read_db():
+    async with read_session() as session:
+        yield session
+
+WriteDB = Annotated[AsyncSession, Depends(get_write_db)]
+ReadDB = Annotated[AsyncSession, Depends(get_read_db)]
+
+
+@app.get("/users")
+async def list_users(db: ReadDB):  # Read from replica
+    result = await db.execute(select(User))
+    return result.scalars().all()
+
+@app.post("/users")
+async def create_user(user: UserCreate, db: WriteDB):  # Write to primary
+    db_user = User(**user.model_dump())
+    db.add(db_user)
+    await db.commit()
+    return db_user
+```
+
+## Graceful Shutdown
+
+```python
+from contextlib import asynccontextmanager
+from fastapi import FastAPI
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # Startup - engines already created
+    yield
+    # Shutdown - close all pools gracefully
+    await async_engine.dispose()
+    logger.info("Database connections closed")
+
+app = FastAPI(lifespan=lifespan)
+```
+
+## Quick Reference
+
+| Setting | Purpose | Typical Value |
+|---------|---------|---------------|
+| `pool_size` | Persistent connections | 5-20 |
+| `max_overflow` | Extra connections | 10-30 |
+| `pool_timeout` | Wait for connection | 30s |
+| `pool_recycle` | Recycle connection age | 3600s |
+| `pool_pre_ping` | Test before use | True |
+
+| Scenario | pool_size | max_overflow |
+|----------|-----------|--------------|
+| Development | 5 | 5 |
+| Small API | 10 | 10 |
+| High-traffic | 20 | 30 |
+| Background worker | 5 | 5 |

+ 342 - 0
skills/python-database-patterns/references/migrations.md

@@ -0,0 +1,342 @@
+# Database Migrations with Alembic
+
+Schema migration patterns for SQLAlchemy projects.
+
+## Setup
+
+```bash
+# Install
+pip install alembic
+
+# Initialize in project root
+alembic init alembic
+
+# For async projects
+alembic init -t async alembic
+```
+
+## Configuration
+
+```python
+# alembic/env.py
+from logging.config import fileConfig
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+from alembic import context
+
+from app.models import Base  # Your declarative base
+from app.config import settings
+
+config = context.config
+
+# Set database URL from settings
+config.set_main_option("sqlalchemy.url", settings.database_url)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode."""
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def do_run_migrations(connection: Connection):
+    context.configure(connection=connection, target_metadata=target_metadata)
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+async def run_async_migrations():
+    """Run migrations in 'online' mode with async engine."""
+    connectable = async_engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    async with connectable.connect() as connection:
+        await connection.run_sync(do_run_migrations)
+    await connectable.dispose()
+
+
+def run_migrations_online():
+    import asyncio
+    asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()
+```
+
+## Common Commands
+
+```bash
+# Generate migration from model changes
+alembic revision --autogenerate -m "add users table"
+
+# Apply all pending migrations
+alembic upgrade head
+
+# Rollback one migration
+alembic downgrade -1
+
+# Rollback to specific revision
+alembic downgrade abc123
+
+# Show current revision
+alembic current
+
+# Show migration history
+alembic history
+
+# Show pending migrations
+alembic history --indicate-current
+```
+
+## Migration Script Example
+
+```python
+"""Add users table
+
+Revision ID: abc123
+Revises:
+Create Date: 2024-01-15 10:00:00.000000
+"""
+from typing import Sequence
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = 'abc123'
+down_revision: str | None = None
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        'users',
+        sa.Column('id', sa.Integer(), primary_key=True),
+        sa.Column('email', sa.String(255), nullable=False, unique=True),
+        sa.Column('name', sa.String(100), nullable=False),
+        sa.Column('is_active', sa.Boolean(), default=True),
+        sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
+    )
+    op.create_index('ix_users_email', 'users', ['email'])
+
+
+def downgrade() -> None:
+    op.drop_index('ix_users_email')
+    op.drop_table('users')
+```
+
+## Data Migrations
+
+```python
+"""Migrate user names to lowercase
+
+Revision ID: def456
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import table, column
+
+revision = 'def456'
+down_revision = 'abc123'
+
+
+def upgrade() -> None:
+    # Define table structure for data migration
+    users = table(
+        'users',
+        column('id', sa.Integer),
+        column('name', sa.String),
+    )
+
+    # Update data
+    op.execute(
+        users.update().values(name=sa.func.lower(users.c.name))
+    )
+
+
+def downgrade() -> None:
+    # Data migrations are often one-way
+    pass
+
+
+# For complex data migrations
+def upgrade() -> None:
+    connection = op.get_bind()
+
+    # Read in batches
+    results = connection.execute(
+        sa.text("SELECT id, name FROM users")
+    )
+
+    for batch in results.partitions(1000):
+        for row in batch:
+            connection.execute(
+                sa.text("UPDATE users SET name = :name WHERE id = :id"),
+                {"id": row.id, "name": row.name.lower()}
+            )
+```
+
+## Adding Columns Safely
+
+```python
+"""Add nullable column first, then populate
+
+Production-safe column addition for large tables.
+"""
+
+def upgrade() -> None:
+    # Step 1: Add nullable column (fast, no table rewrite)
+    op.add_column(
+        'users',
+        sa.Column('phone', sa.String(20), nullable=True)
+    )
+
+    # Step 2: Populate data (can be done in batches)
+    # This is often done in a separate migration or script
+
+    # Step 3: Add constraint (in a later migration after data is populated)
+    # op.alter_column('users', 'phone', nullable=False)
+
+
+def downgrade() -> None:
+    op.drop_column('users', 'phone')
+```
+
+## Renaming Columns
+
+```python
+"""Rename column with zero downtime
+
+Use a multi-step approach for production.
+"""
+
+# Migration 1: Add new column
+def upgrade() -> None:
+    op.add_column('users', sa.Column('full_name', sa.String(200)))
+    # Copy data
+    op.execute("UPDATE users SET full_name = name")
+
+def downgrade() -> None:
+    op.drop_column('users', 'full_name')
+
+
+# Migration 2: Drop old column (after app updated to use new column)
+def upgrade() -> None:
+    op.drop_column('users', 'name')
+
+def downgrade() -> None:
+    op.add_column('users', sa.Column('name', sa.String(100)))
+    op.execute("UPDATE users SET name = full_name")
+```
+
+## Index Management
+
+```python
+"""Add index concurrently (PostgreSQL)
+
+Non-blocking index creation for large tables.
+"""
+from alembic import op
+
+def upgrade() -> None:
+    # Create index without locking table (PostgreSQL)
+    op.execute("""
+        CREATE INDEX CONCURRENTLY IF NOT EXISTS
+        ix_users_created_at ON users (created_at)
+    """)
+
+
+def downgrade() -> None:
+    op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_users_created_at")
+
+
+# Note: CONCURRENTLY cannot run inside a transaction
+# Add to migration script:
+# from alembic import context
+# context.execute_ddl_statements = True
+```
+
+## Multi-Database Migrations
+
+```python
+# alembic.ini
+[alembic]
+script_location = alembic
+
+[primary]
+sqlalchemy.url = postgresql://user:pass@primary/db
+
+[analytics]
+sqlalchemy.url = postgresql://user:pass@analytics/db
+```
+
+```bash
+# Run migrations for specific database
+alembic -n primary upgrade head
+alembic -n analytics upgrade head
+```
+
+## Testing Migrations
+
+```python
+import pytest
+from alembic import command
+from alembic.config import Config
+
+@pytest.fixture
+def alembic_config():
+    config = Config("alembic.ini")
+    config.set_main_option("sqlalchemy.url", "sqlite:///:memory:")
+    return config
+
+def test_migrations_up_down(alembic_config):
+    """Test that all migrations apply and rollback cleanly."""
+    # Apply all migrations
+    command.upgrade(alembic_config, "head")
+
+    # Rollback all migrations
+    command.downgrade(alembic_config, "base")
+
+    # Apply again
+    command.upgrade(alembic_config, "head")
+
+
+def test_migration_idempotent(alembic_config):
+    """Test migrations can be run multiple times."""
+    command.upgrade(alembic_config, "head")
+    command.upgrade(alembic_config, "head")  # Should be no-op
+```
+
+## Quick Reference
+
+| Command | Purpose |
+|---------|---------|
+| `alembic revision --autogenerate -m "msg"` | Generate migration |
+| `alembic upgrade head` | Apply all migrations |
+| `alembic downgrade -1` | Rollback one |
+| `alembic current` | Show current version |
+| `alembic history` | List all migrations |
+
+| Operation | Method |
+|-----------|--------|
+| Create table | `op.create_table()` |
+| Drop table | `op.drop_table()` |
+| Add column | `op.add_column()` |
+| Drop column | `op.drop_column()` |
+| Alter column | `op.alter_column()` |
+| Create index | `op.create_index()` |
+| Execute SQL | `op.execute()` |

+ 299 - 0
skills/python-database-patterns/references/sqlalchemy-async.md

@@ -0,0 +1,299 @@
+# Async SQLAlchemy Patterns
+
+Modern async database patterns with SQLAlchemy 2.0.
+
+## Engine and Session Setup
+
+```python
+from sqlalchemy.ext.asyncio import (
+    AsyncSession,
+    AsyncEngine,
+    async_sessionmaker,
+    create_async_engine,
+)
+
+# Create async engine
+engine = create_async_engine(
+    "postgresql+asyncpg://user:pass@localhost/db",
+    echo=False,            # SQL logging
+    pool_size=5,           # Connection pool size
+    max_overflow=10,       # Extra connections allowed
+    pool_pre_ping=True,    # Test connections before use
+    pool_recycle=3600,     # Recycle connections after 1 hour
+)
+
+# Session factory (not the session itself)
+async_session_factory = async_sessionmaker(
+    engine,
+    class_=AsyncSession,
+    expire_on_commit=False,  # Don't expire objects after commit
+)
+
+
+# Usage with context manager
+async def get_users():
+    async with async_session_factory() as session:
+        result = await session.execute(select(User))
+        return result.scalars().all()
+```
+
+## Session Scopes
+
+```python
+# Per-request (FastAPI dependency)
+async def get_db():
+    async with async_session_factory() as session:
+        try:
+            yield session
+            await session.commit()
+        except Exception:
+            await session.rollback()
+            raise
+
+
+# Explicit transaction control
+async def transfer_funds(from_id: int, to_id: int, amount: Decimal):
+    async with async_session_factory() as session:
+        async with session.begin():  # Auto-commit on success
+            from_account = await session.get(Account, from_id)
+            to_account = await session.get(Account, to_id)
+
+            from_account.balance -= amount
+            to_account.balance += amount
+            # Commits automatically if no exception
+
+
+# Nested transactions (savepoints)
+async def complex_operation():
+    async with async_session_factory() as session:
+        async with session.begin():
+            # Outer transaction
+            user = User(name="Test")
+            session.add(user)
+
+            try:
+                async with session.begin_nested():  # Savepoint
+                    # Inner operation that might fail
+                    await risky_operation(session)
+            except RiskyOperationError:
+                # Savepoint rolled back, outer continues
+                pass
+
+            await session.commit()
+```
+
+## Lazy Loading in Async
+
+```python
+from sqlalchemy.orm import selectinload, joinedload, subqueryload
+
+# WRONG - lazy loading doesn't work in async
+async def bad_example():
+    async with async_session_factory() as session:
+        user = await session.get(User, 1)
+        # This raises an error!
+        print(user.posts)  # MissingGreenlet error
+
+
+# CORRECT - eager loading
+async def good_example():
+    async with async_session_factory() as session:
+        # Option 1: selectinload (separate query per relationship)
+        stmt = select(User).options(selectinload(User.posts))
+        result = await session.execute(stmt)
+        user = result.scalar_one()
+        print(user.posts)  # Works!
+
+        # Option 2: joinedload (single JOIN query)
+        stmt = select(User).options(joinedload(User.profile))
+        result = await session.execute(stmt)
+        user = result.scalar_one()
+
+
+# With nested relationships
+stmt = select(User).options(
+    selectinload(User.posts).selectinload(Post.comments)
+)
+```
+
+## Async Session Dependency
+
+```python
+from fastapi import Depends
+from typing import Annotated, AsyncGenerator
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+    """Dependency for FastAPI."""
+    async with async_session_factory() as session:
+        yield session
+
+DB = Annotated[AsyncSession, Depends(get_db)]
+
+
+# With automatic transaction handling
+async def get_db_with_transaction() -> AsyncGenerator[AsyncSession, None]:
+    async with async_session_factory() as session:
+        try:
+            yield session
+            await session.commit()
+        except Exception:
+            await session.rollback()
+            raise
+        finally:
+            await session.close()
+```
+
+## Batch Operations
+
+```python
+from sqlalchemy import insert, update, delete
+
+# Bulk insert
+async def bulk_create_users(users_data: list[dict]):
+    async with async_session_factory() as session:
+        stmt = insert(User).values(users_data)
+        await session.execute(stmt)
+        await session.commit()
+
+
+# Bulk update
+async def deactivate_users(user_ids: list[int]):
+    async with async_session_factory() as session:
+        stmt = (
+            update(User)
+            .where(User.id.in_(user_ids))
+            .values(is_active=False)
+        )
+        result = await session.execute(stmt)
+        await session.commit()
+        return result.rowcount
+
+
+# Bulk delete
+async def delete_old_posts(before_date: datetime):
+    async with async_session_factory() as session:
+        stmt = delete(Post).where(Post.created_at < before_date)
+        result = await session.execute(stmt)
+        await session.commit()
+        return result.rowcount
+
+
+# Batch processing with chunks
+async def process_all_users(batch_size: int = 100):
+    async with async_session_factory() as session:
+        offset = 0
+        while True:
+            stmt = select(User).offset(offset).limit(batch_size)
+            result = await session.execute(stmt)
+            users = result.scalars().all()
+
+            if not users:
+                break
+
+            for user in users:
+                await process_user(user)
+
+            await session.commit()
+            offset += batch_size
+```
+
+## Streaming Results
+
+```python
+from sqlalchemy import select
+
+async def stream_large_table():
+    """Process large tables without loading all into memory."""
+    async with async_session_factory() as session:
+        stmt = select(User).execution_options(yield_per=100)
+        result = await session.stream(stmt)
+
+        async for user in result.scalars():
+            await process_user(user)
+
+
+# Partitioned streaming
+async def stream_partitioned():
+    async with async_session_factory() as session:
+        stmt = select(User).execution_options(yield_per=100)
+        result = await session.stream(stmt)
+
+        async for partition in result.scalars().partitions(100):
+            # partition is a list of 100 users
+            await process_batch(partition)
+```
+
+## Async Raw SQL
+
+```python
+from sqlalchemy import text
+
+async def raw_query():
+    async with async_session_factory() as session:
+        # Simple query
+        result = await session.execute(
+            text("SELECT * FROM users WHERE is_active = :active"),
+            {"active": True}
+        )
+        rows = result.fetchall()
+
+        # With column access
+        for row in rows:
+            print(row.id, row.name)
+
+
+async def raw_insert():
+    async with async_session_factory() as session:
+        await session.execute(
+            text("INSERT INTO logs (message) VALUES (:msg)"),
+            {"msg": "Test log"}
+        )
+        await session.commit()
+```
+
+## Testing with Async
+
+```python
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+
+@pytest_asyncio.fixture(scope="session")
+async def async_engine():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield engine
+    await engine.dispose()
+
+@pytest_asyncio.fixture
+async def async_session(async_engine):
+    async with AsyncSession(async_engine) as session:
+        async with session.begin():
+            yield session
+            await session.rollback()
+
+
+@pytest.mark.asyncio
+async def test_create_user(async_session):
+    user = User(name="Test", email="test@example.com")
+    async_session.add(user)
+    await async_session.flush()
+
+    assert user.id is not None
+```
+
+## Quick Reference
+
+| Pattern | Async SQLAlchemy |
+|---------|------------------|
+| Create engine | `create_async_engine(url)` |
+| Session factory | `async_sessionmaker(engine)` |
+| Get session | `async with factory() as session:` |
+| Execute | `await session.execute(stmt)` |
+| Get one | `result.scalar_one_or_none()` |
+| Get all | `result.scalars().all()` |
+| Stream | `await session.stream(stmt)` |
+| Commit | `await session.commit()` |
+| Transaction | `async with session.begin():` |
+| Eager load | `.options(selectinload(rel))` |

+ 286 - 0
skills/python-database-patterns/references/transactions.md

@@ -0,0 +1,286 @@
+# Transaction Patterns
+
+Database transaction management for data integrity.
+
+## Basic Transaction Patterns
+
+```python
+from sqlalchemy.ext.asyncio import AsyncSession
+
+# Pattern 1: Context manager (auto-commit/rollback)
+async with async_session_factory() as session:
+    async with session.begin():
+        user = User(name="Test")
+        session.add(user)
+        # Auto-commits on exit, rollback on exception
+
+
+# Pattern 2: Explicit control
+async with async_session_factory() as session:
+    try:
+        user = User(name="Test")
+        session.add(user)
+        await session.commit()
+    except Exception:
+        await session.rollback()
+        raise
+
+
+# Pattern 3: Dependency with auto-commit
+async def get_db():
+    async with async_session_factory() as session:
+        try:
+            yield session
+            await session.commit()
+        except Exception:
+            await session.rollback()
+            raise
+```
+
+## Nested Transactions (Savepoints)
+
+```python
+async def complex_operation():
+    async with async_session_factory() as session:
+        async with session.begin():
+            # Create user (outer transaction)
+            user = User(name="Test")
+            session.add(user)
+            await session.flush()  # Get user.id
+
+            try:
+                # Nested operation (savepoint)
+                async with session.begin_nested():
+                    profile = Profile(user_id=user.id, bio="Hello")
+                    session.add(profile)
+                    await session.flush()
+
+                    # This might fail
+                    await validate_profile(profile)
+
+            except ValidationError:
+                # Savepoint rolled back, but user is preserved
+                logger.warning("Profile creation failed")
+
+            # Commit user (profile may or may not exist)
+            await session.commit()
+```
+
+## Unit of Work Pattern
+
+```python
+from typing import TypeVar, Generic
+from sqlalchemy.ext.asyncio import AsyncSession
+
+T = TypeVar("T")
+
+class UnitOfWork:
+    """Coordinate multiple repository operations in a transaction."""
+
+    def __init__(self, session_factory):
+        self._session_factory = session_factory
+        self._session: AsyncSession | None = None
+
+    async def __aenter__(self):
+        self._session = self._session_factory()
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        if exc_type:
+            await self.rollback()
+        await self._session.close()
+
+    async def commit(self):
+        await self._session.commit()
+
+    async def rollback(self):
+        await self._session.rollback()
+
+    @property
+    def users(self) -> "UserRepository":
+        return UserRepository(self._session)
+
+    @property
+    def orders(self) -> "OrderRepository":
+        return OrderRepository(self._session)
+
+
+# Usage
+async def create_order_with_items(user_id: int, items: list):
+    async with UnitOfWork(async_session_factory) as uow:
+        user = await uow.users.get(user_id)
+        if not user:
+            raise NotFoundError("User not found")
+
+        order = Order(user_id=user_id)
+        order = await uow.orders.add(order)
+
+        for item in items:
+            await uow.orders.add_item(order.id, item)
+
+        await uow.commit()
+        return order
+```
+
+## Isolation Levels
+
+```python
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+# Engine-level default
+engine = create_engine(
+    "postgresql://...",
+    isolation_level="REPEATABLE READ"  # Default for all sessions
+)
+
+
+# Per-session isolation
+async with async_session_factory() as session:
+    await session.connection(
+        execution_options={"isolation_level": "SERIALIZABLE"}
+    )
+    # This session uses SERIALIZABLE isolation
+
+
+# Transaction-level in raw SQL
+async with async_session_factory() as session:
+    await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"))
+    # Perform operations
+    await session.commit()
+```
+
+### Isolation Level Reference
+
+| Level | Dirty Read | Non-repeatable Read | Phantom Read |
+|-------|------------|---------------------|--------------|
+| READ UNCOMMITTED | Possible | Possible | Possible |
+| READ COMMITTED | No | Possible | Possible |
+| REPEATABLE READ | No | No | Possible* |
+| SERIALIZABLE | No | No | No |
+
+*PostgreSQL prevents phantoms in REPEATABLE READ
+
+## Optimistic Locking
+
+```python
+from sqlalchemy import Column, Integer
+from sqlalchemy.orm import Mapped, mapped_column
+
+class Account(Base):
+    __tablename__ = "accounts"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    balance: Mapped[int]
+    version: Mapped[int] = mapped_column(default=0)
+
+    __mapper_args__ = {"version_id_col": version}
+
+
+async def transfer_funds(from_id: int, to_id: int, amount: int):
+    """Transfer with optimistic locking."""
+    async with async_session_factory() as session:
+        from_account = await session.get(Account, from_id)
+        to_account = await session.get(Account, to_id)
+
+        if from_account.balance < amount:
+            raise InsufficientFunds()
+
+        from_account.balance -= amount
+        to_account.balance += amount
+
+        try:
+            await session.commit()
+        except StaleDataError:
+            # Concurrent modification detected
+            await session.rollback()
+            raise ConcurrentModificationError()
+```
+
+## Pessimistic Locking
+
+```python
+from sqlalchemy import select
+
+async def transfer_with_lock(from_id: int, to_id: int, amount: int):
+    """Transfer with row-level lock."""
+    async with async_session_factory() as session:
+        async with session.begin():
+            # Lock rows for update
+            stmt = (
+                select(Account)
+                .where(Account.id.in_([from_id, to_id]))
+                .with_for_update()  # SELECT ... FOR UPDATE
+            )
+            result = await session.execute(stmt)
+            accounts = {a.id: a for a in result.scalars()}
+
+            from_account = accounts[from_id]
+            to_account = accounts[to_id]
+
+            if from_account.balance < amount:
+                raise InsufficientFunds()
+
+            from_account.balance -= amount
+            to_account.balance += amount
+            # Commit releases locks
+
+
+# Lock with options
+stmt = select(Account).with_for_update(
+    nowait=True,     # Fail immediately if locked
+    skip_locked=True # Skip locked rows (for queue processing)
+)
+```
+
+## Retry on Serialization Failure
+
+```python
+from sqlalchemy.exc import OperationalError
+import asyncio
+
+async def retry_on_conflict(
+    func,
+    max_retries: int = 3,
+    base_delay: float = 0.1,
+):
+    """Retry transaction on serialization failure."""
+    for attempt in range(max_retries):
+        try:
+            return await func()
+        except OperationalError as e:
+            if "serialization" in str(e).lower() or "deadlock" in str(e).lower():
+                if attempt < max_retries - 1:
+                    delay = base_delay * (2 ** attempt)
+                    await asyncio.sleep(delay)
+                    continue
+            raise
+
+
+# Usage
+async def process_order(order_id: int):
+    async def _process():
+        async with async_session_factory() as session:
+            async with session.begin():
+                order = await session.get(Order, order_id)
+                order.status = "processed"
+                await session.commit()
+
+    await retry_on_conflict(_process)
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `session.begin()` | Auto-commit/rollback |
+| `session.begin_nested()` | Savepoint (partial rollback) |
+| `with_for_update()` | Row-level locking |
+| `version_id_col` | Optimistic concurrency |
+| Isolation levels | Control visibility |
+
+| Isolation Level | When to Use |
+|-----------------|-------------|
+| READ COMMITTED | Default, most apps |
+| REPEATABLE READ | Reports, analytics |
+| SERIALIZABLE | Financial, inventory |

+ 33 - 146
skills/python-env/SKILL.md

@@ -3,11 +3,13 @@ name: python-env
 description: "Fast Python environment management with uv (10-100x faster than pip). Triggers on: uv, venv, pip, pyproject, python environment, install package, dependencies."
 compatibility: "Requires uv CLI tool. Install: curl -LsSf https://astral.sh/uv/install.sh | sh"
 allowed-tools: "Bash"
+depends-on: []
+related-skills: []
 ---
 
 # Python Environment
 
-Fast Python environment management with uv (10-100x faster than pip).
+Fast Python environment management with uv.
 
 ## Quick Commands
 
@@ -28,12 +30,9 @@ uv venv
 # Create with specific Python
 uv venv --python 3.11
 
-# Activate
-# Windows: .venv\Scripts\activate
-# Unix: source .venv/bin/activate
-
-# Or skip activation and use uv run
-uv run python script.py
+# Activate (or use uv run)
+source .venv/bin/activate  # Unix
+.venv\Scripts\activate     # Windows
 ```
 
 ## Package Installation
@@ -51,16 +50,12 @@ uv pip install "fastapi[all]"
 # Version constraints
 uv pip install "django>=4.0,<5.0"
 
-# From requirements
-uv pip install -r requirements.txt
-
 # Uninstall
 uv pip uninstall requests
 ```
 
-## pyproject.toml Configuration
+## Minimal pyproject.toml
 
-### Minimal Project
 ```toml
 [project]
 name = "my-project"
@@ -78,149 +73,24 @@ dev = [
 ]
 ```
 
-### With Build System
-```toml
-[build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
-
-[project]
-name = "my-package"
-version = "0.1.0"
-requires-python = ">=3.10"
-dependencies = [
-    "httpx>=0.25",
-]
-
-[project.optional-dependencies]
-dev = ["pytest", "ruff", "mypy"]
-docs = ["mkdocs", "mkdocs-material"]
-
-[project.scripts]
-my-cli = "my_package.cli:main"
-```
-
-### With Tool Configuration
-```toml
-[tool.ruff]
-line-length = 100
-target-version = "py310"
-
-[tool.ruff.lint]
-select = ["E", "F", "I", "UP"]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-asyncio_mode = "auto"
-
-[tool.mypy]
-python_version = "3.10"
-strict = true
-```
-
-## Dependency Management
-
-### Lock File Workflow
-```bash
-# Create requirements.in with loose constraints
-echo "flask>=2.0" > requirements.in
-echo "sqlalchemy>=2.0" >> requirements.in
-
-# Generate locked requirements.txt
-uv pip compile requirements.in -o requirements.txt
-
-# Install exact versions
-uv pip sync requirements.txt
-
-# Update locks
-uv pip compile requirements.in -o requirements.txt --upgrade
-```
-
-### Dev Dependencies Pattern
-```bash
-# requirements.in (production)
-flask>=2.0
-sqlalchemy>=2.0
-
-# requirements-dev.in
--r requirements.in
-pytest>=7.0
-ruff>=0.1
-
-# Compile both
-uv pip compile requirements.in -o requirements.txt
-uv pip compile requirements-dev.in -o requirements-dev.txt
-```
-
-## Workspace/Monorepo
-
-```toml
-# pyproject.toml (root)
-[tool.uv.workspace]
-members = ["packages/*"]
-
-# packages/core/pyproject.toml
-[project]
-name = "my-core"
-version = "0.1.0"
-
-# packages/api/pyproject.toml
-[project]
-name = "my-api"
-version = "0.1.0"
-dependencies = ["my-core"]
-```
-
-```bash
-# Install all workspace packages
-uv pip install -e packages/core -e packages/api
-```
-
-## Running Scripts
+## Project Setup Checklist
 
 ```bash
-# Run with project's Python
-uv run python script.py
-
-# Run with specific Python version
-uv run --python 3.11 python script.py
-
-# Run module
-uv run python -m pytest
-
-# Run installed CLI
-uv run ruff check .
+mkdir my-project && cd my-project
+uv venv
+# Create pyproject.toml
+uv pip install -e ".[dev]"
+uv pip list
 ```
 
 ## Troubleshooting
 
 | Issue | Solution |
 |-------|----------|
-| "No Python found" | `uv python install 3.11` or install from python.org |
-| Wrong Python version | `uv venv --python 3.11` to force version |
+| "No Python found" | `uv python install 3.11` |
+| Wrong Python version | `uv venv --python 3.11` |
 | Conflicting deps | `uv pip compile --resolver=backtracking` |
 | Cache issues | `uv cache clean` |
-| SSL errors | `uv pip install --cert /path/to/cert pkg` |
-
-## Project Setup Checklist
-
-```bash
-# 1. Create project structure
-mkdir my-project && cd my-project
-mkdir src tests
-
-# 2. Create venv
-uv venv
-
-# 3. Create pyproject.toml (see templates above)
-
-# 4. Install dependencies
-uv pip install -e ".[dev]"
-
-# 5. Verify
-uv pip list
-uv run python -c "import my_package"
-```
 
 ## When to Use
 
@@ -229,4 +99,21 @@ uv run python -c "import my_package"
 - Installing packages
 - Managing dependencies
 - Running scripts in project context
-- Compiling lockfiles
+
+## Additional Resources
+
+For detailed patterns, load:
+- `./references/pyproject-patterns.md` - Full pyproject.toml examples, tool configs
+- `./references/dependency-management.md` - Lock files, workspaces, private packages
+- `./references/publishing.md` - PyPI publishing, versioning, CI/CD
+
+---
+
+## See Also
+
+This is a **foundation skill** with no prerequisites.
+
+**Build on this skill:**
+- `python-typing-patterns` - Type hints for projects
+- `python-pytest-patterns` - Testing infrastructure
+- `python-fastapi-patterns` - Web API development

+ 297 - 0
skills/python-env/references/dependency-management.md

@@ -0,0 +1,297 @@
+# Python Dependency Management
+
+Advanced patterns for managing Python dependencies with uv.
+
+## Lock File Workflow
+
+### Basic Lock Pattern
+
+```bash
+# requirements.in (loose constraints)
+flask>=2.0
+sqlalchemy>=2.0
+pydantic>=2.0
+
+# Generate locked requirements.txt
+uv pip compile requirements.in -o requirements.txt
+
+# Install exact versions
+uv pip sync requirements.txt
+```
+
+### Separate Dev Dependencies
+
+```bash
+# requirements.in
+flask>=2.0
+sqlalchemy>=2.0
+
+# requirements-dev.in
+-r requirements.in
+pytest>=7.0
+ruff>=0.1
+mypy>=1.0
+
+# Compile both
+uv pip compile requirements.in -o requirements.txt
+uv pip compile requirements-dev.in -o requirements-dev.txt
+
+# Install for development
+uv pip sync requirements-dev.txt
+```
+
+### Update Workflow
+
+```bash
+# Update all packages to latest compatible versions
+uv pip compile requirements.in -o requirements.txt --upgrade
+
+# Update specific package
+uv pip compile requirements.in -o requirements.txt --upgrade-package flask
+
+# Update with constraints
+uv pip compile requirements.in -o requirements.txt --upgrade --constraint constraints.txt
+```
+
+## Constraint Files
+
+```bash
+# constraints.txt
+# Pin versions that need to be consistent across projects
+numpy==1.26.0
+pandas==2.0.0
+
+# Use constraints during compile
+uv pip compile requirements.in -o requirements.txt --constraint constraints.txt
+```
+
+## Multiple Environments
+
+### Python Version Specific
+
+```bash
+# Python 3.10
+uv pip compile requirements.in -o requirements-py310.txt --python-version 3.10
+
+# Python 3.11
+uv pip compile requirements.in -o requirements-py311.txt --python-version 3.11
+```
+
+### Platform Specific
+
+```bash
+# Linux
+uv pip compile requirements.in -o requirements-linux.txt --platform linux
+
+# macOS
+uv pip compile requirements.in -o requirements-macos.txt --platform macos
+
+# Windows
+uv pip compile requirements.in -o requirements-windows.txt --platform windows
+```
+
+## Workspace/Monorepo
+
+### Structure
+
+```
+my-monorepo/
+├── pyproject.toml        # Root workspace config
+├── packages/
+│   ├── core/
+│   │   └── pyproject.toml
+│   ├── api/
+│   │   └── pyproject.toml
+│   └── cli/
+│       └── pyproject.toml
+```
+
+### Root pyproject.toml
+
+```toml
+[tool.uv.workspace]
+members = ["packages/*"]
+
+[tool.uv.sources]
+my-core = { workspace = true }
+my-api = { workspace = true }
+```
+
+### Package pyproject.toml
+
+```toml
+# packages/core/pyproject.toml
+[project]
+name = "my-core"
+version = "0.1.0"
+dependencies = ["pydantic>=2.0"]
+
+# packages/api/pyproject.toml
+[project]
+name = "my-api"
+version = "0.1.0"
+dependencies = ["my-core", "fastapi>=0.100"]
+```
+
+### Workspace Commands
+
+```bash
+# Install all workspace packages
+uv pip install -e packages/core -e packages/api -e packages/cli
+
+# Sync entire workspace
+uv sync
+
+# Run command in workspace context
+uv run pytest
+```
+
+## Private Packages
+
+### Configure Index
+
+```bash
+# Extra index for private packages
+uv pip install my-private-package --extra-index-url https://pypi.private.com/simple/
+
+# With authentication
+uv pip install my-private-package \
+  --extra-index-url https://user:token@pypi.private.com/simple/
+```
+
+### In requirements.in
+
+```
+--extra-index-url https://pypi.private.com/simple/
+my-public-package>=1.0
+my-private-package>=2.0
+```
+
+### Environment Variable
+
+```bash
+export UV_EXTRA_INDEX_URL=https://user:token@pypi.private.com/simple/
+uv pip install my-private-package
+```
+
+## Git Dependencies
+
+```toml
+# In pyproject.toml
+[project]
+dependencies = [
+    "my-package @ git+https://github.com/user/repo.git",
+    "my-package @ git+https://github.com/user/repo.git@v1.0.0",
+    "my-package @ git+https://github.com/user/repo.git@main",
+    "my-package @ git+ssh://git@github.com/user/repo.git",
+]
+```
+
+```bash
+# requirements.in
+git+https://github.com/user/repo.git@main#egg=my-package
+```
+
+## Local Dependencies
+
+```toml
+# In pyproject.toml
+[project]
+dependencies = [
+    "my-local @ file:///path/to/package",
+]
+
+# Relative path
+[tool.uv.sources]
+my-local = { path = "../my-local-package" }
+```
+
+## Dependency Resolution
+
+### Resolver Options
+
+```bash
+# Use backtracking resolver (more thorough but slower)
+uv pip compile requirements.in -o requirements.txt --resolver=backtracking
+
+# Allow prereleases
+uv pip compile requirements.in -o requirements.txt --prerelease=allow
+
+# Exclude specific packages from upgrade
+uv pip compile requirements.in -o requirements.txt --upgrade --no-upgrade-package numpy
+```
+
+### Resolution Troubleshooting
+
+```bash
+# Show why a version was chosen
+uv pip compile requirements.in --verbose
+
+# Generate dependency tree
+uv pip tree
+
+# Check for conflicts
+uv pip check
+```
+
+## Caching
+
+```bash
+# Clear uv cache
+uv cache clean
+
+# Show cache location
+uv cache dir
+
+# Disable cache for one command
+uv pip install --no-cache package-name
+```
+
+## CI/CD Patterns
+
+### GitHub Actions
+
+```yaml
+- name: Install uv
+  uses: astral-sh/setup-uv@v1
+  with:
+    version: "latest"
+
+- name: Install dependencies
+  run: |
+    uv venv
+    uv pip sync requirements.txt
+
+- name: Run tests
+  run: uv run pytest
+```
+
+### Cache Dependencies
+
+```yaml
+- name: Cache uv
+  uses: actions/cache@v3
+  with:
+    path: ~/.cache/uv
+    key: uv-${{ hashFiles('requirements.txt') }}
+    restore-keys: uv-
+```
+
+### Lock File in CI
+
+```yaml
+- name: Verify lock file is up to date
+  run: |
+    uv pip compile requirements.in -o requirements-check.txt
+    diff requirements.txt requirements-check.txt
+```
+
+## Best Practices
+
+1. **Always use lock files in production** - Reproducible builds
+2. **Separate dev dependencies** - Smaller production installs
+3. **Use constraints for shared deps** - Consistent versions across packages
+4. **Pin Python version** - Avoid compatibility surprises
+5. **Run `uv pip check`** - Catch conflicts early
+6. **Cache in CI** - Faster builds
+7. **Review upgrades carefully** - Don't blindly `--upgrade`

+ 270 - 0
skills/python-env/references/publishing.md

@@ -0,0 +1,270 @@
+# Publishing Python Packages
+
+Publish packages to PyPI with modern tooling.
+
+## pyproject.toml for Publishing
+
+```toml
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "my-package"
+version = "0.1.0"
+description = "My awesome package"
+readme = "README.md"
+license = {file = "LICENSE"}
+authors = [
+    {name = "Your Name", email = "you@example.com"},
+]
+keywords = ["keyword1", "keyword2"]
+classifiers = [
+    "Development Status :: 4 - Beta",
+    "Intended Audience :: Developers",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+]
+requires-python = ">=3.10"
+dependencies = [
+    "requests>=2.28",
+    "pydantic>=2.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=7.0",
+    "pytest-cov>=4.0",
+    "ruff>=0.1",
+    "mypy>=1.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/username/my-package"
+Documentation = "https://my-package.readthedocs.io"
+Repository = "https://github.com/username/my-package"
+Changelog = "https://github.com/username/my-package/blob/main/CHANGELOG.md"
+
+[project.scripts]
+my-command = "my_package.cli:main"
+
+[project.entry-points."my_package.plugins"]
+plugin1 = "my_package.plugins:Plugin1"
+```
+
+## Build and Upload
+
+```bash
+# Install build tools
+uv pip install build twine
+
+# Build package
+python -m build
+
+# Check build artifacts
+ls dist/
+# my_package-0.1.0-py3-none-any.whl
+# my_package-0.1.0.tar.gz
+
+# Check distribution
+twine check dist/*
+
+# Upload to TestPyPI first
+twine upload --repository testpypi dist/*
+
+# Test installation from TestPyPI
+uv pip install --index-url https://test.pypi.org/simple/ my-package
+
+# Upload to PyPI (production)
+twine upload dist/*
+```
+
+## Version Management
+
+### Option 1: Manual version
+
+```toml
+[project]
+version = "0.1.0"
+```
+
+### Option 2: Dynamic from __init__.py
+
+```toml
+[project]
+dynamic = ["version"]
+
+[tool.hatch.version]
+path = "src/my_package/__init__.py"
+```
+
+```python
+# src/my_package/__init__.py
+__version__ = "0.1.0"
+```
+
+### Option 3: Git tags with hatch-vcs
+
+```toml
+[project]
+dynamic = ["version"]
+
+[tool.hatch.version]
+source = "vcs"
+
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+```
+
+```bash
+# Create version tag
+git tag -a v0.1.0 -m "Release 0.1.0"
+git push origin v0.1.0
+```
+
+## Semantic Versioning
+
+```
+MAJOR.MINOR.PATCH
+
+Examples:
+0.1.0 - Initial development
+0.2.0 - New features (minor)
+0.2.1 - Bug fixes (patch)
+1.0.0 - First stable release
+1.1.0 - New features, backwards compatible
+2.0.0 - Breaking changes
+```
+
+## Changelog (CHANGELOG.md)
+
+```markdown
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+### Added
+- New feature X
+
+### Changed
+- Updated dependency Y
+
+### Fixed
+- Bug in Z
+
+## [0.1.0] - 2024-01-15
+
+### Added
+- Initial release
+- Core functionality
+
+[Unreleased]: https://github.com/user/repo/compare/v0.1.0...HEAD
+[0.1.0]: https://github.com/user/repo/releases/tag/v0.1.0
+```
+
+## GitHub Actions CI/CD
+
+```yaml
+# .github/workflows/publish.yml
+name: Publish to PyPI
+
+on:
+  release:
+    types: [published]
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write  # For trusted publishing
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
+
+      - name: Install build tools
+        run: pip install build
+
+      - name: Build package
+        run: python -m build
+
+      - name: Publish to PyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+        # Uses trusted publishing - no token needed
+```
+
+## PyPI Configuration
+
+### ~/.pypirc (for twine)
+
+```ini
+[pypi]
+username = __token__
+password = pypi-xxxx...
+
+[testpypi]
+repository = https://test.pypi.org/legacy/
+username = __token__
+password = pypi-xxxx...
+```
+
+### Trusted Publishing (Recommended)
+
+1. Go to PyPI → Your project → Publishing
+2. Add new trusted publisher
+3. Set GitHub repo and workflow file
+4. No API token needed in CI
+
+## Source Distribution Layout
+
+```
+my-package/
+├── pyproject.toml
+├── README.md
+├── LICENSE
+├── CHANGELOG.md
+├── src/
+│   └── my_package/
+│       ├── __init__.py
+│       └── core.py
+└── tests/
+    └── test_core.py
+```
+
+## Quick Reference
+
+| Command | Purpose |
+|---------|---------|
+| `python -m build` | Build wheel and sdist |
+| `twine check dist/*` | Verify package |
+| `twine upload dist/*` | Upload to PyPI |
+| `twine upload --repository testpypi dist/*` | Upload to TestPyPI |
+
+| Version | When |
+|---------|------|
+| 0.x.x | Initial development |
+| x.0.0 | Breaking changes |
+| x.x.0 | New features |
+| x.x.x | Bug fixes |
+
+## Checklist Before Publishing
+
+```markdown
+- [ ] Version updated in pyproject.toml
+- [ ] CHANGELOG.md updated
+- [ ] README.md current
+- [ ] All tests passing
+- [ ] Type checks passing
+- [ ] Build succeeds locally
+- [ ] TestPyPI upload works
+- [ ] Installation from TestPyPI works
+```

+ 334 - 0
skills/python-env/references/pyproject-patterns.md

@@ -0,0 +1,334 @@
+# pyproject.toml Patterns
+
+Comprehensive patterns for Python project configuration.
+
+## Minimal Project
+
+```toml
+[project]
+name = "my-project"
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+    "httpx>=0.25",
+    "pydantic>=2.0",
+]
+```
+
+## Standard Library Project
+
+```toml
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "my-package"
+version = "0.1.0"
+description = "A short description of the project"
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.10"
+authors = [
+    { name = "Your Name", email = "you@example.com" }
+]
+keywords = ["keyword1", "keyword2"]
+classifiers = [
+    "Development Status :: 3 - Alpha",
+    "Intended Audience :: Developers",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+]
+
+dependencies = [
+    "httpx>=0.25",
+    "pydantic>=2.0",
+    "rich>=13.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=7.0",
+    "pytest-asyncio>=0.21",
+    "pytest-cov>=4.0",
+    "ruff>=0.1",
+    "mypy>=1.0",
+]
+docs = [
+    "mkdocs>=1.5",
+    "mkdocs-material>=9.0",
+]
+
+[project.scripts]
+my-cli = "my_package.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/username/my-package"
+Documentation = "https://my-package.readthedocs.io"
+Repository = "https://github.com/username/my-package"
+Issues = "https://github.com/username/my-package/issues"
+```
+
+## CLI Application
+
+```toml
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "my-cli"
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+    "typer>=0.9",
+    "rich>=13.0",
+]
+
+[project.scripts]
+mycli = "my_cli.main:app"
+
+[project.optional-dependencies]
+dev = ["pytest", "ruff"]
+```
+
+## FastAPI Application
+
+```toml
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "my-api"
+version = "0.1.0"
+requires-python = ">=3.10"
+dependencies = [
+    "fastapi>=0.100",
+    "uvicorn[standard]>=0.23",
+    "pydantic>=2.0",
+    "sqlalchemy>=2.0",
+    "alembic>=1.12",
+    "python-dotenv>=1.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=7.0",
+    "pytest-asyncio>=0.21",
+    "httpx>=0.25",  # for testing
+    "ruff>=0.1",
+    "mypy>=1.0",
+]
+```
+
+## Tool Configurations
+
+### Ruff (Linting + Formatting)
+
+```toml
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+exclude = [
+    ".git",
+    ".venv",
+    "__pycache__",
+    "dist",
+    "build",
+]
+
+[tool.ruff.lint]
+select = [
+    "E",   # pycodestyle errors
+    "W",   # pycodestyle warnings
+    "F",   # pyflakes
+    "I",   # isort
+    "UP",  # pyupgrade
+    "B",   # flake8-bugbear
+    "C4",  # flake8-comprehensions
+    "DTZ", # flake8-datetimez
+    "T10", # flake8-debugger
+    "FA",  # flake8-future-annotations
+    "ISC", # flake8-implicit-str-concat
+    "PIE", # flake8-pie
+    "PT",  # flake8-pytest-style
+    "Q",   # flake8-quotes
+    "RSE", # flake8-raise
+    "RET", # flake8-return
+    "SIM", # flake8-simplify
+    "TID", # flake8-tidy-imports
+    "TCH", # flake8-type-checking
+    "ARG", # flake8-unused-arguments
+    "PTH", # flake8-use-pathlib
+    "RUF", # Ruff-specific rules
+]
+ignore = [
+    "E501",  # line too long (handled by formatter)
+    "B008",  # do not perform function calls in argument defaults
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["S101"]  # Allow assert in tests
+
+[tool.ruff.lint.isort]
+known-first-party = ["my_package"]
+```
+
+### pytest
+
+```toml
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = [
+    "-ra",
+    "-q",
+    "--strict-markers",
+    "--strict-config",
+]
+markers = [
+    "slow: marks tests as slow",
+    "integration: marks tests as integration tests",
+]
+asyncio_mode = "auto"
+filterwarnings = [
+    "error",
+    "ignore::DeprecationWarning",
+]
+```
+
+### mypy
+
+```toml
+[tool.mypy]
+python_version = "3.10"
+strict = true
+warn_return_any = true
+warn_unused_ignores = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+check_untyped_defs = true
+disallow_untyped_decorators = true
+no_implicit_optional = true
+warn_redundant_casts = true
+warn_unreachable = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+
+[[tool.mypy.overrides]]
+module = ["httpx.*", "pydantic.*"]
+ignore_missing_imports = true
+```
+
+### Coverage
+
+```toml
+[tool.coverage.run]
+source = ["my_package"]
+branch = true
+omit = [
+    "*/__pycache__/*",
+    "*/tests/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    "def __repr__",
+    "raise NotImplementedError",
+    "if TYPE_CHECKING:",
+    "if __name__ == .__main__.:",
+]
+fail_under = 80
+show_missing = true
+```
+
+## Build Systems
+
+### Hatchling (Recommended)
+
+```toml
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/my_package"]
+
+[tool.hatch.version]
+path = "src/my_package/__init__.py"
+```
+
+### Setuptools
+
+```toml
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+```
+
+### Poetry (pyproject.toml native)
+
+```toml
+[tool.poetry]
+name = "my-package"
+version = "0.1.0"
+description = ""
+authors = ["Your Name <you@example.com>"]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.10"
+httpx = "^0.25"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^7.0"
+ruff = "^0.1"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+```
+
+## Version Management
+
+### Static Version
+
+```toml
+[project]
+version = "0.1.0"
+```
+
+### Dynamic Version (from file)
+
+```toml
+[project]
+dynamic = ["version"]
+
+[tool.hatch.version]
+path = "src/my_package/__init__.py"
+# Reads: __version__ = "0.1.0"
+```
+
+### Dynamic Version (from VCS)
+
+```toml
+[project]
+dynamic = ["version"]
+
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.build.hooks.vcs]
+version-file = "src/my_package/_version.py"
+```

+ 206 - 0
skills/python-fastapi-patterns/SKILL.md

@@ -0,0 +1,206 @@
+---
+name: python-fastapi-patterns
+description: "FastAPI web framework patterns. Triggers on: fastapi, api endpoint, dependency injection, pydantic model, openapi, swagger, starlette, async api, rest api, uvicorn."
+compatibility: "FastAPI 0.100+, Pydantic v2, Python 3.10+. Requires uvicorn for production."
+allowed-tools: "Read Write Bash"
+depends-on: [python-typing-patterns, python-async-patterns]
+related-skills: [python-database-patterns, python-observability-patterns, python-pytest-patterns]
+---
+
+# FastAPI Patterns
+
+Modern async API development with FastAPI.
+
+## Basic Application
+
+```python
+from fastapi import FastAPI
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """Application lifespan - startup and shutdown."""
+    # Startup
+    app.state.db = await create_db_pool()
+    yield
+    # Shutdown
+    await app.state.db.close()
+
+app = FastAPI(
+    title="My API",
+    version="1.0.0",
+    lifespan=lifespan,
+)
+
+@app.get("/")
+async def root():
+    return {"message": "Hello World"}
+```
+
+## Request/Response Models
+
+```python
+from pydantic import BaseModel, Field, EmailStr
+from datetime import datetime
+
+class UserCreate(BaseModel):
+    """Request model with validation."""
+    name: str = Field(..., min_length=1, max_length=100)
+    email: EmailStr
+    age: int = Field(..., ge=0, le=150)
+
+class UserResponse(BaseModel):
+    """Response model."""
+    id: int
+    name: str
+    email: EmailStr
+    created_at: datetime
+
+    model_config = {"from_attributes": True}  # Enable ORM mode
+
+@app.post("/users", response_model=UserResponse, status_code=201)
+async def create_user(user: UserCreate):
+    db_user = await create_user_in_db(user)
+    return db_user
+```
+
+## Path and Query Parameters
+
+```python
+from fastapi import Query, Path
+from typing import Annotated
+
+@app.get("/users/{user_id}")
+async def get_user(
+    user_id: Annotated[int, Path(..., ge=1, description="User ID")],
+):
+    return await fetch_user(user_id)
+
+@app.get("/users")
+async def list_users(
+    skip: Annotated[int, Query(ge=0)] = 0,
+    limit: Annotated[int, Query(ge=1, le=100)] = 10,
+    search: str | None = None,
+):
+    return await fetch_users(skip=skip, limit=limit, search=search)
+```
+
+## Dependency Injection
+
+```python
+from fastapi import Depends
+from typing import Annotated
+
+async def get_db():
+    """Database session dependency."""
+    async with async_session() as session:
+        yield session
+
+async def get_current_user(
+    token: Annotated[str, Depends(oauth2_scheme)],
+    db: Annotated[AsyncSession, Depends(get_db)],
+) -> User:
+    """Authenticate and return current user."""
+    user = await authenticate_token(db, token)
+    if not user:
+        raise HTTPException(status_code=401, detail="Invalid token")
+    return user
+
+# Annotated types for reuse
+DB = Annotated[AsyncSession, Depends(get_db)]
+CurrentUser = Annotated[User, Depends(get_current_user)]
+
+@app.get("/me")
+async def get_me(user: CurrentUser):
+    return user
+```
+
+## Exception Handling
+
+```python
+from fastapi import HTTPException
+from fastapi.responses import JSONResponse
+
+# Built-in HTTP exceptions
+@app.get("/items/{item_id}")
+async def get_item(item_id: int):
+    item = await fetch_item(item_id)
+    if not item:
+        raise HTTPException(status_code=404, detail="Item not found")
+    return item
+
+# Custom exception handler
+class ItemNotFoundError(Exception):
+    def __init__(self, item_id: int):
+        self.item_id = item_id
+
+@app.exception_handler(ItemNotFoundError)
+async def item_not_found_handler(request, exc: ItemNotFoundError):
+    return JSONResponse(
+        status_code=404,
+        content={"detail": f"Item {exc.item_id} not found"},
+    )
+```
+
+## Router Organization
+
+```python
+from fastapi import APIRouter
+
+# users.py
+router = APIRouter(prefix="/users", tags=["users"])
+
+@router.get("/")
+async def list_users():
+    return []
+
+@router.get("/{user_id}")
+async def get_user(user_id: int):
+    return {"id": user_id}
+
+# main.py
+from app.routers import users, items
+
+app.include_router(users.router)
+app.include_router(items.router, prefix="/api/v1")
+```
+
+## Quick Reference
+
+| Feature | Usage |
+|---------|-------|
+| Path param | `@app.get("/items/{id}")` |
+| Query param | `def f(q: str = None)` |
+| Body | `def f(item: ItemCreate)` |
+| Dependency | `Depends(get_db)` |
+| Auth | `Depends(get_current_user)` |
+| Response model | `response_model=ItemResponse` |
+| Status code | `status_code=201` |
+
+## Additional Resources
+
+- `./references/dependency-injection.md` - Advanced DI patterns, scopes, caching
+- `./references/middleware-patterns.md` - Middleware chains, CORS, error handling
+- `./references/validation-serialization.md` - Pydantic v2 patterns, custom validators
+- `./references/background-tasks.md` - Background tasks, async workers, scheduling
+
+## Scripts
+
+- `./scripts/scaffold-api.sh` - Generate API endpoint boilerplate
+
+## Assets
+
+- `./assets/fastapi-template.py` - Production-ready FastAPI app skeleton
+
+---
+
+## See Also
+
+**Prerequisites:**
+- `python-typing-patterns` - Pydantic models and type hints
+- `python-async-patterns` - Async endpoint patterns
+
+**Related Skills:**
+- `python-database-patterns` - SQLAlchemy integration
+- `python-observability-patterns` - Logging, metrics, tracing middleware
+- `python-pytest-patterns` - API testing with TestClient

+ 180 - 0
skills/python-fastapi-patterns/assets/fastapi-template.py

@@ -0,0 +1,180 @@
+"""
+Production-ready FastAPI application template.
+
+Usage:
+    uvicorn main:app --reload  # Development
+    uvicorn main:app --host 0.0.0.0 --port 8000  # Production
+"""
+
+from contextlib import asynccontextmanager
+from typing import Annotated
+
+from fastapi import Depends, FastAPI, HTTPException, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+from pydantic_settings import BaseSettings
+
+
+# =============================================================================
+# Configuration
+# =============================================================================
+
+class Settings(BaseSettings):
+    """Application settings from environment variables."""
+
+    app_name: str = "My API"
+    debug: bool = False
+    database_url: str = "postgresql+asyncpg://user:pass@localhost/db"
+    redis_url: str = "redis://localhost:6379/0"
+    api_key: str = ""
+
+    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+
+
+# Cache settings
+from functools import lru_cache
+
+@lru_cache
+def get_settings() -> Settings:
+    return Settings()
+
+
+# =============================================================================
+# Lifespan Management
+# =============================================================================
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """Application startup and shutdown."""
+    settings = get_settings()
+
+    # Startup
+    # app.state.db = await create_db_pool(settings.database_url)
+    # app.state.redis = await create_redis_client(settings.redis_url)
+    print(f"Starting {settings.app_name}...")
+
+    yield
+
+    # Shutdown
+    # await app.state.db.close()
+    # await app.state.redis.close()
+    print("Shutting down...")
+
+
+# =============================================================================
+# Application
+# =============================================================================
+
+app = FastAPI(
+    title="My API",
+    version="1.0.0",
+    lifespan=lifespan,
+)
+
+# CORS
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"] if get_settings().debug else ["https://myapp.com"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+# =============================================================================
+# Error Handling
+# =============================================================================
+
+@app.exception_handler(Exception)
+async def global_exception_handler(request: Request, exc: Exception):
+    """Handle unhandled exceptions."""
+    return JSONResponse(
+        status_code=500,
+        content={"detail": "Internal server error"},
+    )
+
+
+# =============================================================================
+# Dependencies
+# =============================================================================
+
+async def get_db():
+    """Database session dependency."""
+    # async with async_session() as session:
+    #     yield session
+    yield None  # Placeholder
+
+
+DB = Annotated[None, Depends(get_db)]  # Replace None with actual type
+
+
+# =============================================================================
+# Models
+# =============================================================================
+
+class HealthResponse(BaseModel):
+    status: str
+    version: str
+
+
+class ItemCreate(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    description: str | None = None
+
+
+class ItemResponse(BaseModel):
+    id: int
+    name: str
+    description: str | None
+
+    model_config = {"from_attributes": True}
+
+
+# =============================================================================
+# Routes
+# =============================================================================
+
+@app.get("/health", response_model=HealthResponse)
+async def health_check():
+    """Health check endpoint."""
+    return HealthResponse(status="healthy", version="1.0.0")
+
+
+@app.get("/items", response_model=list[ItemResponse])
+async def list_items(
+    db: DB,
+    skip: int = 0,
+    limit: int = 10,
+):
+    """List all items."""
+    # items = await db.execute(select(Item).offset(skip).limit(limit))
+    # return items.scalars().all()
+    return []
+
+
+@app.post("/items", response_model=ItemResponse, status_code=201)
+async def create_item(item: ItemCreate, db: DB):
+    """Create a new item."""
+    # db_item = Item(**item.model_dump())
+    # db.add(db_item)
+    # await db.commit()
+    # await db.refresh(db_item)
+    # return db_item
+    return ItemResponse(id=1, name=item.name, description=item.description)
+
+
+@app.get("/items/{item_id}", response_model=ItemResponse)
+async def get_item(item_id: int, db: DB):
+    """Get a single item."""
+    # item = await db.get(Item, item_id)
+    # if not item:
+    #     raise HTTPException(status_code=404, detail="Item not found")
+    # return item
+    raise HTTPException(status_code=404, detail="Item not found")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

+ 324 - 0
skills/python-fastapi-patterns/references/background-tasks.md

@@ -0,0 +1,324 @@
+# FastAPI Background Tasks
+
+Async background processing patterns.
+
+## Built-in BackgroundTasks
+
+```python
+from fastapi import BackgroundTasks, FastAPI
+
+app = FastAPI()
+
+async def send_email(email: str, message: str):
+    """Background task - runs after response sent."""
+    # Simulate email sending
+    await asyncio.sleep(2)
+    print(f"Email sent to {email}: {message}")
+
+@app.post("/signup")
+async def signup(
+    email: str,
+    background_tasks: BackgroundTasks,
+):
+    # Create user synchronously
+    user = create_user(email)
+
+    # Queue background task
+    background_tasks.add_task(send_email, email, "Welcome!")
+
+    # Response sent immediately
+    return {"message": "User created"}
+
+
+# Multiple tasks
+@app.post("/order")
+async def create_order(
+    order: OrderCreate,
+    background_tasks: BackgroundTasks,
+):
+    db_order = save_order(order)
+
+    # Queue multiple tasks
+    background_tasks.add_task(send_confirmation, order.email)
+    background_tasks.add_task(update_inventory, order.items)
+    background_tasks.add_task(notify_warehouse, db_order.id)
+
+    return {"order_id": db_order.id}
+```
+
+## Dependency Injection with Background Tasks
+
+```python
+from fastapi import Depends, BackgroundTasks
+from typing import Annotated
+
+async def audit_log(action: str, user_id: int):
+    """Log user actions."""
+    await db.execute(
+        "INSERT INTO audit_log (action, user_id) VALUES ($1, $2)",
+        action, user_id
+    )
+
+def get_auditor(background_tasks: BackgroundTasks):
+    """Factory for audit logging."""
+    def log(action: str, user_id: int):
+        background_tasks.add_task(audit_log, action, user_id)
+    return log
+
+Auditor = Annotated[Callable, Depends(get_auditor)]
+
+@app.delete("/users/{user_id}")
+async def delete_user(
+    user_id: int,
+    current_user: CurrentUser,
+    auditor: Auditor,
+):
+    await db.delete_user(user_id)
+    auditor("user_deleted", current_user.id)
+    return {"deleted": user_id}
+```
+
+## Longer Tasks with Celery
+
+```python
+# tasks.py
+from celery import Celery
+
+celery_app = Celery(
+    "tasks",
+    broker="redis://localhost:6379/0",
+    backend="redis://localhost:6379/0",
+)
+
+@celery_app.task
+def process_video(video_id: int):
+    """Long-running task - handled by Celery worker."""
+    video = get_video(video_id)
+    processed = transcode(video)
+    save_processed(processed)
+    return {"status": "completed", "video_id": video_id}
+
+
+# api.py
+from fastapi import FastAPI
+from tasks import process_video
+
+app = FastAPI()
+
+@app.post("/videos/{video_id}/process")
+async def start_processing(video_id: int):
+    # Queue task in Celery
+    task = process_video.delay(video_id)
+
+    return {
+        "task_id": task.id,
+        "status": "queued",
+    }
+
+@app.get("/tasks/{task_id}")
+async def get_task_status(task_id: str):
+    task = process_video.AsyncResult(task_id)
+    return {
+        "task_id": task_id,
+        "status": task.status,
+        "result": task.result if task.ready() else None,
+    }
+```
+
+## Periodic Tasks with APScheduler
+
+```python
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.cron import CronTrigger
+from fastapi import FastAPI
+from contextlib import asynccontextmanager
+
+scheduler = AsyncIOScheduler()
+
+async def cleanup_expired_sessions():
+    """Run daily at midnight."""
+    await db.execute("DELETE FROM sessions WHERE expires < NOW()")
+
+async def send_daily_report():
+    """Run daily at 9 AM."""
+    report = await generate_report()
+    await send_email("admin@example.com", report)
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # Startup - configure scheduler
+    scheduler.add_job(
+        cleanup_expired_sessions,
+        CronTrigger(hour=0, minute=0),
+        id="cleanup_sessions",
+    )
+    scheduler.add_job(
+        send_daily_report,
+        CronTrigger(hour=9, minute=0),
+        id="daily_report",
+    )
+    scheduler.start()
+
+    yield
+
+    # Shutdown
+    scheduler.shutdown()
+
+app = FastAPI(lifespan=lifespan)
+
+
+# Manual trigger endpoint (for testing)
+@app.post("/admin/trigger/{job_id}")
+async def trigger_job(job_id: str, admin: AdminUser):
+    job = scheduler.get_job(job_id)
+    if not job:
+        raise HTTPException(status_code=404, detail="Job not found")
+
+    job.modify(next_run_time=datetime.now())
+    return {"message": f"Job {job_id} triggered"}
+```
+
+## Task Queues with Redis
+
+```python
+import redis.asyncio as redis
+import json
+from uuid import uuid4
+
+class TaskQueue:
+    def __init__(self, redis_url: str):
+        self.redis = redis.from_url(redis_url)
+        self.queue_name = "task_queue"
+
+    async def enqueue(self, task_type: str, payload: dict) -> str:
+        """Add task to queue."""
+        task_id = str(uuid4())
+        task = {
+            "id": task_id,
+            "type": task_type,
+            "payload": payload,
+            "status": "pending",
+        }
+        await self.redis.rpush(self.queue_name, json.dumps(task))
+        await self.redis.set(f"task:{task_id}", json.dumps(task))
+        return task_id
+
+    async def get_status(self, task_id: str) -> dict | None:
+        """Get task status."""
+        data = await self.redis.get(f"task:{task_id}")
+        return json.loads(data) if data else None
+
+
+# Worker (separate process)
+async def worker(queue: TaskQueue):
+    """Process tasks from queue."""
+    while True:
+        task_data = await queue.redis.blpop(queue.queue_name, timeout=1)
+        if not task_data:
+            continue
+
+        task = json.loads(task_data[1])
+        task["status"] = "processing"
+        await queue.redis.set(f"task:{task['id']}", json.dumps(task))
+
+        try:
+            result = await process_task(task)
+            task["status"] = "completed"
+            task["result"] = result
+        except Exception as e:
+            task["status"] = "failed"
+            task["error"] = str(e)
+
+        await queue.redis.set(f"task:{task['id']}", json.dumps(task))
+
+
+# API endpoints
+queue = TaskQueue("redis://localhost:6379")
+
+@app.post("/tasks")
+async def create_task(task_type: str, payload: dict):
+    task_id = await queue.enqueue(task_type, payload)
+    return {"task_id": task_id}
+
+@app.get("/tasks/{task_id}")
+async def get_task(task_id: str):
+    task = await queue.get_status(task_id)
+    if not task:
+        raise HTTPException(status_code=404)
+    return task
+```
+
+## Async Task Manager
+
+```python
+import asyncio
+from contextlib import asynccontextmanager
+from typing import Callable, Awaitable
+
+class TaskManager:
+    """Manage long-running async tasks."""
+
+    def __init__(self):
+        self._tasks: dict[str, asyncio.Task] = {}
+        self._results: dict[str, Any] = {}
+
+    async def start(self, task_id: str, coro: Awaitable):
+        """Start a named task."""
+        if task_id in self._tasks:
+            raise ValueError(f"Task {task_id} already running")
+
+        async def wrapper():
+            try:
+                result = await coro
+                self._results[task_id] = {"status": "completed", "result": result}
+            except Exception as e:
+                self._results[task_id] = {"status": "failed", "error": str(e)}
+            finally:
+                self._tasks.pop(task_id, None)
+
+        self._tasks[task_id] = asyncio.create_task(wrapper())
+        return task_id
+
+    def get_status(self, task_id: str) -> dict:
+        if task_id in self._tasks:
+            return {"status": "running"}
+        return self._results.get(task_id, {"status": "not_found"})
+
+    async def cancel(self, task_id: str) -> bool:
+        task = self._tasks.get(task_id)
+        if task:
+            task.cancel()
+            return True
+        return False
+
+
+# Global task manager
+task_manager = TaskManager()
+
+@app.post("/process")
+async def start_processing(data: ProcessRequest):
+    task_id = str(uuid4())
+    await task_manager.start(task_id, heavy_processing(data))
+    return {"task_id": task_id}
+
+@app.get("/process/{task_id}")
+async def get_processing_status(task_id: str):
+    return task_manager.get_status(task_id)
+```
+
+## Quick Reference
+
+| Method | Use Case | Runs Where |
+|--------|----------|------------|
+| `BackgroundTasks` | Quick async tasks | Same process |
+| Celery | Heavy processing | Worker process |
+| APScheduler | Periodic jobs | Same process |
+| Redis queue | Distributed tasks | Worker process |
+| `asyncio.Task` | In-memory async | Same process |
+
+| Pattern | Best For |
+|---------|----------|
+| BackgroundTasks | Email, webhooks, logging |
+| Celery | Video processing, ML, reports |
+| APScheduler | Cleanup, reports, sync |
+| Redis queue | Scalable task distribution |

+ 301 - 0
skills/python-fastapi-patterns/references/dependency-injection.md

@@ -0,0 +1,301 @@
+# FastAPI Dependency Injection Patterns
+
+Advanced patterns for managing dependencies in FastAPI.
+
+## Basic Dependencies
+
+```python
+from fastapi import Depends, FastAPI
+from typing import Annotated
+
+app = FastAPI()
+
+# Simple dependency
+async def get_db():
+    db = DatabaseSession()
+    try:
+        yield db
+    finally:
+        await db.close()
+
+# Use with Annotated for reusability
+DB = Annotated[DatabaseSession, Depends(get_db)]
+
+@app.get("/items")
+async def get_items(db: DB):
+    return await db.fetch_all("SELECT * FROM items")
+```
+
+## Dependency Hierarchy
+
+```python
+from fastapi import Depends, HTTPException, Header
+from typing import Annotated
+
+# Base dependency
+async def get_db():
+    async with async_session() as session:
+        yield session
+
+# Depends on get_db
+async def get_current_user(
+    db: Annotated[AsyncSession, Depends(get_db)],
+    token: Annotated[str, Header()],
+) -> User:
+    user = await db.execute(
+        select(User).where(User.token == token)
+    )
+    if not user:
+        raise HTTPException(status_code=401)
+    return user.scalar_one()
+
+# Depends on get_current_user
+async def get_admin_user(
+    user: Annotated[User, Depends(get_current_user)],
+) -> User:
+    if not user.is_admin:
+        raise HTTPException(status_code=403)
+    return user
+
+# Reusable annotated types
+DB = Annotated[AsyncSession, Depends(get_db)]
+CurrentUser = Annotated[User, Depends(get_current_user)]
+AdminUser = Annotated[User, Depends(get_admin_user)]
+
+@app.get("/admin/users")
+async def admin_list_users(admin: AdminUser, db: DB):
+    return await db.execute(select(User)).scalars().all()
+```
+
+## Class-Based Dependencies
+
+```python
+from dataclasses import dataclass
+from fastapi import Depends, Query
+from typing import Annotated
+
+@dataclass
+class Pagination:
+    """Reusable pagination parameters."""
+    skip: int = 0
+    limit: int = 10
+
+    def __init__(
+        self,
+        skip: Annotated[int, Query(ge=0)] = 0,
+        limit: Annotated[int, Query(ge=1, le=100)] = 10,
+    ):
+        self.skip = skip
+        self.limit = limit
+
+# Use as dependency
+@app.get("/items")
+async def list_items(pagination: Annotated[Pagination, Depends()]):
+    return await fetch_items(
+        skip=pagination.skip,
+        limit=pagination.limit
+    )
+
+
+# Class with injected dependencies
+class UserService:
+    def __init__(self, db: Annotated[AsyncSession, Depends(get_db)]):
+        self.db = db
+
+    async def get_user(self, user_id: int) -> User | None:
+        result = await self.db.execute(
+            select(User).where(User.id == user_id)
+        )
+        return result.scalar_one_or_none()
+
+@app.get("/users/{user_id}")
+async def get_user(
+    user_id: int,
+    service: Annotated[UserService, Depends()],
+):
+    user = await service.get_user(user_id)
+    if not user:
+        raise HTTPException(status_code=404)
+    return user
+```
+
+## Cached Dependencies
+
+```python
+from functools import lru_cache
+from pydantic_settings import BaseSettings
+
+class Settings(BaseSettings):
+    database_url: str
+    redis_url: str
+    api_key: str
+
+    model_config = {"env_file": ".env"}
+
+@lru_cache
+def get_settings() -> Settings:
+    """Cached settings - loaded once."""
+    return Settings()
+
+# Use in dependencies
+async def get_db(settings: Annotated[Settings, Depends(get_settings)]):
+    engine = create_async_engine(settings.database_url)
+    async with AsyncSession(engine) as session:
+        yield session
+```
+
+## Request-Scoped State
+
+```python
+from fastapi import Request
+from contextvars import ContextVar
+from uuid import uuid4
+
+# Context variable for request ID
+request_id_var: ContextVar[str] = ContextVar("request_id")
+
+@app.middleware("http")
+async def add_request_id(request: Request, call_next):
+    request_id = str(uuid4())
+    request_id_var.set(request_id)
+    request.state.request_id = request_id
+
+    response = await call_next(request)
+    response.headers["X-Request-ID"] = request_id
+    return response
+
+# Access in dependencies
+def get_request_id() -> str:
+    return request_id_var.get()
+
+@app.get("/trace")
+async def trace_request(request_id: Annotated[str, Depends(get_request_id)]):
+    return {"request_id": request_id}
+```
+
+## Dependency Overrides for Testing
+
+```python
+from fastapi.testclient import TestClient
+
+# Production dependency
+async def get_db():
+    async with async_session() as session:
+        yield session
+
+# Test override
+async def get_test_db():
+    async with test_session() as session:
+        yield session
+
+def test_create_user():
+    app.dependency_overrides[get_db] = get_test_db
+
+    with TestClient(app) as client:
+        response = client.post("/users", json={"name": "Test"})
+        assert response.status_code == 201
+
+    app.dependency_overrides.clear()
+
+
+# Context manager for cleaner tests
+from contextlib import contextmanager
+
+@contextmanager
+def override_dependency(original, replacement):
+    app.dependency_overrides[original] = replacement
+    try:
+        yield
+    finally:
+        app.dependency_overrides.pop(original, None)
+
+def test_with_override():
+    with override_dependency(get_db, get_test_db):
+        # Test code here
+        pass
+```
+
+## Parameterized Dependencies
+
+```python
+from fastapi import Depends
+from typing import Callable
+
+def require_permission(permission: str):
+    """Factory for permission-checking dependencies."""
+    async def check_permission(
+        user: Annotated[User, Depends(get_current_user)],
+    ):
+        if permission not in user.permissions:
+            raise HTTPException(
+                status_code=403,
+                detail=f"Missing permission: {permission}"
+            )
+        return user
+    return check_permission
+
+@app.delete("/items/{item_id}")
+async def delete_item(
+    item_id: int,
+    user: Annotated[User, Depends(require_permission("items:delete"))],
+):
+    return {"deleted": item_id}
+
+
+# Rate limiting factory
+def rate_limit(requests: int, window: int):
+    """Create rate limit dependency."""
+    async def check_rate(
+        request: Request,
+        redis: Annotated[Redis, Depends(get_redis)],
+    ):
+        key = f"rate:{request.client.host}"
+        count = await redis.incr(key)
+        if count == 1:
+            await redis.expire(key, window)
+        if count > requests:
+            raise HTTPException(status_code=429, detail="Rate limited")
+    return check_rate
+
+@app.get("/api/search")
+async def search(
+    q: str,
+    _: Annotated[None, Depends(rate_limit(requests=100, window=60))],
+):
+    return {"query": q}
+```
+
+## Sub-Application Dependencies
+
+```python
+from fastapi import FastAPI, Depends
+
+# Shared dependency for sub-app
+async def get_api_key(x_api_key: Annotated[str, Header()]):
+    if x_api_key != "secret":
+        raise HTTPException(status_code=401)
+    return x_api_key
+
+# Sub-application with its own dependencies
+api_v1 = FastAPI(dependencies=[Depends(get_api_key)])
+
+@api_v1.get("/items")
+async def list_items():
+    return []
+
+# Mount on main app
+app = FastAPI()
+app.mount("/api/v1", api_v1)
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `Annotated[T, Depends(f)]` | Reusable dependency type |
+| Class dependency | Group related params |
+| `@lru_cache` | Cache settings/config |
+| Dependency factory | Parameterized checks |
+| `dependency_overrides` | Testing isolation |
+| Hierarchy | Auth → User → Admin chain |
+| `ContextVar` | Request-scoped state |

+ 321 - 0
skills/python-fastapi-patterns/references/middleware-patterns.md

@@ -0,0 +1,321 @@
+# FastAPI Middleware Patterns
+
+Request/response processing, CORS, security, and error handling.
+
+## Basic Middleware
+
+```python
+from fastapi import FastAPI, Request
+from starlette.middleware.base import BaseHTTPMiddleware
+import time
+
+app = FastAPI()
+
+# Function-based middleware
+@app.middleware("http")
+async def add_timing_header(request: Request, call_next):
+    start = time.perf_counter()
+    response = await call_next(request)
+    duration = time.perf_counter() - start
+    response.headers["X-Process-Time"] = f"{duration:.4f}"
+    return response
+
+
+# Class-based middleware
+class TimingMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        start = time.perf_counter()
+        response = await call_next(request)
+        duration = time.perf_counter() - start
+        response.headers["X-Process-Time"] = f"{duration:.4f}"
+        return response
+
+app.add_middleware(TimingMiddleware)
+```
+
+## CORS Configuration
+
+```python
+from fastapi.middleware.cors import CORSMiddleware
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        "http://localhost:3000",
+        "https://myapp.com",
+    ],
+    allow_credentials=True,
+    allow_methods=["*"],  # Or specific: ["GET", "POST"]
+    allow_headers=["*"],
+    expose_headers=["X-Request-ID"],
+    max_age=600,  # Cache preflight for 10 minutes
+)
+
+# Development: allow all origins
+if settings.debug:
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+```
+
+## Security Headers
+
+```python
+from starlette.middleware.base import BaseHTTPMiddleware
+
+class SecurityHeadersMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        response = await call_next(request)
+
+        # Security headers
+        response.headers["X-Content-Type-Options"] = "nosniff"
+        response.headers["X-Frame-Options"] = "DENY"
+        response.headers["X-XSS-Protection"] = "1; mode=block"
+        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+
+        # CSP - customize for your app
+        response.headers["Content-Security-Policy"] = (
+            "default-src 'self'; "
+            "script-src 'self' 'unsafe-inline'; "
+            "style-src 'self' 'unsafe-inline'"
+        )
+
+        # HSTS (only in production with HTTPS)
+        if not request.url.scheme == "http":
+            response.headers["Strict-Transport-Security"] = (
+                "max-age=31536000; includeSubDomains"
+            )
+
+        return response
+
+app.add_middleware(SecurityHeadersMiddleware)
+```
+
+## Request ID Tracking
+
+```python
+from uuid import uuid4
+from contextvars import ContextVar
+
+request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
+
+class RequestIDMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        # Use existing or generate new
+        request_id = request.headers.get("X-Request-ID") or str(uuid4())
+
+        # Store in context for logging
+        request_id_ctx.set(request_id)
+        request.state.request_id = request_id
+
+        response = await call_next(request)
+        response.headers["X-Request-ID"] = request_id
+
+        return response
+
+app.add_middleware(RequestIDMiddleware)
+
+
+# Access in endpoints
+@app.get("/trace")
+async def trace(request: Request):
+    return {"request_id": request.state.request_id}
+```
+
+## Logging Middleware
+
+```python
+import logging
+import time
+from starlette.middleware.base import BaseHTTPMiddleware
+
+logger = logging.getLogger(__name__)
+
+class LoggingMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        start = time.perf_counter()
+
+        # Log request
+        logger.info(
+            "Request started",
+            extra={
+                "method": request.method,
+                "path": request.url.path,
+                "client": request.client.host if request.client else None,
+            }
+        )
+
+        response = await call_next(request)
+
+        # Log response
+        duration = time.perf_counter() - start
+        logger.info(
+            "Request completed",
+            extra={
+                "method": request.method,
+                "path": request.url.path,
+                "status": response.status_code,
+                "duration": f"{duration:.3f}s",
+            }
+        )
+
+        return response
+
+app.add_middleware(LoggingMiddleware)
+```
+
+## Error Handling Middleware
+
+```python
+from fastapi import Request
+from fastapi.responses import JSONResponse
+import traceback
+
+class ErrorHandlingMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        try:
+            return await call_next(request)
+        except Exception as exc:
+            # Log the full traceback
+            logger.exception(
+                "Unhandled exception",
+                extra={
+                    "path": request.url.path,
+                    "method": request.method,
+                    "traceback": traceback.format_exc(),
+                }
+            )
+
+            # Return generic error (hide details in production)
+            return JSONResponse(
+                status_code=500,
+                content={
+                    "detail": "Internal server error",
+                    "request_id": getattr(request.state, "request_id", None),
+                },
+            )
+
+app.add_middleware(ErrorHandlingMiddleware)
+```
+
+## Rate Limiting
+
+```python
+from collections import defaultdict
+from datetime import datetime, timedelta
+import asyncio
+
+class RateLimitMiddleware(BaseHTTPMiddleware):
+    def __init__(self, app, requests: int = 100, window: int = 60):
+        super().__init__(app)
+        self.requests = requests
+        self.window = window
+        self.clients: dict[str, list[datetime]] = defaultdict(list)
+        self.lock = asyncio.Lock()
+
+    async def dispatch(self, request: Request, call_next):
+        client_ip = request.client.host if request.client else "unknown"
+        now = datetime.now()
+        window_start = now - timedelta(seconds=self.window)
+
+        async with self.lock:
+            # Remove old requests
+            self.clients[client_ip] = [
+                t for t in self.clients[client_ip]
+                if t > window_start
+            ]
+
+            if len(self.clients[client_ip]) >= self.requests:
+                return JSONResponse(
+                    status_code=429,
+                    content={"detail": "Rate limit exceeded"},
+                    headers={
+                        "Retry-After": str(self.window),
+                        "X-RateLimit-Limit": str(self.requests),
+                        "X-RateLimit-Remaining": "0",
+                    },
+                )
+
+            self.clients[client_ip].append(now)
+            remaining = self.requests - len(self.clients[client_ip])
+
+        response = await call_next(request)
+        response.headers["X-RateLimit-Limit"] = str(self.requests)
+        response.headers["X-RateLimit-Remaining"] = str(remaining)
+        return response
+
+app.add_middleware(RateLimitMiddleware, requests=100, window=60)
+```
+
+## GZip Compression
+
+```python
+from fastapi.middleware.gzip import GZipMiddleware
+
+app.add_middleware(
+    GZipMiddleware,
+    minimum_size=1000,  # Only compress responses > 1KB
+)
+```
+
+## Trusted Host Validation
+
+```python
+from fastapi.middleware.trustedhost import TrustedHostMiddleware
+
+app.add_middleware(
+    TrustedHostMiddleware,
+    allowed_hosts=["example.com", "*.example.com"],
+)
+```
+
+## Middleware Order
+
+```python
+# Middleware executes in REVERSE order of addition
+# Last added = First to process request, Last to process response
+
+app = FastAPI()
+
+# 1. Error handling (outermost - catches all errors)
+app.add_middleware(ErrorHandlingMiddleware)
+
+# 2. Logging (log after error handling)
+app.add_middleware(LoggingMiddleware)
+
+# 3. Request ID (needed for logging)
+app.add_middleware(RequestIDMiddleware)
+
+# 4. Security (before business logic)
+app.add_middleware(SecurityHeadersMiddleware)
+
+# 5. CORS (needs to be early for preflight)
+app.add_middleware(CORSMiddleware, ...)
+
+# 6. GZip (compress final response)
+app.add_middleware(GZipMiddleware, minimum_size=1000)
+
+# Request flow: GZip → CORS → Security → RequestID → Logging → Error → App
+# Response flow: App → Error → Logging → RequestID → Security → CORS → GZip
+```
+
+## Quick Reference
+
+| Middleware | Purpose |
+|------------|---------|
+| `CORSMiddleware` | Cross-origin requests |
+| `GZipMiddleware` | Response compression |
+| `TrustedHostMiddleware` | Host validation |
+| `BaseHTTPMiddleware` | Custom middleware base |
+| `@app.middleware("http")` | Simple function middleware |
+
+| Order Position | Middleware Type |
+|----------------|-----------------|
+| First (outer) | Error handling |
+| Early | Logging, tracing |
+| Middle | Auth, rate limiting |
+| Late | CORS, compression |

+ 320 - 0
skills/python-fastapi-patterns/references/validation-serialization.md

@@ -0,0 +1,320 @@
+# Pydantic v2 Validation & Serialization
+
+Modern validation patterns for FastAPI with Pydantic v2.
+
+## Basic Models
+
+```python
+from pydantic import BaseModel, Field, EmailStr
+from datetime import datetime
+from typing import Annotated
+
+class UserCreate(BaseModel):
+    """Request model with field validation."""
+    name: str = Field(..., min_length=1, max_length=100)
+    email: EmailStr
+    age: int = Field(..., ge=0, le=150)
+    bio: str | None = Field(default=None, max_length=500)
+
+class UserResponse(BaseModel):
+    """Response model with ORM support."""
+    id: int
+    name: str
+    email: EmailStr
+    created_at: datetime
+
+    model_config = {"from_attributes": True}
+```
+
+## Custom Validators
+
+```python
+from pydantic import BaseModel, field_validator, model_validator
+from typing import Self
+
+class UserCreate(BaseModel):
+    username: str
+    password: str
+    password_confirm: str
+
+    @field_validator("username")
+    @classmethod
+    def validate_username(cls, v: str) -> str:
+        """Validate single field."""
+        if not v.isalnum():
+            raise ValueError("Username must be alphanumeric")
+        return v.lower()
+
+    @model_validator(mode="after")
+    def validate_passwords(self) -> Self:
+        """Validate across multiple fields."""
+        if self.password != self.password_confirm:
+            raise ValueError("Passwords don't match")
+        return self
+
+
+# Before validation (raw input)
+class Config(BaseModel):
+    port: int
+
+    @field_validator("port", mode="before")
+    @classmethod
+    def parse_port(cls, v):
+        """Convert string to int before validation."""
+        if isinstance(v, str):
+            return int(v)
+        return v
+```
+
+## Computed Fields
+
+```python
+from pydantic import BaseModel, computed_field
+from datetime import datetime
+
+class User(BaseModel):
+    first_name: str
+    last_name: str
+    birth_date: datetime
+
+    @computed_field
+    @property
+    def full_name(self) -> str:
+        return f"{self.first_name} {self.last_name}"
+
+    @computed_field
+    @property
+    def age(self) -> int:
+        today = datetime.now()
+        return today.year - self.birth_date.year
+```
+
+## Field Serialization
+
+```python
+from pydantic import BaseModel, field_serializer
+from datetime import datetime
+from decimal import Decimal
+
+class Order(BaseModel):
+    id: int
+    total: Decimal
+    created_at: datetime
+
+    @field_serializer("total")
+    def serialize_total(self, value: Decimal) -> str:
+        """Serialize Decimal as formatted string."""
+        return f"${value:.2f}"
+
+    @field_serializer("created_at")
+    def serialize_date(self, value: datetime) -> str:
+        """Serialize datetime as ISO string."""
+        return value.isoformat()
+
+
+# Or use Annotated with serialization
+from pydantic import PlainSerializer
+
+FormattedDecimal = Annotated[
+    Decimal,
+    PlainSerializer(lambda v: f"${v:.2f}", return_type=str)
+]
+
+class Order(BaseModel):
+    total: FormattedDecimal
+```
+
+## Custom Types
+
+```python
+from pydantic import BaseModel, GetCoreSchemaHandler
+from pydantic_core import CoreSchema, core_schema
+from typing import Any
+
+class PhoneNumber(str):
+    """Custom phone number type with validation."""
+
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls, source_type: Any, handler: GetCoreSchemaHandler
+    ) -> CoreSchema:
+        return core_schema.no_info_after_validator_function(
+            cls._validate,
+            core_schema.str_schema(),
+        )
+
+    @classmethod
+    def _validate(cls, v: str) -> "PhoneNumber":
+        # Remove non-digits
+        digits = "".join(c for c in v if c.isdigit())
+        if len(digits) != 10:
+            raise ValueError("Phone must be 10 digits")
+        return cls(f"({digits[:3]}) {digits[3:6]}-{digits[6:]}")
+
+
+class Contact(BaseModel):
+    name: str
+    phone: PhoneNumber
+
+# Usage
+contact = Contact(name="John", phone="1234567890")
+print(contact.phone)  # (123) 456-7890
+```
+
+## Nested Models
+
+```python
+from pydantic import BaseModel
+from datetime import datetime
+
+class Address(BaseModel):
+    street: str
+    city: str
+    country: str = "USA"
+
+class Company(BaseModel):
+    name: str
+    address: Address
+
+class UserResponse(BaseModel):
+    id: int
+    name: str
+    company: Company | None = None
+    addresses: list[Address] = []
+
+    model_config = {"from_attributes": True}
+```
+
+## Discriminated Unions
+
+```python
+from pydantic import BaseModel, Field
+from typing import Literal, Union
+from typing_extensions import Annotated
+
+class Dog(BaseModel):
+    pet_type: Literal["dog"]
+    name: str
+    breed: str
+
+class Cat(BaseModel):
+    pet_type: Literal["cat"]
+    name: str
+    indoor: bool = True
+
+# Use discriminator for efficient parsing
+Pet = Annotated[
+    Union[Dog, Cat],
+    Field(discriminator="pet_type")
+]
+
+class Owner(BaseModel):
+    name: str
+    pets: list[Pet]
+
+# FastAPI automatically validates
+@app.post("/owners")
+async def create_owner(owner: Owner):
+    return owner
+```
+
+## Model Inheritance
+
+```python
+from pydantic import BaseModel
+from datetime import datetime
+
+class BaseResponse(BaseModel):
+    """Base for all responses."""
+    model_config = {"from_attributes": True}
+
+class TimestampMixin(BaseModel):
+    """Mixin for timestamp fields."""
+    created_at: datetime
+    updated_at: datetime
+
+class UserBase(BaseModel):
+    name: str
+    email: str
+
+class UserCreate(UserBase):
+    password: str
+
+class UserResponse(UserBase, TimestampMixin, BaseResponse):
+    id: int
+```
+
+## Partial Updates (PATCH)
+
+```python
+from pydantic import BaseModel
+from typing import Any
+
+class UserUpdate(BaseModel):
+    """All fields optional for partial updates."""
+    name: str | None = None
+    email: str | None = None
+    bio: str | None = None
+
+@app.patch("/users/{user_id}")
+async def update_user(user_id: int, updates: UserUpdate):
+    # Only get set fields
+    update_data = updates.model_dump(exclude_unset=True)
+
+    # Apply to existing user
+    user = await get_user(user_id)
+    for field, value in update_data.items():
+        setattr(user, field, value)
+
+    return user
+```
+
+## Validation Error Handling
+
+```python
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from fastapi.exceptions import RequestValidationError
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(
+    request: Request,
+    exc: RequestValidationError
+):
+    """Custom validation error response."""
+    errors = []
+    for error in exc.errors():
+        errors.append({
+            "field": ".".join(str(loc) for loc in error["loc"]),
+            "message": error["msg"],
+            "type": error["type"],
+        })
+
+    return JSONResponse(
+        status_code=422,
+        content={
+            "detail": "Validation error",
+            "errors": errors,
+        },
+    )
+```
+
+## Quick Reference
+
+| Feature | Pydantic v2 |
+|---------|-------------|
+| ORM mode | `model_config = {"from_attributes": True}` |
+| Field validator | `@field_validator("field")` |
+| Model validator | `@model_validator(mode="after")` |
+| Serializer | `@field_serializer("field")` |
+| Computed | `@computed_field` + `@property` |
+| Exclude unset | `model_dump(exclude_unset=True)` |
+| Discriminator | `Field(discriminator="type")` |
+
+| Validation | Usage |
+|------------|-------|
+| Required | `name: str` |
+| Optional | `name: str \| None = None` |
+| Default | `name: str = "default"` |
+| Constraints | `Field(min_length=1, max_length=100)` |
+| Custom | `@field_validator` |

+ 122 - 0
skills/python-fastapi-patterns/scripts/scaffold-api.sh

@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# Generate FastAPI endpoint boilerplate
+#
+# Usage: scaffold-api.sh <resource_name>
+# Example: scaffold-api.sh user
+
+set -euo pipefail
+
+RESOURCE="${1:-}"
+
+if [[ -z "$RESOURCE" ]]; then
+    echo "Usage: scaffold-api.sh <resource_name>"
+    echo "Example: scaffold-api.sh user"
+    exit 1
+fi
+
+# Convert to different cases
+RESOURCE_LOWER=$(echo "$RESOURCE" | tr '[:upper:]' '[:lower:]')
+RESOURCE_UPPER=$(echo "$RESOURCE" | tr '[:lower:]' '[:upper:]')
+RESOURCE_TITLE=$(echo "$RESOURCE_LOWER" | sed 's/\b\(.\)/\u\1/g')
+RESOURCE_PLURAL="${RESOURCE_LOWER}s"
+
+cat << EOF
+# =============================================================================
+# ${RESOURCE_TITLE} Models
+# =============================================================================
+
+from pydantic import BaseModel, Field
+from datetime import datetime
+
+class ${RESOURCE_TITLE}Create(BaseModel):
+    """Create ${RESOURCE_LOWER} request."""
+    name: str = Field(..., min_length=1, max_length=100)
+    # Add more fields
+
+class ${RESOURCE_TITLE}Update(BaseModel):
+    """Update ${RESOURCE_LOWER} request (partial)."""
+    name: str | None = None
+    # Add more fields
+
+class ${RESOURCE_TITLE}Response(BaseModel):
+    """${RESOURCE_TITLE} response."""
+    id: int
+    name: str
+    created_at: datetime
+    updated_at: datetime
+
+    model_config = {"from_attributes": True}
+
+
+# =============================================================================
+# ${RESOURCE_TITLE} Router
+# =============================================================================
+
+from fastapi import APIRouter, Depends, HTTPException
+from typing import Annotated
+
+router = APIRouter(prefix="/${RESOURCE_PLURAL}", tags=["${RESOURCE_PLURAL}"])
+
+@router.get("/", response_model=list[${RESOURCE_TITLE}Response])
+async def list_${RESOURCE_PLURAL}(
+    db: DB,
+    skip: int = 0,
+    limit: int = 10,
+):
+    """List all ${RESOURCE_PLURAL}."""
+    result = await db.execute(
+        select(${RESOURCE_TITLE}).offset(skip).limit(limit)
+    )
+    return result.scalars().all()
+
+@router.post("/", response_model=${RESOURCE_TITLE}Response, status_code=201)
+async def create_${RESOURCE_LOWER}(data: ${RESOURCE_TITLE}Create, db: DB):
+    """Create a new ${RESOURCE_LOWER}."""
+    ${RESOURCE_LOWER} = ${RESOURCE_TITLE}(**data.model_dump())
+    db.add(${RESOURCE_LOWER})
+    await db.commit()
+    await db.refresh(${RESOURCE_LOWER})
+    return ${RESOURCE_LOWER}
+
+@router.get("/{${RESOURCE_LOWER}_id}", response_model=${RESOURCE_TITLE}Response)
+async def get_${RESOURCE_LOWER}(${RESOURCE_LOWER}_id: int, db: DB):
+    """Get a ${RESOURCE_LOWER} by ID."""
+    ${RESOURCE_LOWER} = await db.get(${RESOURCE_TITLE}, ${RESOURCE_LOWER}_id)
+    if not ${RESOURCE_LOWER}:
+        raise HTTPException(status_code=404, detail="${RESOURCE_TITLE} not found")
+    return ${RESOURCE_LOWER}
+
+@router.patch("/{${RESOURCE_LOWER}_id}", response_model=${RESOURCE_TITLE}Response)
+async def update_${RESOURCE_LOWER}(
+    ${RESOURCE_LOWER}_id: int,
+    data: ${RESOURCE_TITLE}Update,
+    db: DB,
+):
+    """Update a ${RESOURCE_LOWER}."""
+    ${RESOURCE_LOWER} = await db.get(${RESOURCE_TITLE}, ${RESOURCE_LOWER}_id)
+    if not ${RESOURCE_LOWER}:
+        raise HTTPException(status_code=404, detail="${RESOURCE_TITLE} not found")
+
+    for field, value in data.model_dump(exclude_unset=True).items():
+        setattr(${RESOURCE_LOWER}, field, value)
+
+    await db.commit()
+    await db.refresh(${RESOURCE_LOWER})
+    return ${RESOURCE_LOWER}
+
+@router.delete("/{${RESOURCE_LOWER}_id}", status_code=204)
+async def delete_${RESOURCE_LOWER}(${RESOURCE_LOWER}_id: int, db: DB):
+    """Delete a ${RESOURCE_LOWER}."""
+    ${RESOURCE_LOWER} = await db.get(${RESOURCE_TITLE}, ${RESOURCE_LOWER}_id)
+    if not ${RESOURCE_LOWER}:
+        raise HTTPException(status_code=404, detail="${RESOURCE_TITLE} not found")
+
+    await db.delete(${RESOURCE_LOWER})
+    await db.commit()
+
+# =============================================================================
+# Include in main app:
+# from routers.${RESOURCE_PLURAL} import router as ${RESOURCE_PLURAL}_router
+# app.include_router(${RESOURCE_PLURAL}_router, prefix="/api/v1")
+# =============================================================================
+EOF

+ 186 - 0
skills/python-observability-patterns/SKILL.md

@@ -0,0 +1,186 @@
+---
+name: python-observability-patterns
+description: "Observability patterns for Python applications. Triggers on: logging, metrics, tracing, opentelemetry, prometheus, observability, monitoring, structlog, correlation id."
+compatibility: "Python 3.10+. Requires structlog, opentelemetry-api, prometheus-client."
+allowed-tools: "Read Write"
+depends-on: [python-async-patterns]
+related-skills: [python-fastapi-patterns, python-cli-patterns]
+---
+
+# Python Observability Patterns
+
+Logging, metrics, and tracing for production applications.
+
+## Structured Logging with structlog
+
+```python
+import structlog
+
+# Configure structlog
+structlog.configure(
+    processors=[
+        structlog.contextvars.merge_contextvars,
+        structlog.processors.add_log_level,
+        structlog.processors.TimeStamper(fmt="iso"),
+        structlog.processors.JSONRenderer(),
+    ],
+    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
+    context_class=dict,
+    logger_factory=structlog.PrintLoggerFactory(),
+)
+
+logger = structlog.get_logger()
+
+# Usage
+logger.info("user_created", user_id=123, email="test@example.com")
+# Output: {"event": "user_created", "user_id": 123, "email": "test@example.com", "level": "info", "timestamp": "2024-01-15T10:00:00Z"}
+```
+
+## Request Context Propagation
+
+```python
+import structlog
+from contextvars import ContextVar
+from uuid import uuid4
+
+request_id_var: ContextVar[str] = ContextVar("request_id", default="")
+
+def bind_request_context(request_id: str | None = None):
+    """Bind request ID to logging context."""
+    rid = request_id or str(uuid4())
+    request_id_var.set(rid)
+    structlog.contextvars.bind_contextvars(request_id=rid)
+    return rid
+
+# FastAPI middleware
+@app.middleware("http")
+async def request_context_middleware(request, call_next):
+    request_id = request.headers.get("X-Request-ID") or str(uuid4())
+    bind_request_context(request_id)
+    response = await call_next(request)
+    response.headers["X-Request-ID"] = request_id
+    structlog.contextvars.clear_contextvars()
+    return response
+```
+
+## Prometheus Metrics
+
+```python
+from prometheus_client import Counter, Histogram, Gauge, generate_latest
+from fastapi import FastAPI, Response
+
+# Define metrics
+REQUEST_COUNT = Counter(
+    "http_requests_total",
+    "Total HTTP requests",
+    ["method", "endpoint", "status"]
+)
+
+REQUEST_LATENCY = Histogram(
+    "http_request_duration_seconds",
+    "HTTP request latency",
+    ["method", "endpoint"],
+    buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 5.0]
+)
+
+ACTIVE_CONNECTIONS = Gauge(
+    "active_connections",
+    "Number of active connections"
+)
+
+# Middleware to record metrics
+@app.middleware("http")
+async def metrics_middleware(request, call_next):
+    ACTIVE_CONNECTIONS.inc()
+    start = time.perf_counter()
+
+    response = await call_next(request)
+
+    duration = time.perf_counter() - start
+    REQUEST_COUNT.labels(
+        method=request.method,
+        endpoint=request.url.path,
+        status=response.status_code
+    ).inc()
+    REQUEST_LATENCY.labels(
+        method=request.method,
+        endpoint=request.url.path
+    ).observe(duration)
+    ACTIVE_CONNECTIONS.dec()
+
+    return response
+
+# Metrics endpoint
+@app.get("/metrics")
+async def metrics():
+    return Response(
+        content=generate_latest(),
+        media_type="text/plain"
+    )
+```
+
+## OpenTelemetry Tracing
+
+```python
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+
+# Setup
+provider = TracerProvider()
+processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="localhost:4317"))
+provider.add_span_processor(processor)
+trace.set_tracer_provider(provider)
+
+tracer = trace.get_tracer(__name__)
+
+# Manual instrumentation
+async def process_order(order_id: int):
+    with tracer.start_as_current_span("process_order") as span:
+        span.set_attribute("order_id", order_id)
+
+        with tracer.start_as_current_span("validate_order"):
+            await validate(order_id)
+
+        with tracer.start_as_current_span("charge_payment"):
+            await charge(order_id)
+```
+
+## Quick Reference
+
+| Library | Purpose |
+|---------|---------|
+| structlog | Structured logging |
+| prometheus-client | Metrics collection |
+| opentelemetry | Distributed tracing |
+
+| Metric Type | Use Case |
+|-------------|----------|
+| Counter | Total requests, errors |
+| Histogram | Latencies, sizes |
+| Gauge | Current connections, queue size |
+
+## Additional Resources
+
+- `./references/structured-logging.md` - structlog configuration, formatters
+- `./references/metrics.md` - Prometheus patterns, custom metrics
+- `./references/tracing.md` - OpenTelemetry, distributed tracing
+
+## Assets
+
+- `./assets/logging-config.py` - Production logging configuration
+
+---
+
+## See Also
+
+**Prerequisites:**
+- `python-async-patterns` - Async context propagation
+
+**Related Skills:**
+- `python-fastapi-patterns` - API middleware for metrics/tracing
+- `python-cli-patterns` - CLI logging patterns
+
+**Integration Skills:**
+- `python-database-patterns` - Database query tracing

+ 114 - 0
skills/python-observability-patterns/assets/logging-config.py

@@ -0,0 +1,114 @@
+"""
+Production logging configuration for Python applications.
+
+Usage:
+    from logging_config import configure_logging
+    configure_logging()
+"""
+
+import logging
+import sys
+from typing import Literal
+
+import structlog
+
+
+def configure_logging(
+    log_level: str = "INFO",
+    format: Literal["json", "console"] = "json",
+    service_name: str = "app",
+):
+    """
+    Configure structured logging for production.
+
+    Args:
+        log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
+        format: Output format - 'json' for production, 'console' for development
+        service_name: Service name to include in logs
+    """
+
+    # Timestamper
+    timestamper = structlog.processors.TimeStamper(fmt="iso")
+
+    # Shared processors for structlog and stdlib
+    shared_processors = [
+        structlog.contextvars.merge_contextvars,
+        structlog.stdlib.add_log_level,
+        structlog.stdlib.add_logger_name,
+        structlog.stdlib.PositionalArgumentsFormatter(),
+        timestamper,
+        structlog.processors.StackInfoRenderer(),
+        structlog.processors.UnicodeDecoder(),
+    ]
+
+    # Add service name
+    def add_service_name(_, __, event_dict):
+        event_dict["service"] = service_name
+        return event_dict
+
+    shared_processors.insert(0, add_service_name)
+
+    # Choose renderer based on format
+    if format == "json":
+        renderer = structlog.processors.JSONRenderer()
+    else:
+        renderer = structlog.dev.ConsoleRenderer(
+            colors=True,
+            exception_formatter=structlog.dev.plain_traceback,
+        )
+
+    # Configure structlog
+    structlog.configure(
+        processors=shared_processors + [
+            structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
+        ],
+        logger_factory=structlog.stdlib.LoggerFactory(),
+        wrapper_class=structlog.stdlib.BoundLogger,
+        cache_logger_on_first_use=True,
+    )
+
+    # Configure stdlib logging
+    formatter = structlog.stdlib.ProcessorFormatter(
+        foreign_pre_chain=shared_processors,
+        processors=[
+            structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+            renderer,
+        ],
+    )
+
+    handler = logging.StreamHandler(sys.stdout)
+    handler.setFormatter(formatter)
+
+    # Configure root logger
+    root_logger = logging.getLogger()
+    root_logger.handlers = []
+    root_logger.addHandler(handler)
+    root_logger.setLevel(log_level)
+
+    # Quiet noisy libraries
+    logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("httpcore").setLevel(logging.WARNING)
+    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+
+
+def get_logger(name: str = None):
+    """Get a structlog logger."""
+    return structlog.get_logger(name)
+
+
+# Example usage
+if __name__ == "__main__":
+    # Development
+    configure_logging(log_level="DEBUG", format="console", service_name="demo")
+
+    logger = get_logger("example")
+
+    logger.info("application_started", version="1.0.0")
+    logger.debug("debug_message", data={"key": "value"})
+    logger.warning("rate_limit_approaching", current=95, limit=100)
+
+    try:
+        raise ValueError("Something went wrong")
+    except Exception:
+        logger.exception("operation_failed")

+ 328 - 0
skills/python-observability-patterns/references/metrics.md

@@ -0,0 +1,328 @@
+# Prometheus Metrics Patterns
+
+Application metrics for monitoring and alerting.
+
+## Metric Types
+
+```python
+from prometheus_client import Counter, Histogram, Gauge, Summary, Info
+
+# Counter - only goes up (resets on restart)
+REQUEST_COUNT = Counter(
+    "http_requests_total",
+    "Total number of HTTP requests",
+    ["method", "endpoint", "status"]
+)
+
+# Histogram - distribution of values (latency, sizes)
+REQUEST_LATENCY = Histogram(
+    "http_request_duration_seconds",
+    "HTTP request latency in seconds",
+    ["method", "endpoint"],
+    buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
+)
+
+# Gauge - can go up and down (current state)
+ACTIVE_CONNECTIONS = Gauge(
+    "active_connections",
+    "Number of active connections"
+)
+
+IN_PROGRESS_REQUESTS = Gauge(
+    "in_progress_requests",
+    "Number of requests currently being processed",
+    ["endpoint"]
+)
+
+# Summary - like histogram but calculates quantiles client-side
+RESPONSE_SIZE = Summary(
+    "response_size_bytes",
+    "Response size in bytes",
+    ["endpoint"]
+)
+
+# Info - static labels (version, build info)
+APP_INFO = Info(
+    "app",
+    "Application information"
+)
+APP_INFO.info({"version": "1.0.0", "environment": "production"})
+```
+
+## FastAPI Integration
+
+```python
+from fastapi import FastAPI, Request, Response
+from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
+import time
+
+app = FastAPI()
+
+@app.middleware("http")
+async def metrics_middleware(request: Request, call_next):
+    """Record request metrics."""
+    # Track in-progress requests
+    endpoint = request.url.path
+    IN_PROGRESS_REQUESTS.labels(endpoint=endpoint).inc()
+
+    start = time.perf_counter()
+    response = await call_next(request)
+    duration = time.perf_counter() - start
+
+    # Record metrics
+    REQUEST_COUNT.labels(
+        method=request.method,
+        endpoint=endpoint,
+        status=response.status_code
+    ).inc()
+
+    REQUEST_LATENCY.labels(
+        method=request.method,
+        endpoint=endpoint
+    ).observe(duration)
+
+    IN_PROGRESS_REQUESTS.labels(endpoint=endpoint).dec()
+
+    return response
+
+
+@app.get("/metrics")
+async def metrics():
+    """Prometheus metrics endpoint."""
+    return Response(
+        content=generate_latest(),
+        media_type=CONTENT_TYPE_LATEST
+    )
+```
+
+## Business Metrics
+
+```python
+from prometheus_client import Counter, Histogram
+
+# User actions
+USER_SIGNUPS = Counter(
+    "user_signups_total",
+    "Total user signups",
+    ["source", "plan"]
+)
+
+USER_LOGINS = Counter(
+    "user_logins_total",
+    "Total user logins",
+    ["method"]  # oauth, password, token
+)
+
+# Orders
+ORDERS_CREATED = Counter(
+    "orders_created_total",
+    "Total orders created",
+    ["payment_method"]
+)
+
+ORDER_VALUE = Histogram(
+    "order_value_dollars",
+    "Order value distribution",
+    buckets=[10, 25, 50, 100, 250, 500, 1000, 2500, 5000]
+)
+
+# Errors by type
+ERRORS = Counter(
+    "errors_total",
+    "Total errors by type",
+    ["type", "endpoint"]
+)
+
+
+# Usage
+async def create_order(order: OrderCreate):
+    try:
+        result = await process_order(order)
+        ORDERS_CREATED.labels(payment_method=order.payment_method).inc()
+        ORDER_VALUE.observe(float(order.total))
+        return result
+    except PaymentError as e:
+        ERRORS.labels(type="payment", endpoint="/orders").inc()
+        raise
+```
+
+## Database Metrics
+
+```python
+from prometheus_client import Histogram, Counter, Gauge
+from contextlib import asynccontextmanager
+
+DB_QUERY_DURATION = Histogram(
+    "db_query_duration_seconds",
+    "Database query duration",
+    ["operation", "table"]
+)
+
+DB_CONNECTIONS_ACTIVE = Gauge(
+    "db_connections_active",
+    "Active database connections"
+)
+
+DB_CONNECTIONS_POOL = Gauge(
+    "db_connections_pool",
+    "Database connection pool size"
+)
+
+DB_ERRORS = Counter(
+    "db_errors_total",
+    "Database errors",
+    ["operation", "error_type"]
+)
+
+
+@asynccontextmanager
+async def timed_query(operation: str, table: str):
+    """Context manager to time database queries."""
+    start = time.perf_counter()
+    try:
+        yield
+    except Exception as e:
+        DB_ERRORS.labels(
+            operation=operation,
+            error_type=type(e).__name__
+        ).inc()
+        raise
+    finally:
+        duration = time.perf_counter() - start
+        DB_QUERY_DURATION.labels(
+            operation=operation,
+            table=table
+        ).observe(duration)
+
+
+# Usage
+async def get_user(user_id: int):
+    async with timed_query("select", "users"):
+        return await db.execute(select(User).where(User.id == user_id))
+```
+
+## Cache Metrics
+
+```python
+CACHE_HITS = Counter(
+    "cache_hits_total",
+    "Cache hits",
+    ["cache_name"]
+)
+
+CACHE_MISSES = Counter(
+    "cache_misses_total",
+    "Cache misses",
+    ["cache_name"]
+)
+
+CACHE_LATENCY = Histogram(
+    "cache_operation_duration_seconds",
+    "Cache operation latency",
+    ["cache_name", "operation"]
+)
+
+
+async def cached_get(key: str, fetch_func):
+    """Get from cache with metrics."""
+    start = time.perf_counter()
+    value = await cache.get(key)
+
+    if value is not None:
+        CACHE_HITS.labels(cache_name="redis").inc()
+        CACHE_LATENCY.labels(cache_name="redis", operation="get").observe(
+            time.perf_counter() - start
+        )
+        return value
+
+    CACHE_MISSES.labels(cache_name="redis").inc()
+
+    # Fetch and cache
+    value = await fetch_func()
+    await cache.set(key, value, ttl=300)
+
+    return value
+```
+
+## Custom Collectors
+
+```python
+from prometheus_client import Gauge
+from prometheus_client.core import GaugeMetricFamily, REGISTRY
+
+class QueueMetricsCollector:
+    """Collect queue metrics on demand."""
+
+    def collect(self):
+        # This runs when /metrics is scraped
+        queue_sizes = get_queue_sizes()  # Your function
+
+        gauge = GaugeMetricFamily(
+            "queue_size",
+            "Current queue size",
+            labels=["queue_name"]
+        )
+
+        for name, size in queue_sizes.items():
+            gauge.add_metric([name], size)
+
+        yield gauge
+
+
+# Register collector
+REGISTRY.register(QueueMetricsCollector())
+```
+
+## Decorators for Metrics
+
+```python
+from functools import wraps
+import time
+
+def count_calls(counter: Counter, labels: dict | None = None):
+    """Decorator to count function calls."""
+    def decorator(func):
+        @wraps(func)
+        async def wrapper(*args, **kwargs):
+            counter.labels(**(labels or {})).inc()
+            return await func(*args, **kwargs)
+        return wrapper
+    return decorator
+
+
+def time_calls(histogram: Histogram, labels: dict | None = None):
+    """Decorator to time function calls."""
+    def decorator(func):
+        @wraps(func)
+        async def wrapper(*args, **kwargs):
+            start = time.perf_counter()
+            try:
+                return await func(*args, **kwargs)
+            finally:
+                duration = time.perf_counter() - start
+                histogram.labels(**(labels or {})).observe(duration)
+        return wrapper
+    return decorator
+
+
+# Usage
+@count_calls(USER_SIGNUPS, {"source": "api", "plan": "free"})
+@time_calls(REQUEST_LATENCY, {"method": "POST", "endpoint": "/users"})
+async def create_user(user: UserCreate):
+    return await db.create_user(user)
+```
+
+## Quick Reference
+
+| Metric Type | Use Case | Example |
+|-------------|----------|---------|
+| Counter | Totals | Requests, errors, signups |
+| Histogram | Distributions | Latency, request size |
+| Gauge | Current state | Active connections, queue size |
+| Summary | Quantiles | Response times (p50, p99) |
+
+| Label Cardinality | Rule |
+|-------------------|------|
+| Good | method, endpoint, status |
+| Bad | user_id, request_id |
+| Limit | < 10 unique values per label |

+ 299 - 0
skills/python-observability-patterns/references/structured-logging.md

@@ -0,0 +1,299 @@
+# Structured Logging with structlog
+
+Production logging patterns for Python applications.
+
+## Basic Setup
+
+```python
+import logging
+import structlog
+import sys
+
+def configure_logging(json_output: bool = True, log_level: str = "INFO"):
+    """Configure structlog for production."""
+
+    # Shared processors for both stdlib and structlog
+    shared_processors = [
+        structlog.contextvars.merge_contextvars,
+        structlog.stdlib.add_log_level,
+        structlog.stdlib.add_logger_name,
+        structlog.stdlib.PositionalArgumentsFormatter(),
+        structlog.processors.TimeStamper(fmt="iso"),
+        structlog.processors.StackInfoRenderer(),
+        structlog.processors.UnicodeDecoder(),
+    ]
+
+    if json_output:
+        # Production: JSON output
+        renderer = structlog.processors.JSONRenderer()
+    else:
+        # Development: colored console output
+        renderer = structlog.dev.ConsoleRenderer(colors=True)
+
+    structlog.configure(
+        processors=shared_processors + [
+            structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
+        ],
+        logger_factory=structlog.stdlib.LoggerFactory(),
+        cache_logger_on_first_use=True,
+    )
+
+    # Configure standard library logging
+    handler = logging.StreamHandler(sys.stdout)
+    handler.setFormatter(structlog.stdlib.ProcessorFormatter(
+        foreign_pre_chain=shared_processors,
+        processors=[
+            structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+            renderer,
+        ],
+    ))
+
+    root_logger = logging.getLogger()
+    root_logger.addHandler(handler)
+    root_logger.setLevel(log_level)
+
+
+# Usage
+configure_logging(json_output=True, log_level="INFO")
+logger = structlog.get_logger()
+```
+
+## Context Variables
+
+```python
+import structlog
+from contextvars import ContextVar
+from uuid import uuid4
+
+# Request context
+request_id_var: ContextVar[str] = ContextVar("request_id", default="")
+user_id_var: ContextVar[int | None] = ContextVar("user_id", default=None)
+
+def bind_request_context(request_id: str | None = None, user_id: int | None = None):
+    """Bind context that will be included in all log messages."""
+    rid = request_id or str(uuid4())
+    request_id_var.set(rid)
+
+    context = {"request_id": rid}
+    if user_id:
+        user_id_var.set(user_id)
+        context["user_id"] = user_id
+
+    structlog.contextvars.bind_contextvars(**context)
+    return rid
+
+def clear_request_context():
+    """Clear context at end of request."""
+    structlog.contextvars.clear_contextvars()
+
+
+# FastAPI middleware
+from fastapi import Request
+
+@app.middleware("http")
+async def logging_middleware(request: Request, call_next):
+    # Extract or generate request ID
+    request_id = request.headers.get("X-Request-ID", str(uuid4()))
+    bind_request_context(request_id=request_id)
+
+    # Log request
+    logger.info(
+        "request_started",
+        method=request.method,
+        path=request.url.path,
+        client=request.client.host if request.client else None,
+    )
+
+    try:
+        response = await call_next(request)
+        logger.info(
+            "request_completed",
+            status_code=response.status_code,
+        )
+        response.headers["X-Request-ID"] = request_id
+        return response
+    except Exception as e:
+        logger.exception("request_failed", error=str(e))
+        raise
+    finally:
+        clear_request_context()
+```
+
+## Exception Logging
+
+```python
+import structlog
+
+logger = structlog.get_logger()
+
+# Log exception with context
+try:
+    result = risky_operation()
+except ValueError as e:
+    logger.error(
+        "operation_failed",
+        error=str(e),
+        error_type=type(e).__name__,
+    )
+    raise
+
+# Log with full traceback
+try:
+    result = another_operation()
+except Exception:
+    logger.exception("unexpected_error")  # Includes full traceback
+    raise
+
+
+# Custom exception with context
+class OrderError(Exception):
+    def __init__(self, message: str, order_id: int, **context):
+        super().__init__(message)
+        self.order_id = order_id
+        self.context = context
+
+try:
+    process_order(order_id=123)
+except OrderError as e:
+    logger.error(
+        "order_processing_failed",
+        order_id=e.order_id,
+        **e.context,
+    )
+```
+
+## Filtering Sensitive Data
+
+```python
+import structlog
+import re
+
+def filter_sensitive_data(_, __, event_dict):
+    """Remove sensitive data from logs."""
+    sensitive_keys = {"password", "token", "secret", "api_key", "authorization"}
+
+    def redact(data):
+        if isinstance(data, dict):
+            return {
+                k: "[REDACTED]" if k.lower() in sensitive_keys else redact(v)
+                for k, v in data.items()
+            }
+        elif isinstance(data, list):
+            return [redact(item) for item in data]
+        elif isinstance(data, str):
+            # Redact emails
+            return re.sub(
+                r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
+                '[EMAIL]',
+                data
+            )
+        return data
+
+    return redact(event_dict)
+
+
+structlog.configure(
+    processors=[
+        filter_sensitive_data,
+        structlog.processors.JSONRenderer(),
+    ],
+)
+```
+
+## Log Levels and Events
+
+```python
+logger = structlog.get_logger()
+
+# Use semantic event names
+logger.debug("cache_lookup", key="user:123", hit=True)
+logger.info("user_created", user_id=123, email="user@example.com")
+logger.warning("rate_limit_approaching", current=95, limit=100)
+logger.error("payment_failed", order_id=456, reason="insufficient_funds")
+logger.critical("database_connection_lost", host="db.example.com")
+
+# Business events
+logger.info("order_placed", order_id=789, total=99.99, items=3)
+logger.info("order_shipped", order_id=789, carrier="ups", tracking="1Z...")
+logger.info("user_login", user_id=123, method="oauth", provider="google")
+```
+
+## Integration with Third-Party Loggers
+
+```python
+import structlog
+import logging
+
+# Capture logs from libraries
+logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+logging.getLogger("httpx").setLevel(logging.WARNING)
+
+# Create a structlog-wrapped stdlib logger for compatibility
+def get_stdlib_logger(name: str):
+    """Get a structlog logger that works with libraries expecting stdlib."""
+    return structlog.wrap_logger(
+        logging.getLogger(name),
+        processors=[
+            structlog.stdlib.filter_by_level,
+            structlog.stdlib.add_logger_name,
+            structlog.stdlib.add_log_level,
+            structlog.processors.TimeStamper(fmt="iso"),
+            structlog.processors.JSONRenderer(),
+        ]
+    )
+```
+
+## Performance Logging
+
+```python
+import structlog
+import time
+from contextlib import contextmanager
+
+logger = structlog.get_logger()
+
+@contextmanager
+def log_duration(event: str, **context):
+    """Context manager to log operation duration."""
+    start = time.perf_counter()
+    try:
+        yield
+        duration = time.perf_counter() - start
+        logger.info(
+            event,
+            duration_ms=round(duration * 1000, 2),
+            status="success",
+            **context,
+        )
+    except Exception as e:
+        duration = time.perf_counter() - start
+        logger.error(
+            event,
+            duration_ms=round(duration * 1000, 2),
+            status="error",
+            error=str(e),
+            **context,
+        )
+        raise
+
+
+# Usage
+with log_duration("database_query", table="users"):
+    users = await db.fetch_users()
+```
+
+## Quick Reference
+
+| Function | Purpose |
+|----------|---------|
+| `structlog.get_logger()` | Get logger instance |
+| `bind_contextvars()` | Add context to all logs |
+| `clear_contextvars()` | Clear request context |
+| `logger.exception()` | Log with traceback |
+
+| Processor | Purpose |
+|-----------|---------|
+| `TimeStamper(fmt="iso")` | Add timestamp |
+| `add_log_level` | Add level field |
+| `JSONRenderer()` | Output as JSON |
+| `ConsoleRenderer()` | Pretty console output |

+ 281 - 0
skills/python-observability-patterns/references/tracing.md

@@ -0,0 +1,281 @@
+# Distributed Tracing with OpenTelemetry
+
+Trace requests across services for debugging and performance analysis.
+
+## Setup
+
+```python
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.sdk.resources import Resource
+
+# Create resource with service info
+resource = Resource.create({
+    "service.name": "my-service",
+    "service.version": "1.0.0",
+    "deployment.environment": "production",
+})
+
+# Create and configure tracer provider
+provider = TracerProvider(resource=resource)
+
+# Export to OTLP collector (Jaeger, Tempo, etc.)
+otlp_exporter = OTLPSpanExporter(
+    endpoint="http://localhost:4317",
+    insecure=True,
+)
+provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
+
+# Set as global tracer provider
+trace.set_tracer_provider(provider)
+
+# Get tracer for your module
+tracer = trace.get_tracer(__name__)
+```
+
+## FastAPI Auto-Instrumentation
+
+```python
+from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
+from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
+from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
+from opentelemetry.instrumentation.redis import RedisInstrumentor
+
+# Instrument FastAPI
+FastAPIInstrumentor.instrument_app(app)
+
+# Instrument HTTP client
+HTTPXClientInstrumentor().instrument()
+
+# Instrument database
+SQLAlchemyInstrumentor().instrument(engine=engine)
+
+# Instrument Redis
+RedisInstrumentor().instrument()
+```
+
+## Manual Instrumentation
+
+```python
+from opentelemetry import trace
+from opentelemetry.trace import Status, StatusCode
+
+tracer = trace.get_tracer(__name__)
+
+async def process_order(order_id: int):
+    """Process order with detailed tracing."""
+    with tracer.start_as_current_span("process_order") as span:
+        # Add attributes
+        span.set_attribute("order.id", order_id)
+        span.set_attribute("order.type", "standard")
+
+        # Nested spans
+        with tracer.start_as_current_span("validate_order"):
+            order = await validate(order_id)
+            span.set_attribute("order.items", len(order.items))
+
+        with tracer.start_as_current_span("check_inventory"):
+            await check_inventory(order.items)
+
+        with tracer.start_as_current_span("process_payment") as payment_span:
+            try:
+                result = await charge_payment(order)
+                payment_span.set_attribute("payment.amount", float(order.total))
+            except PaymentError as e:
+                payment_span.set_status(Status(StatusCode.ERROR, str(e)))
+                payment_span.record_exception(e)
+                raise
+
+        with tracer.start_as_current_span("send_confirmation"):
+            await send_email(order.customer_email)
+
+        span.set_status(Status(StatusCode.OK))
+        return order
+```
+
+## Context Propagation
+
+```python
+from opentelemetry import trace
+from opentelemetry.propagate import inject, extract
+from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
+
+propagator = TraceContextTextMapPropagator()
+
+# Inject context into outgoing HTTP headers
+async def call_external_service(data: dict):
+    headers = {}
+    inject(headers)  # Adds traceparent header
+
+    async with httpx.AsyncClient() as client:
+        response = await client.post(
+            "https://api.example.com/process",
+            json=data,
+            headers=headers,
+        )
+    return response.json()
+
+
+# Extract context from incoming request (usually handled by instrumentation)
+@app.middleware("http")
+async def trace_middleware(request: Request, call_next):
+    # Extract trace context from headers
+    ctx = extract(dict(request.headers))
+
+    with tracer.start_as_current_span(
+        f"{request.method} {request.url.path}",
+        context=ctx,
+    ):
+        return await call_next(request)
+```
+
+## Adding Events and Exceptions
+
+```python
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+async def process_with_events():
+    with tracer.start_as_current_span("process") as span:
+        # Add event (point-in-time occurrence)
+        span.add_event("processing_started", {
+            "items": 10,
+        })
+
+        try:
+            result = await heavy_processing()
+            span.add_event("processing_completed", {
+                "result_count": len(result),
+            })
+        except Exception as e:
+            # Record exception in span
+            span.record_exception(e)
+            span.set_status(Status(StatusCode.ERROR, str(e)))
+            raise
+
+        return result
+```
+
+## Span Decorator
+
+```python
+from functools import wraps
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+def traced(span_name: str | None = None, attributes: dict | None = None):
+    """Decorator to trace function execution."""
+    def decorator(func):
+        @wraps(func)
+        async def async_wrapper(*args, **kwargs):
+            name = span_name or f"{func.__module__}.{func.__name__}"
+            with tracer.start_as_current_span(name) as span:
+                if attributes:
+                    for key, value in attributes.items():
+                        span.set_attribute(key, value)
+                try:
+                    result = await func(*args, **kwargs)
+                    span.set_status(Status(StatusCode.OK))
+                    return result
+                except Exception as e:
+                    span.record_exception(e)
+                    span.set_status(Status(StatusCode.ERROR, str(e)))
+                    raise
+
+        @wraps(func)
+        def sync_wrapper(*args, **kwargs):
+            name = span_name or f"{func.__module__}.{func.__name__}"
+            with tracer.start_as_current_span(name) as span:
+                if attributes:
+                    for key, value in attributes.items():
+                        span.set_attribute(key, value)
+                try:
+                    result = func(*args, **kwargs)
+                    span.set_status(Status(StatusCode.OK))
+                    return result
+                except Exception as e:
+                    span.record_exception(e)
+                    span.set_status(Status(StatusCode.ERROR, str(e)))
+                    raise
+
+        if asyncio.iscoroutinefunction(func):
+            return async_wrapper
+        return sync_wrapper
+    return decorator
+
+
+# Usage
+@traced("user.create", {"component": "users"})
+async def create_user(user: UserCreate):
+    return await db.create(user)
+```
+
+## Linking Traces to Logs
+
+```python
+import structlog
+from opentelemetry import trace
+
+def add_trace_context(_, __, event_dict):
+    """Add trace context to log entries."""
+    span = trace.get_current_span()
+    if span.is_recording():
+        ctx = span.get_span_context()
+        event_dict["trace_id"] = format(ctx.trace_id, "032x")
+        event_dict["span_id"] = format(ctx.span_id, "016x")
+    return event_dict
+
+
+structlog.configure(
+    processors=[
+        add_trace_context,
+        structlog.processors.JSONRenderer(),
+    ],
+)
+```
+
+## Sampling
+
+```python
+from opentelemetry.sdk.trace.sampling import (
+    TraceIdRatioBased,
+    ParentBased,
+    ALWAYS_ON,
+)
+
+# Sample 10% of traces
+sampler = TraceIdRatioBased(0.1)
+
+# Respect parent's sampling decision, default to 10%
+sampler = ParentBased(root=TraceIdRatioBased(0.1))
+
+# Always sample (development)
+sampler = ALWAYS_ON
+
+provider = TracerProvider(
+    resource=resource,
+    sampler=sampler,
+)
+```
+
+## Quick Reference
+
+| Concept | Description |
+|---------|-------------|
+| Trace | Complete request journey |
+| Span | Single operation within trace |
+| Context | Propagated trace information |
+| Attribute | Key-value metadata on span |
+| Event | Point-in-time occurrence |
+
+| Instrumentation | Package |
+|-----------------|---------|
+| FastAPI | `opentelemetry-instrumentation-fastapi` |
+| httpx | `opentelemetry-instrumentation-httpx` |
+| SQLAlchemy | `opentelemetry-instrumentation-sqlalchemy` |
+| Redis | `opentelemetry-instrumentation-redis` |
+| Celery | `opentelemetry-instrumentation-celery` |

+ 201 - 0
skills/python-pytest-patterns/SKILL.md

@@ -0,0 +1,201 @@
+---
+name: python-pytest-patterns
+description: "pytest testing patterns for Python. Triggers on: pytest, fixture, mark, parametrize, mock, conftest, test coverage, unit test, integration test, pytest.raises."
+compatibility: "pytest 7.0+, Python 3.9+. Some features require pytest-asyncio, pytest-mock, pytest-cov."
+allowed-tools: "Read Write Bash"
+depends-on: []
+related-skills: [python-typing-patterns, python-async-patterns]
+---
+
+# Python pytest Patterns
+
+Modern pytest patterns for effective testing.
+
+## Basic Test Structure
+
+```python
+import pytest
+
+def test_basic():
+    """Simple assertion test."""
+    assert 1 + 1 == 2
+
+def test_with_description():
+    """Descriptive name and docstring."""
+    result = calculate_total([1, 2, 3])
+    assert result == 6, "Sum should equal 6"
+```
+
+## Fixtures
+
+```python
+import pytest
+
+@pytest.fixture
+def sample_user():
+    """Create test user."""
+    return {"id": 1, "name": "Test User"}
+
+@pytest.fixture
+def db_connection():
+    """Fixture with setup and teardown."""
+    conn = create_connection()
+    yield conn
+    conn.close()
+
+def test_user(sample_user):
+    """Fixtures injected by name."""
+    assert sample_user["name"] == "Test User"
+```
+
+### Fixture Scopes
+
+```python
+@pytest.fixture(scope="function")  # Default - per test
+@pytest.fixture(scope="class")     # Per test class
+@pytest.fixture(scope="module")    # Per test file
+@pytest.fixture(scope="session")   # Entire test run
+```
+
+## Parametrize
+
+```python
+@pytest.mark.parametrize("input,expected", [
+    (1, 2),
+    (2, 4),
+    (3, 6),
+])
+def test_double(input, expected):
+    assert double(input) == expected
+
+# Multiple parameters
+@pytest.mark.parametrize("x", [1, 2])
+@pytest.mark.parametrize("y", [10, 20])
+def test_multiply(x, y):  # 4 test combinations
+    assert x * y > 0
+```
+
+## Exception Testing
+
+```python
+def test_raises():
+    with pytest.raises(ValueError) as exc_info:
+        raise ValueError("Invalid input")
+    assert "Invalid" in str(exc_info.value)
+
+def test_raises_match():
+    with pytest.raises(ValueError, match=r".*[Ii]nvalid.*"):
+        raise ValueError("Invalid input")
+```
+
+## Markers
+
+```python
+@pytest.mark.skip(reason="Not implemented yet")
+def test_future_feature():
+    pass
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
+def test_unix_feature():
+    pass
+
+@pytest.mark.xfail(reason="Known bug")
+def test_buggy():
+    assert broken_function() == expected
+
+@pytest.mark.slow
+def test_performance():
+    """Custom marker - register in pytest.ini."""
+    pass
+```
+
+## Mocking
+
+```python
+from unittest.mock import Mock, patch, MagicMock
+
+def test_with_mock():
+    mock_api = Mock()
+    mock_api.get.return_value = {"status": "ok"}
+    result = mock_api.get("/endpoint")
+    assert result["status"] == "ok"
+
+@patch("module.external_api")
+def test_with_patch(mock_api):
+    mock_api.return_value = {"data": []}
+    result = function_using_api()
+    mock_api.assert_called_once()
+```
+
+### pytest-mock (Recommended)
+
+```python
+def test_with_mocker(mocker):
+    mock_api = mocker.patch("module.api_call")
+    mock_api.return_value = {"success": True}
+    result = process_data()
+    assert result["success"]
+```
+
+## conftest.py
+
+```python
+# tests/conftest.py - Shared fixtures
+
+import pytest
+
+@pytest.fixture(scope="session")
+def app():
+    """Application fixture available to all tests."""
+    return create_app(testing=True)
+
+@pytest.fixture
+def client(app):
+    """Test client fixture."""
+    return app.test_client()
+```
+
+## Quick Reference
+
+| Command | Description |
+|---------|-------------|
+| `pytest` | Run all tests |
+| `pytest -v` | Verbose output |
+| `pytest -x` | Stop on first failure |
+| `pytest -k "test_name"` | Run matching tests |
+| `pytest -m slow` | Run marked tests |
+| `pytest --lf` | Rerun last failed |
+| `pytest --cov=src` | Coverage report |
+| `pytest -n auto` | Parallel (pytest-xdist) |
+
+## Additional Resources
+
+- `./references/fixtures-advanced.md` - Factory fixtures, autouse, conftest patterns
+- `./references/mocking-patterns.md` - Mock, patch, MagicMock, side_effect
+- `./references/async-testing.md` - pytest-asyncio patterns
+- `./references/coverage-strategies.md` - pytest-cov, branch coverage, reports
+- `./references/integration-testing.md` - Database fixtures, API testing, testcontainers
+- `./references/property-testing.md` - Hypothesis framework, strategies, shrinking
+- `./references/test-architecture.md` - Test pyramid, organization, isolation strategies
+
+## Scripts
+
+- `./scripts/run-tests.sh` - Run tests with recommended options
+- `./scripts/generate-conftest.sh` - Generate conftest.py boilerplate
+
+## Assets
+
+- `./assets/pytest.ini.template` - Recommended pytest configuration
+- `./assets/conftest.py.template` - Common fixture patterns
+
+---
+
+## See Also
+
+**Related Skills:**
+- `python-typing-patterns` - Type-safe test code
+- `python-async-patterns` - Async test patterns (pytest-asyncio)
+
+**Testing specific frameworks:**
+- `python-fastapi-patterns` - TestClient, API testing
+- `python-database-patterns` - Database fixtures, transactions

+ 203 - 0
skills/python-pytest-patterns/assets/conftest.py.template

@@ -0,0 +1,203 @@
+"""
+Pytest configuration and fixtures.
+
+This is a template conftest.py with common patterns.
+Copy to tests/conftest.py and customize for your project.
+"""
+import pytest
+from typing import Generator, Any
+from pathlib import Path
+
+
+# ============================================================
+# Pytest Hooks
+# ============================================================
+
+def pytest_configure(config):
+    """Configure pytest with custom markers."""
+    config.addinivalue_line("markers", "slow: marks tests as slow")
+    config.addinivalue_line("markers", "integration: marks integration tests")
+    config.addinivalue_line("markers", "e2e: marks end-to-end tests")
+
+
+def pytest_addoption(parser):
+    """Add custom CLI options."""
+    parser.addoption(
+        "--slow",
+        action="store_true",
+        default=False,
+        help="Run slow tests",
+    )
+    parser.addoption(
+        "--integration",
+        action="store_true",
+        default=False,
+        help="Run integration tests",
+    )
+
+
+def pytest_collection_modifyitems(config, items):
+    """Modify test collection based on options."""
+    # Skip slow tests unless --slow
+    if not config.getoption("--slow"):
+        skip_slow = pytest.mark.skip(reason="use --slow to run")
+        for item in items:
+            if "slow" in item.keywords:
+                item.add_marker(skip_slow)
+
+    # Skip integration tests unless --integration
+    if not config.getoption("--integration"):
+        skip_integration = pytest.mark.skip(reason="use --integration to run")
+        for item in items:
+            if "integration" in item.keywords:
+                item.add_marker(skip_integration)
+
+
+# ============================================================
+# Session-Scoped Fixtures
+# ============================================================
+
+@pytest.fixture(scope="session")
+def project_root() -> Path:
+    """Return project root directory."""
+    return Path(__file__).parent.parent
+
+
+@pytest.fixture(scope="session")
+def test_data_dir(project_root: Path) -> Path:
+    """Return test data directory."""
+    return project_root / "tests" / "data"
+
+
+# ============================================================
+# Common Fixtures
+# ============================================================
+
+@pytest.fixture
+def sample_dict() -> dict[str, Any]:
+    """Provide sample dictionary for testing."""
+    return {
+        "id": 1,
+        "name": "Test Item",
+        "active": True,
+        "tags": ["a", "b", "c"],
+    }
+
+
+@pytest.fixture
+def temp_file(tmp_path: Path) -> Generator[Path, None, None]:
+    """Create temporary file with cleanup."""
+    file_path = tmp_path / "test_file.txt"
+    file_path.write_text("test content")
+    yield file_path
+    # Cleanup happens automatically via tmp_path
+
+
+@pytest.fixture
+def mock_env(monkeypatch) -> Generator[dict[str, str], None, None]:
+    """Set up mock environment variables."""
+    env_vars = {
+        "TEST_MODE": "true",
+        "API_KEY": "test-key-12345",
+        "DATABASE_URL": "sqlite:///:memory:",
+    }
+    for key, value in env_vars.items():
+        monkeypatch.setenv(key, value)
+    yield env_vars
+
+
+# ============================================================
+# Factory Fixtures
+# ============================================================
+
+@pytest.fixture
+def user_factory():
+    """Factory to create user objects with custom attributes."""
+    def _create_user(
+        id: int = 1,
+        name: str = "Test User",
+        email: str = "test@example.com",
+        active: bool = True,
+    ) -> dict[str, Any]:
+        return {
+            "id": id,
+            "name": name,
+            "email": email,
+            "active": active,
+        }
+    return _create_user
+
+
+# ============================================================
+# Database Fixtures (uncomment if using SQLAlchemy)
+# ============================================================
+
+# from sqlalchemy import create_engine
+# from sqlalchemy.orm import sessionmaker, Session
+# from myapp.database import Base
+#
+# @pytest.fixture(scope="session")
+# def db_engine():
+#     """Create test database engine."""
+#     engine = create_engine(
+#         "sqlite:///:memory:",
+#         echo=False,
+#     )
+#     Base.metadata.create_all(engine)
+#     yield engine
+#     engine.dispose()
+#
+# @pytest.fixture
+# def db_session(db_engine) -> Generator[Session, None, None]:
+#     """Create database session with rollback."""
+#     Session = sessionmaker(bind=db_engine)
+#     session = Session()
+#     yield session
+#     session.rollback()
+#     session.close()
+
+
+# ============================================================
+# API Fixtures (uncomment if using FastAPI)
+# ============================================================
+
+# from fastapi.testclient import TestClient
+# from myapp import create_app
+#
+# @pytest.fixture
+# def app():
+#     """Create test application."""
+#     return create_app(testing=True)
+#
+# @pytest.fixture
+# def client(app) -> Generator[TestClient, None, None]:
+#     """Create test client."""
+#     with TestClient(app) as client:
+#         yield client
+#
+# @pytest.fixture
+# def auth_client(client: TestClient) -> TestClient:
+#     """Create authenticated test client."""
+#     client.headers["Authorization"] = "Bearer test-token"
+#     return client
+
+
+# ============================================================
+# Async Fixtures (uncomment if using pytest-asyncio)
+# ============================================================
+
+# import asyncio
+# import aiohttp
+#
+# @pytest.fixture(scope="session")
+# def event_loop():
+#     """Create event loop for async tests."""
+#     loop = asyncio.new_event_loop()
+#     yield loop
+#     loop.close()
+#
+# @pytest.fixture
+# async def async_client():
+#     """Async HTTP client."""
+#     async with aiohttp.ClientSession() as session:
+#         yield session

+ 50 - 0
skills/python-pytest-patterns/assets/pytest.ini.template

@@ -0,0 +1,50 @@
+# pytest.ini - pytest configuration
+# Copy to your project root and customize
+
+[pytest]
+# Test discovery
+testpaths = tests
+python_files = test_*.py *_test.py
+python_classes = Test*
+python_functions = test_*
+
+# Output options
+addopts =
+    -v
+    --strict-markers
+    --tb=short
+    -ra
+
+# Async mode (pytest-asyncio)
+asyncio_mode = auto
+
+# Logging
+log_cli = true
+log_cli_level = WARNING
+log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
+
+# Custom markers
+markers =
+    slow: marks tests as slow (deselect with '-m "not slow"')
+    integration: marks integration tests
+    e2e: marks end-to-end tests
+    unit: marks unit tests
+
+# Warnings
+filterwarnings =
+    error
+    ignore::DeprecationWarning
+    ignore::PendingDeprecationWarning
+
+# Coverage (pytest-cov)
+# Uncomment to enable by default:
+# addopts = --cov=src --cov-report=term-missing
+
+# Minimum version
+minversion = 7.0
+
+# Timeout (pytest-timeout)
+# timeout = 60
+
+# Parallel execution (pytest-xdist)
+# addopts = -n auto

+ 298 - 0
skills/python-pytest-patterns/references/async-testing.md

@@ -0,0 +1,298 @@
+# Async Testing Patterns
+
+Testing asyncio code with pytest-asyncio.
+
+## Setup
+
+```bash
+pip install pytest-asyncio
+```
+
+```ini
+# pytest.ini or pyproject.toml
+[pytest]
+asyncio_mode = auto  # Recommended for pytest-asyncio 0.21+
+```
+
+## Basic Async Tests
+
+```python
+import pytest
+
+@pytest.mark.asyncio
+async def test_async_function():
+    result = await async_fetch_data()
+    assert result["status"] == "ok"
+
+@pytest.mark.asyncio
+async def test_async_context_manager():
+    async with AsyncResource() as resource:
+        result = await resource.get()
+        assert result is not None
+```
+
+## Async Fixtures
+
+```python
+import pytest
+import aiohttp
+
+@pytest.fixture
+async def async_client():
+    """Async fixture with cleanup."""
+    async with aiohttp.ClientSession() as session:
+        yield session
+    # Session closed automatically
+
+@pytest.fixture
+async def database():
+    """Async database fixture."""
+    conn = await create_async_connection()
+    await conn.execute("BEGIN")
+    yield conn
+    await conn.execute("ROLLBACK")
+    await conn.close()
+
+@pytest.mark.asyncio
+async def test_with_async_fixture(async_client):
+    async with async_client.get("https://httpbin.org/json") as resp:
+        data = await resp.json()
+        assert "slideshow" in data
+```
+
+## Fixture Scopes
+
+```python
+@pytest.fixture(scope="session")
+async def app():
+    """Session-scoped async fixture."""
+    app = await create_app()
+    yield app
+    await app.shutdown()
+
+@pytest.fixture(scope="module")
+async def db_pool():
+    """Module-scoped connection pool."""
+    pool = await asyncpg.create_pool(DATABASE_URL)
+    yield pool
+    await pool.close()
+```
+
+## Testing Timeouts
+
+```python
+import asyncio
+
+@pytest.mark.asyncio
+async def test_timeout():
+    with pytest.raises(asyncio.TimeoutError):
+        async with asyncio.timeout(0.1):
+            await asyncio.sleep(1.0)
+
+@pytest.mark.asyncio
+async def test_wait_for():
+    with pytest.raises(asyncio.TimeoutError):
+        await asyncio.wait_for(slow_operation(), timeout=0.1)
+```
+
+## Testing Cancellation
+
+```python
+@pytest.mark.asyncio
+async def test_task_cancellation():
+    task = asyncio.create_task(long_running_task())
+    await asyncio.sleep(0.01)
+    task.cancel()
+
+    with pytest.raises(asyncio.CancelledError):
+        await task
+
+@pytest.mark.asyncio
+async def test_graceful_cancellation():
+    """Test that cleanup runs on cancellation."""
+    cleanup_ran = False
+
+    async def task_with_cleanup():
+        nonlocal cleanup_ran
+        try:
+            await asyncio.sleep(10)
+        except asyncio.CancelledError:
+            cleanup_ran = True
+            raise
+
+    task = asyncio.create_task(task_with_cleanup())
+    await asyncio.sleep(0.01)
+    task.cancel()
+
+    with pytest.raises(asyncio.CancelledError):
+        await task
+
+    assert cleanup_ran
+```
+
+## Testing gather
+
+```python
+@pytest.mark.asyncio
+async def test_gather_success():
+    results = await asyncio.gather(
+        async_op_1(),
+        async_op_2(),
+        async_op_3(),
+    )
+    assert len(results) == 3
+
+@pytest.mark.asyncio
+async def test_gather_with_exceptions():
+    results = await asyncio.gather(
+        async_op_1(),
+        async_op_that_fails(),
+        async_op_3(),
+        return_exceptions=True
+    )
+    assert isinstance(results[1], Exception)
+```
+
+## Testing TaskGroup (Python 3.11+)
+
+```python
+@pytest.mark.asyncio
+async def test_task_group():
+    results = []
+
+    async with asyncio.TaskGroup() as tg:
+        tg.create_task(append_result(results, 1))
+        tg.create_task(append_result(results, 2))
+        tg.create_task(append_result(results, 3))
+
+    assert sorted(results) == [1, 2, 3]
+
+@pytest.mark.asyncio
+async def test_task_group_exception():
+    with pytest.raises(ExceptionGroup):
+        async with asyncio.TaskGroup() as tg:
+            tg.create_task(successful_task())
+            tg.create_task(failing_task())
+```
+
+## Mocking Async Functions
+
+```python
+from unittest.mock import AsyncMock
+
+@pytest.mark.asyncio
+async def test_mock_async_function(mocker):
+    mock = mocker.patch("mymodule.async_api_call", new_callable=AsyncMock)
+    mock.return_value = {"data": "mocked"}
+
+    result = await mymodule.fetch_data()
+
+    assert result == {"data": "mocked"}
+    mock.assert_awaited_once()
+
+@pytest.mark.asyncio
+async def test_async_side_effect(mocker):
+    mock = AsyncMock()
+    mock.side_effect = [
+        {"page": 1},
+        {"page": 2},
+        ValueError("No more pages"),
+    ]
+
+    assert await mock() == {"page": 1}
+    assert await mock() == {"page": 2}
+    with pytest.raises(ValueError):
+        await mock()
+```
+
+## Testing aiohttp
+
+```python
+import aiohttp
+from aiohttp import web
+import pytest
+
+@pytest.fixture
+async def app():
+    """Create aiohttp app."""
+    app = web.Application()
+    app.router.add_get("/", home_handler)
+    return app
+
+@pytest.fixture
+async def client(aiohttp_client, app):
+    """Create test client."""
+    return await aiohttp_client(app)
+
+@pytest.mark.asyncio
+async def test_endpoint(client):
+    resp = await client.get("/")
+    assert resp.status == 200
+    data = await resp.json()
+    assert "message" in data
+```
+
+## Testing WebSockets
+
+```python
+@pytest.mark.asyncio
+async def test_websocket(aiohttp_client, app):
+    client = await aiohttp_client(app)
+
+    async with client.ws_connect("/ws") as ws:
+        await ws.send_str("Hello")
+        msg = await ws.receive()
+        assert msg.type == aiohttp.WSMsgType.TEXT
+        assert msg.data == "Hello back"
+```
+
+## Event Loop Fixtures
+
+```python
+import pytest
+
+@pytest.fixture(scope="session")
+def event_loop_policy():
+    """Custom event loop policy."""
+    return asyncio.DefaultEventLoopPolicy()
+
+# For uvloop
+@pytest.fixture(scope="session")
+def event_loop_policy():
+    import uvloop
+    return uvloop.EventLoopPolicy()
+```
+
+## Testing Queues
+
+```python
+@pytest.mark.asyncio
+async def test_queue_producer_consumer():
+    queue = asyncio.Queue()
+    results = []
+
+    async def producer():
+        for i in range(3):
+            await queue.put(i)
+        await queue.put(None)  # Sentinel
+
+    async def consumer():
+        while True:
+            item = await queue.get()
+            if item is None:
+                break
+            results.append(item)
+
+    await asyncio.gather(producer(), consumer())
+    assert results == [0, 1, 2]
+```
+
+## Best Practices
+
+1. **Use `asyncio_mode = auto`** - Simplifies test marking
+2. **Scope fixtures appropriately** - Session for expensive resources
+3. **Use AsyncMock** - For mocking coroutines
+4. **Test cancellation** - Ensure cleanup happens
+5. **Test timeouts** - Verify timeout behavior
+6. **Avoid blocking calls** - Use `run_in_executor` if needed
+7. **Close resources** - Use async context managers

+ 300 - 0
skills/python-pytest-patterns/references/coverage-strategies.md

@@ -0,0 +1,300 @@
+# Coverage Strategies
+
+Comprehensive code coverage with pytest-cov.
+
+## Setup
+
+```bash
+pip install pytest-cov
+```
+
+## Basic Usage
+
+```bash
+# Run with coverage
+pytest --cov=src
+
+# With terminal report
+pytest --cov=src --cov-report=term
+
+# With HTML report
+pytest --cov=src --cov-report=html
+open htmlcov/index.html
+
+# Multiple formats
+pytest --cov=src --cov-report=term --cov-report=html --cov-report=xml
+```
+
+## Coverage Configuration
+
+### pyproject.toml
+
+```toml
+[tool.coverage.run]
+source = ["src"]
+branch = true
+omit = [
+    "*/tests/*",
+    "*/__init__.py",
+    "*/migrations/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    "def __repr__",
+    "raise NotImplementedError",
+    "if TYPE_CHECKING:",
+    "if __name__ == .__main__.:",
+]
+fail_under = 80
+show_missing = true
+
+[tool.coverage.html]
+directory = "htmlcov"
+```
+
+### .coveragerc (Alternative)
+
+```ini
+[run]
+source = src
+branch = true
+omit =
+    */tests/*
+    */__init__.py
+
+[report]
+exclude_lines =
+    pragma: no cover
+    raise NotImplementedError
+fail_under = 80
+
+[html]
+directory = htmlcov
+```
+
+## Branch Coverage
+
+```python
+# branch=true catches this
+def process(value):
+    if value > 0:
+        return "positive"
+    # Missing else branch without branch coverage
+    return "non-positive"
+
+# Test both branches
+def test_positive():
+    assert process(5) == "positive"
+
+def test_non_positive():
+    assert process(-1) == "non-positive"
+```
+
+## Excluding Code
+
+```python
+def debug_only():  # pragma: no cover
+    """Never executed in production."""
+    print("Debug info")
+
+if TYPE_CHECKING:  # Excluded by default config
+    from typing import Optional
+
+def platform_specific():
+    if sys.platform == "win32":  # pragma: no cover
+        return windows_implementation()
+    return unix_implementation()
+```
+
+## Coverage in CI
+
+### GitHub Actions
+
+```yaml
+name: Tests
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+
+      - name: Install dependencies
+        run: pip install -e .[test]
+
+      - name: Run tests with coverage
+        run: pytest --cov=src --cov-report=xml
+
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v4
+        with:
+          files: ./coverage.xml
+          fail_ci_if_error: true
+```
+
+### Fail on Low Coverage
+
+```bash
+# Fail if coverage below 80%
+pytest --cov=src --cov-fail-under=80
+```
+
+## Measuring Coverage of Specific Tests
+
+```bash
+# Coverage for specific test file
+pytest tests/test_api.py --cov=src/api
+
+# Coverage for marked tests only
+pytest -m "unit" --cov=src
+
+# Coverage for specific module
+pytest --cov=src/module_name
+```
+
+## Combining Coverage
+
+```bash
+# Run tests in parallel, combine coverage
+pytest -n auto --cov=src --cov-append
+
+# Or manually combine
+coverage combine
+coverage report
+```
+
+## Coverage Diff (Incremental)
+
+```bash
+# Show coverage for changed lines only (with diff-cover)
+pip install diff-cover
+
+pytest --cov=src --cov-report=xml
+diff-cover coverage.xml --compare-branch=origin/main
+```
+
+## Mutation Testing
+
+```bash
+# Beyond coverage: test quality with mutmut
+pip install mutmut
+
+# Run mutation testing
+mutmut run --paths-to-mutate=src/
+
+# View results
+mutmut results
+mutmut html
+```
+
+## Coverage Reports
+
+### Terminal Report
+
+```bash
+pytest --cov=src --cov-report=term-missing
+```
+
+Output:
+```
+Name                      Stmts   Miss Branch BrPart  Cover   Missing
+---------------------------------------------------------------------
+src/api.py                   50      5     12      2    88%   45-49, 67
+src/utils.py                 30      0      8      0   100%
+---------------------------------------------------------------------
+TOTAL                        80      5     20      2    92%
+```
+
+### HTML Report
+
+```bash
+pytest --cov=src --cov-report=html
+# Creates htmlcov/index.html with line-by-line highlighting
+```
+
+### XML Report (CI)
+
+```bash
+pytest --cov=src --cov-report=xml
+# Creates coverage.xml for CI tools
+```
+
+### JSON Report
+
+```bash
+pytest --cov=src --cov-report=json
+# Creates coverage.json for programmatic access
+```
+
+## Coverage Best Practices
+
+### 1. Aim for Meaningful Coverage
+
+```python
+# BAD: 100% coverage but no assertions
+def test_function():
+    result = my_function()  # Just call it
+
+# GOOD: Meaningful assertions
+def test_function():
+    result = my_function()
+    assert result.status == "success"
+    assert len(result.items) > 0
+```
+
+### 2. Don't Chase 100%
+
+```python
+# Some code genuinely shouldn't be tested
+def __repr__(self):  # pragma: no cover
+    return f"<User {self.name}>"
+
+if __name__ == "__main__":  # pragma: no cover
+    main()
+```
+
+### 3. Focus on Critical Paths
+
+```python
+# Prioritize coverage for:
+# - Business logic
+# - Error handling
+# - Edge cases
+# - Security-sensitive code
+```
+
+### 4. Use Branch Coverage
+
+```toml
+[tool.coverage.run]
+branch = true
+```
+
+### 5. Track Coverage Trends
+
+```yaml
+# In CI: fail on coverage decrease
+- name: Check coverage
+  run: |
+    pytest --cov=src --cov-report=xml
+    diff-cover coverage.xml --compare-branch=origin/main --fail-under=90
+```
+
+## Quick Reference
+
+| Command | Description |
+|---------|-------------|
+| `--cov=src` | Enable coverage for src/ |
+| `--cov-report=term` | Terminal report |
+| `--cov-report=html` | HTML report |
+| `--cov-report=xml` | XML report (CI) |
+| `--cov-fail-under=80` | Fail if under 80% |
+| `--cov-branch` | Enable branch coverage |
+| `--cov-append` | Append to existing data |
+| `--no-cov` | Disable coverage |

+ 221 - 0
skills/python-pytest-patterns/references/fixtures-advanced.md

@@ -0,0 +1,221 @@
+# Advanced Fixture Patterns
+
+Deep dive into pytest fixtures for complex testing scenarios.
+
+## Factory Fixtures
+
+```python
+import pytest
+from dataclasses import dataclass
+
+@dataclass
+class User:
+    id: int
+    name: str
+    email: str
+
+@pytest.fixture
+def user_factory():
+    """Factory to create users with custom attributes."""
+    def _create_user(
+        id: int = 1,
+        name: str = "Test User",
+        email: str = "test@example.com"
+    ) -> User:
+        return User(id=id, name=name, email=email)
+    return _create_user
+
+def test_user_factory(user_factory):
+    user1 = user_factory()
+    user2 = user_factory(id=2, name="Another User")
+    assert user1.id != user2.id
+```
+
+## Fixture Dependencies
+
+```python
+@pytest.fixture
+def database():
+    """Base database fixture."""
+    db = connect_to_test_db()
+    yield db
+    db.close()
+
+@pytest.fixture
+def clean_database(database):
+    """Depends on database, adds cleanup."""
+    database.clear_all()
+    yield database
+    database.clear_all()
+
+@pytest.fixture
+def seeded_database(clean_database):
+    """Depends on clean_database, adds seed data."""
+    clean_database.insert(SEED_DATA)
+    return clean_database
+```
+
+## Autouse Fixtures
+
+```python
+@pytest.fixture(autouse=True)
+def reset_environment():
+    """Runs automatically before each test."""
+    os.environ.clear()
+    os.environ.update(TEST_ENV)
+    yield
+    os.environ.clear()
+
+@pytest.fixture(autouse=True, scope="module")
+def setup_logging():
+    """Module-level autouse fixture."""
+    logging.disable(logging.CRITICAL)
+    yield
+    logging.disable(logging.NOTSET)
+```
+
+## Request Fixture
+
+```python
+@pytest.fixture
+def temp_file(request, tmp_path):
+    """Fixture that adapts based on test parameters."""
+    # Access test-specific data
+    filename = getattr(request, "param", "default.txt")
+    file_path = tmp_path / filename
+    file_path.write_text("test content")
+    return file_path
+
+@pytest.mark.parametrize("temp_file", ["custom.txt"], indirect=True)
+def test_with_custom_filename(temp_file):
+    assert temp_file.name == "custom.txt"
+```
+
+## Fixture Finalization
+
+```python
+@pytest.fixture
+def resource_with_finalizer(request):
+    """Using request.addfinalizer for cleanup."""
+    resource = allocate_resource()
+
+    def cleanup():
+        resource.release()
+
+    request.addfinalizer(cleanup)
+    return resource
+
+# Prefer yield-based cleanup when possible
+@pytest.fixture
+def resource_with_yield():
+    """Preferred: yield-based cleanup."""
+    resource = allocate_resource()
+    yield resource
+    resource.release()
+```
+
+## Fixture Caching
+
+```python
+@pytest.fixture(scope="session")
+def expensive_computation():
+    """Computed once, cached for entire session."""
+    return perform_expensive_setup()
+
+@pytest.fixture(scope="module")
+def module_cache():
+    """Cached per test module."""
+    return load_module_data()
+```
+
+## Parametrized Fixtures
+
+```python
+@pytest.fixture(params=["sqlite", "postgres", "mysql"])
+def database_backend(request):
+    """Test runs 3 times, once per backend."""
+    backend = request.param
+    db = create_database(backend)
+    yield db
+    db.close()
+
+def test_database_operations(database_backend):
+    """This test runs against all 3 databases."""
+    database_backend.insert({"key": "value"})
+    assert database_backend.get("key") == "value"
+```
+
+## Fixture with IDs
+
+```python
+@pytest.fixture(
+    params=[
+        pytest.param({"user": "admin"}, id="admin-user"),
+        pytest.param({"user": "guest"}, id="guest-user"),
+    ]
+)
+def user_context(request):
+    return request.param
+```
+
+## conftest.py Organization
+
+```
+tests/
+├── conftest.py              # Session/package-wide fixtures
+├── unit/
+│   ├── conftest.py          # Unit test fixtures
+│   └── test_module.py
+├── integration/
+│   ├── conftest.py          # Integration fixtures
+│   └── test_api.py
+└── e2e/
+    ├── conftest.py          # E2E fixtures
+    └── test_flows.py
+```
+
+### conftest.py Example
+
+```python
+# tests/conftest.py
+import pytest
+
+def pytest_configure(config):
+    """Called after command line parsing."""
+    config.addinivalue_line("markers", "slow: marks slow tests")
+
+def pytest_collection_modifyitems(config, items):
+    """Modify collected tests."""
+    if config.getoption("--quick"):
+        skip_slow = pytest.mark.skip(reason="--quick mode")
+        for item in items:
+            if "slow" in item.keywords:
+                item.add_marker(skip_slow)
+
+@pytest.fixture(scope="session")
+def app():
+    """Application for all tests."""
+    from myapp import create_app
+    return create_app(testing=True)
+
+@pytest.fixture
+def client(app):
+    """Test client per test."""
+    return app.test_client()
+
+@pytest.fixture
+def authenticated_client(client):
+    """Client with auth token."""
+    client.post("/login", json={"user": "test", "pass": "test"})
+    return client
+```
+
+## Fixture Best Practices
+
+1. **Single responsibility** - Each fixture does one thing
+2. **Use factory fixtures** - When tests need variations
+3. **Scope appropriately** - Don't over-cache or under-cache
+4. **Prefer yield** - Over request.addfinalizer
+5. **Name clearly** - `db_connection` not `fixture1`
+6. **Document** - Explain what fixture provides and when to use
+7. **Minimize side effects** - Clean up after yourself

+ 338 - 0
skills/python-pytest-patterns/references/integration-testing.md

@@ -0,0 +1,338 @@
+# Integration Testing Patterns
+
+Patterns for testing real systems, databases, and APIs.
+
+## Database Testing with Transactions
+
+```python
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+@pytest.fixture(scope="session")
+def engine():
+    """Create test database engine."""
+    engine = create_engine("postgresql://test:test@localhost/testdb")
+    return engine
+
+@pytest.fixture(scope="session")
+def tables(engine):
+    """Create all tables once per session."""
+    Base.metadata.create_all(engine)
+    yield
+    Base.metadata.drop_all(engine)
+
+@pytest.fixture
+def db_session(engine, tables):
+    """
+    Transaction rollback fixture.
+    Each test runs in a transaction that's rolled back.
+    """
+    connection = engine.connect()
+    transaction = connection.begin()
+    session = sessionmaker(bind=connection)()
+
+    yield session
+
+    session.close()
+    transaction.rollback()
+    connection.close()
+
+
+def test_user_creation(db_session):
+    """Test runs in rolled-back transaction."""
+    user = User(name="Test")
+    db_session.add(user)
+    db_session.commit()  # Committed to transaction, not DB
+    assert db_session.query(User).count() == 1
+    # Rolled back after test - no cleanup needed
+```
+
+## Async Database Testing
+
+```python
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+
+@pytest_asyncio.fixture(scope="session")
+async def async_engine():
+    engine = create_async_engine("postgresql+asyncpg://test:test@localhost/testdb")
+    yield engine
+    await engine.dispose()
+
+@pytest_asyncio.fixture
+async def async_session(async_engine):
+    """Async session with rollback."""
+    async with async_engine.connect() as conn:
+        await conn.begin()
+        async_session = AsyncSession(bind=conn)
+
+        yield async_session
+
+        await async_session.close()
+        await conn.rollback()
+
+
+@pytest.mark.asyncio
+async def test_async_query(async_session):
+    result = await async_session.execute(select(User))
+    users = result.scalars().all()
+    assert len(users) == 0
+```
+
+## TestContainers
+
+```python
+# pip install testcontainers
+
+import pytest
+from testcontainers.postgres import PostgresContainer
+from testcontainers.redis import RedisContainer
+
+@pytest.fixture(scope="session")
+def postgres():
+    """Spin up PostgreSQL container for tests."""
+    with PostgresContainer("postgres:15") as postgres:
+        yield postgres
+
+@pytest.fixture(scope="session")
+def postgres_url(postgres):
+    """Get connection URL for containerized PostgreSQL."""
+    return postgres.get_connection_url()
+
+@pytest.fixture(scope="session")
+def redis():
+    """Spin up Redis container for tests."""
+    with RedisContainer("redis:7") as redis:
+        yield redis
+
+@pytest.fixture
+def redis_client(redis):
+    """Get Redis client for container."""
+    import redis as redis_lib
+    client = redis_lib.from_url(redis.get_container_host_ip())
+    yield client
+    client.flushdb()
+
+
+def test_with_real_postgres(postgres_url):
+    """Test against real PostgreSQL container."""
+    engine = create_engine(postgres_url)
+    # Use real database
+```
+
+## FastAPI / Starlette Testing
+
+```python
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from httpx import AsyncClient
+
+# Synchronous testing
+@pytest.fixture
+def app():
+    return create_app()
+
+@pytest.fixture
+def client(app):
+    """Sync test client."""
+    return TestClient(app)
+
+def test_endpoint(client):
+    response = client.get("/api/users")
+    assert response.status_code == 200
+    assert "users" in response.json()
+
+
+# Async testing with httpx
+@pytest.fixture
+async def async_client(app):
+    """Async test client for async endpoints."""
+    async with AsyncClient(app=app, base_url="http://test") as client:
+        yield client
+
+@pytest.mark.asyncio
+async def test_async_endpoint(async_client):
+    response = await async_client.get("/api/users")
+    assert response.status_code == 200
+
+
+# With database override
+@pytest.fixture
+def app_with_db(db_session):
+    """Override database dependency."""
+    app = create_app()
+
+    def get_test_db():
+        yield db_session
+
+    app.dependency_overrides[get_db] = get_test_db
+    yield app
+    app.dependency_overrides.clear()
+```
+
+## API Testing Patterns
+
+```python
+import pytest
+from dataclasses import dataclass
+
+@dataclass
+class APITestCase:
+    """Structured API test case."""
+    method: str
+    path: str
+    json: dict | None = None
+    expected_status: int = 200
+    expected_json: dict | None = None
+    headers: dict | None = None
+
+@pytest.mark.parametrize("test_case", [
+    APITestCase("GET", "/api/users", expected_status=200),
+    APITestCase("POST", "/api/users", json={"name": "Test"}, expected_status=201),
+    APITestCase("GET", "/api/users/999", expected_status=404),
+])
+def test_api_endpoints(client, test_case):
+    """Parametrized API testing."""
+    response = client.request(
+        method=test_case.method,
+        url=test_case.path,
+        json=test_case.json,
+        headers=test_case.headers,
+    )
+    assert response.status_code == test_case.expected_status
+
+    if test_case.expected_json:
+        assert response.json() == test_case.expected_json
+
+
+# Request/Response validation
+def test_user_creation_flow(client):
+    """Test complete user flow."""
+    # Create
+    response = client.post("/api/users", json={"name": "Test User"})
+    assert response.status_code == 201
+    user_id = response.json()["id"]
+
+    # Read
+    response = client.get(f"/api/users/{user_id}")
+    assert response.status_code == 200
+    assert response.json()["name"] == "Test User"
+
+    # Update
+    response = client.patch(f"/api/users/{user_id}", json={"name": "Updated"})
+    assert response.status_code == 200
+
+    # Delete
+    response = client.delete(f"/api/users/{user_id}")
+    assert response.status_code == 204
+```
+
+## Snapshot Testing
+
+```python
+# pip install syrupy
+
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+def test_api_response_snapshot(client, snapshot: SnapshotAssertion):
+    """Compare response against stored snapshot."""
+    response = client.get("/api/config")
+    assert response.json() == snapshot
+
+
+def test_user_serialization(snapshot):
+    """Snapshot complex objects."""
+    user = User(id=1, name="Test", email="test@example.com")
+    assert user.dict() == snapshot
+
+
+# Update snapshots: pytest --snapshot-update
+```
+
+## External Service Mocking
+
+```python
+import pytest
+import responses
+import respx
+
+# responses (requests library)
+@responses.activate
+def test_external_api():
+    responses.add(
+        responses.GET,
+        "https://api.example.com/data",
+        json={"result": "mocked"},
+        status=200
+    )
+
+    result = fetch_from_external_api()
+    assert result["result"] == "mocked"
+
+
+# respx (httpx library)
+@pytest.fixture
+def mock_api():
+    with respx.mock:
+        yield respx
+
+def test_httpx_external(mock_api):
+    mock_api.get("https://api.example.com/data").respond(
+        json={"result": "mocked"}
+    )
+
+    result = fetch_with_httpx()
+    assert result["result"] == "mocked"
+```
+
+## Factory Fixtures for Integration Tests
+
+```python
+import pytest
+from faker import Faker
+
+fake = Faker()
+
+@pytest.fixture
+def user_factory(db_session):
+    """Factory for creating test users."""
+    created_users = []
+
+    def _create_user(**kwargs):
+        user = User(
+            name=kwargs.get("name", fake.name()),
+            email=kwargs.get("email", fake.email()),
+            **kwargs
+        )
+        db_session.add(user)
+        db_session.commit()
+        created_users.append(user)
+        return user
+
+    yield _create_user
+
+    # Cleanup handled by transaction rollback
+
+
+def test_user_permissions(user_factory):
+    admin = user_factory(role="admin")
+    user = user_factory(role="user")
+
+    assert admin.can_delete(user)
+    assert not user.can_delete(admin)
+```
+
+## Quick Reference
+
+| Pattern | Use Case | Key Benefit |
+|---------|----------|-------------|
+| Transaction rollback | DB tests | Zero cleanup needed |
+| TestContainers | Real services | Production-like testing |
+| TestClient | API testing | Full HTTP stack |
+| Snapshot testing | Complex responses | Easy regression detection |
+| Factory fixtures | Data creation | Flexible test data |
+| respx/responses | External APIs | Isolated testing |

+ 303 - 0
skills/python-pytest-patterns/references/mocking-patterns.md

@@ -0,0 +1,303 @@
+# Mocking Patterns
+
+Comprehensive guide to mocking in pytest.
+
+## unittest.mock Basics
+
+### Mock Object
+
+```python
+from unittest.mock import Mock
+
+def test_mock_basics():
+    mock = Mock()
+
+    # Access any attribute (auto-created)
+    mock.some_attribute
+    mock.method()
+    mock.nested.deeply.value
+
+    # Configure return values
+    mock.get_data.return_value = {"key": "value"}
+    assert mock.get_data() == {"key": "value"}
+
+    # Check calls
+    mock.get_data.assert_called_once()
+    mock.get_data.assert_called_with()  # No args
+```
+
+### MagicMock
+
+```python
+from unittest.mock import MagicMock
+
+def test_magic_mock():
+    mock = MagicMock()
+
+    # Supports magic methods
+    mock.__len__.return_value = 5
+    assert len(mock) == 5
+
+    # Iteration
+    mock.__iter__.return_value = iter([1, 2, 3])
+    assert list(mock) == [1, 2, 3]
+
+    # Context manager
+    mock.__enter__.return_value = "entered"
+    with mock as m:
+        assert m == "entered"
+```
+
+## patch Decorator
+
+```python
+from unittest.mock import patch
+
+# Patch where used, not where defined
+@patch("mymodule.requests.get")
+def test_api_call(mock_get):
+    mock_get.return_value.json.return_value = {"status": "ok"}
+
+    result = mymodule.fetch_data()
+
+    assert result["status"] == "ok"
+    mock_get.assert_called_once_with("https://api.example.com/data")
+
+# Multiple patches (applied bottom-up)
+@patch("mymodule.save_to_db")
+@patch("mymodule.fetch_from_api")
+def test_multiple_patches(mock_fetch, mock_save):  # Note: reverse order
+    mock_fetch.return_value = {"data": []}
+    process_and_save()
+    mock_save.assert_called_once()
+```
+
+## patch Context Manager
+
+```python
+from unittest.mock import patch
+
+def test_with_context_manager():
+    with patch("mymodule.external_service") as mock_service:
+        mock_service.call.return_value = "mocked"
+        result = mymodule.do_work()
+        assert result == "mocked"
+
+    # After context, original is restored
+```
+
+## patch.object
+
+```python
+from unittest.mock import patch
+
+class MyClass:
+    def method(self):
+        return "real"
+
+def test_patch_object():
+    obj = MyClass()
+
+    with patch.object(obj, "method", return_value="mocked"):
+        assert obj.method() == "mocked"
+
+    assert obj.method() == "real"  # Restored
+```
+
+## patch.dict
+
+```python
+from unittest.mock import patch
+import os
+
+def test_patch_dict():
+    with patch.dict(os.environ, {"API_KEY": "test-key"}):
+        assert os.environ["API_KEY"] == "test-key"
+
+    # Clear and add
+    with patch.dict(os.environ, {"NEW_VAR": "value"}, clear=True):
+        assert "PATH" not in os.environ
+        assert os.environ["NEW_VAR"] == "value"
+```
+
+## side_effect
+
+```python
+from unittest.mock import Mock
+
+def test_side_effect_function():
+    mock = Mock()
+    mock.side_effect = lambda x: x * 2
+    assert mock(5) == 10
+
+def test_side_effect_exception():
+    mock = Mock()
+    mock.side_effect = ValueError("Invalid input")
+
+    with pytest.raises(ValueError):
+        mock()
+
+def test_side_effect_list():
+    mock = Mock()
+    mock.side_effect = [1, 2, ValueError("Done")]
+
+    assert mock() == 1
+    assert mock() == 2
+    with pytest.raises(ValueError):
+        mock()
+```
+
+## spec and autospec
+
+```python
+from unittest.mock import Mock, create_autospec
+
+class RealAPI:
+    def get_user(self, user_id: int) -> dict:
+        pass
+
+    def create_user(self, name: str) -> dict:
+        pass
+
+def test_with_spec():
+    # Only allows methods that exist on RealAPI
+    mock = Mock(spec=RealAPI)
+    mock.get_user(1)  # OK
+    # mock.invalid_method()  # AttributeError
+
+def test_with_autospec():
+    # Also validates signatures
+    mock = create_autospec(RealAPI)
+    mock.get_user(1)  # OK
+    # mock.get_user("string")  # Still OK at runtime, but IDE warns
+    # mock.get_user(1, 2, 3)  # TypeError: too many args
+```
+
+## pytest-mock Plugin
+
+```python
+# pip install pytest-mock
+
+def test_with_mocker(mocker):
+    # mocker is a fixture that wraps unittest.mock
+    mock = mocker.patch("mymodule.external_call")
+    mock.return_value = "mocked"
+
+    result = mymodule.process()
+
+    assert result == "mocked"
+    mock.assert_called_once()
+
+def test_spy(mocker):
+    # Spy: call real method but track calls
+    spy = mocker.spy(mymodule, "helper_function")
+
+    mymodule.main_function()
+
+    spy.assert_called()
+    # Original function was actually called
+
+def test_stub(mocker):
+    # Stub: quick attribute replacement
+    mocker.patch.object(MyClass, "expensive_method", return_value="cheap")
+```
+
+## Async Mocking
+
+```python
+from unittest.mock import AsyncMock
+
+async def test_async_mock():
+    mock = AsyncMock()
+    mock.return_value = {"async": "result"}
+
+    result = await mock()
+
+    assert result == {"async": "result"}
+    mock.assert_awaited_once()
+
+@patch("mymodule.async_fetch", new_callable=AsyncMock)
+async def test_patch_async(mock_fetch):
+    mock_fetch.return_value = {"data": []}
+
+    result = await mymodule.get_data()
+
+    assert result == {"data": []}
+```
+
+## PropertyMock
+
+```python
+from unittest.mock import PropertyMock, patch
+
+class MyClass:
+    @property
+    def value(self):
+        return "real"
+
+def test_property_mock():
+    with patch.object(
+        MyClass, "value", new_callable=PropertyMock
+    ) as mock_prop:
+        mock_prop.return_value = "mocked"
+        obj = MyClass()
+        assert obj.value == "mocked"
+```
+
+## Common Patterns
+
+### Mock HTTP Response
+
+```python
+def test_mock_response(mocker):
+    mock_response = Mock()
+    mock_response.status_code = 200
+    mock_response.json.return_value = {"id": 1}
+    mock_response.raise_for_status = Mock()
+
+    mocker.patch("requests.get", return_value=mock_response)
+
+    result = fetch_user(1)
+    assert result["id"] == 1
+```
+
+### Mock File Operations
+
+```python
+from unittest.mock import mock_open, patch
+
+def test_file_read():
+    m = mock_open(read_data="file content")
+    with patch("builtins.open", m):
+        result = read_config("config.txt")
+        assert "content" in result
+
+def test_file_write():
+    m = mock_open()
+    with patch("builtins.open", m):
+        write_data("output.txt", "data")
+        m().write.assert_called_with("data")
+```
+
+### Mock datetime
+
+```python
+from unittest.mock import patch
+from datetime import datetime
+
+def test_mock_datetime(mocker):
+    mock_dt = mocker.patch("mymodule.datetime")
+    mock_dt.now.return_value = datetime(2024, 1, 15, 12, 0, 0)
+
+    result = mymodule.get_timestamp()
+    assert "2024-01-15" in result
+```
+
+## Best Practices
+
+1. **Patch where used** - Not where defined
+2. **Use autospec** - Catch API mismatches
+3. **Reset mocks** - In fixtures or with `mock.reset_mock()`
+4. **Don't over-mock** - Test behavior, not implementation
+5. **Prefer dependency injection** - Over patching
+6. **Use pytest-mock** - Cleaner syntax than unittest.mock

+ 332 - 0
skills/python-pytest-patterns/references/property-testing.md

@@ -0,0 +1,332 @@
+# Property-Based Testing with Hypothesis
+
+Test properties of code with generated inputs, not just examples.
+
+## Why Property Testing?
+
+```python
+# Example-based: tests specific cases
+def test_sort_examples():
+    assert sort([3, 1, 2]) == [1, 2, 3]
+    assert sort([]) == []
+    assert sort([1]) == [1]
+
+# Property-based: tests properties for ANY input
+from hypothesis import given
+from hypothesis import strategies as st
+
+@given(st.lists(st.integers()))
+def test_sort_properties(lst):
+    result = sort(lst)
+    # Property 1: Same length
+    assert len(result) == len(lst)
+    # Property 2: Sorted order
+    assert all(result[i] <= result[i+1] for i in range(len(result)-1))
+    # Property 3: Same elements
+    assert sorted(lst) == result
+```
+
+## Basic Hypothesis Usage
+
+```python
+from hypothesis import given, settings, assume
+from hypothesis import strategies as st
+
+@given(st.integers(), st.integers())
+def test_addition_commutative(a, b):
+    """Addition is commutative."""
+    assert a + b == b + a
+
+@given(st.text())
+def test_reverse_twice(s):
+    """Reversing twice returns original."""
+    assert s[::-1][::-1] == s
+
+@given(st.lists(st.integers(), min_size=1))
+def test_max_in_list(lst):
+    """Max is an element of the list."""
+    assert max(lst) in lst
+```
+
+## Common Strategies
+
+```python
+from hypothesis import strategies as st
+
+# Primitives
+st.integers()                    # Any integer
+st.integers(min_value=0)         # Non-negative
+st.floats(allow_nan=False)       # Floats without NaN
+st.text()                        # Unicode strings
+st.text(alphabet="abc", max_size=10)
+st.booleans()
+st.none()
+st.binary()                      # Bytes
+
+# Collections
+st.lists(st.integers())          # List of ints
+st.lists(st.text(), min_size=1, max_size=10)
+st.sets(st.integers())
+st.frozensets(st.text())
+st.dictionaries(st.text(), st.integers())
+
+# Tuples
+st.tuples(st.integers(), st.text())   # Fixed structure
+st.tuples(st.integers(), st.integers(), st.integers())
+
+# Optional / One of
+st.one_of(st.integers(), st.text())   # Either type
+st.none() | st.integers()              # Optional int
+st.sampled_from(["red", "green", "blue"])  # Enum-like
+```
+
+## Building Custom Strategies
+
+```python
+from hypothesis import strategies as st
+from dataclasses import dataclass
+
+@dataclass
+class User:
+    name: str
+    age: int
+    email: str
+
+# Strategy for User objects
+user_strategy = st.builds(
+    User,
+    name=st.text(min_size=1, max_size=50),
+    age=st.integers(min_value=0, max_value=150),
+    email=st.emails()
+)
+
+@given(user_strategy)
+def test_user_validation(user):
+    assert validate_user(user)
+
+
+# Composite strategies for complex logic
+@st.composite
+def sorted_lists(draw):
+    """Generate pre-sorted lists."""
+    lst = draw(st.lists(st.integers()))
+    return sorted(lst)
+
+@given(sorted_lists())
+def test_binary_search(sorted_lst):
+    if sorted_lst:
+        target = sorted_lst[len(sorted_lst) // 2]
+        assert binary_search(sorted_lst, target) != -1
+
+
+# Dependent strategies
+@st.composite
+def list_and_index(draw):
+    """Generate a list and valid index into it."""
+    lst = draw(st.lists(st.integers(), min_size=1))
+    index = draw(st.integers(min_value=0, max_value=len(lst)-1))
+    return lst, index
+
+@given(list_and_index())
+def test_indexing(data):
+    lst, index = data
+    # This will never raise IndexError
+    assert lst[index] is not None or lst[index] is None
+```
+
+## Filtering and Assumptions
+
+```python
+from hypothesis import given, assume
+from hypothesis import strategies as st
+
+# Filter strategy (preferred when possible)
+@given(st.integers().filter(lambda x: x % 2 == 0))
+def test_even_numbers(n):
+    assert n % 2 == 0
+
+# assume() for runtime filtering
+@given(st.integers(), st.integers())
+def test_division(a, b):
+    assume(b != 0)  # Skip if b is 0
+    assert (a // b) * b + (a % b) == a
+
+# Combining filters
+positive_even = st.integers(min_value=1).filter(lambda x: x % 2 == 0)
+```
+
+## Settings and Configuration
+
+```python
+from hypothesis import given, settings, Verbosity, Phase
+from hypothesis import strategies as st
+
+# Per-test settings
+@settings(max_examples=500)  # More examples (default: 100)
+@given(st.integers())
+def test_thorough(n):
+    pass
+
+@settings(deadline=None)  # Disable timing check
+@given(st.lists(st.integers()))
+def test_slow_operation(lst):
+    expensive_operation(lst)
+
+@settings(
+    max_examples=1000,
+    verbosity=Verbosity.verbose,
+    phases=[Phase.generate],  # Skip shrinking
+)
+@given(st.text())
+def test_verbose(s):
+    pass
+
+
+# Profile for CI (in conftest.py)
+from hypothesis import settings, Verbosity
+
+settings.register_profile("ci", max_examples=1000)
+settings.register_profile("dev", max_examples=10)
+settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose)
+
+# Use: pytest --hypothesis-profile=ci
+```
+
+## Stateful Testing
+
+```python
+from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
+from hypothesis import strategies as st
+
+class DatabaseMachine(RuleBasedStateMachine):
+    """Test database operations maintain invariants."""
+
+    def __init__(self):
+        super().__init__()
+        self.db = {}  # Model
+        self.real_db = RealDatabase()  # System under test
+
+    @rule(key=st.text(), value=st.integers())
+    def set_value(self, key, value):
+        """Set a value in both model and real DB."""
+        self.db[key] = value
+        self.real_db.set(key, value)
+
+    @rule(key=st.text())
+    def get_value(self, key):
+        """Get value should match model."""
+        expected = self.db.get(key)
+        actual = self.real_db.get(key)
+        assert expected == actual
+
+    @rule(key=st.text())
+    def delete_value(self, key):
+        """Delete from both."""
+        self.db.pop(key, None)
+        self.real_db.delete(key)
+
+    @invariant()
+    def keys_match(self):
+        """Keys should always match."""
+        assert set(self.db.keys()) == set(self.real_db.keys())
+
+
+# Run stateful tests
+TestDatabase = DatabaseMachine.TestCase
+```
+
+## pytest Integration
+
+```python
+# conftest.py
+from hypothesis import settings, Verbosity, Phase
+
+# Default profile for all tests
+settings.register_profile("default", max_examples=100)
+
+# CI profile - more examples, deterministic
+settings.register_profile(
+    "ci",
+    max_examples=500,
+    derandomize=True,  # Deterministic for CI
+)
+
+# Load profile from env or default
+import os
+settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "default"))
+
+
+# pytest.ini
+# [pytest]
+# addopts = --hypothesis-profile=default
+```
+
+## Shrinking Examples
+
+```python
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+@given(st.lists(st.integers()))
+def test_shrinking_demo(lst):
+    """Hypothesis shrinks failing inputs to minimal examples."""
+    # This will fail, but Hypothesis finds minimal case
+    assert sum(lst) < 100
+
+# Hypothesis will shrink to something like:
+# Falsifying example: test_shrinking_demo(lst=[100])
+# Not: test_shrinking_demo(lst=[3847, -293, 10293, ...])
+```
+
+## Common Patterns
+
+```python
+# Roundtrip / Encode-Decode
+@given(st.binary())
+def test_compression_roundtrip(data):
+    assert decompress(compress(data)) == data
+
+@given(st.dictionaries(st.text(), st.integers()))
+def test_json_roundtrip(d):
+    assert json.loads(json.dumps(d)) == d
+
+
+# Oracle testing (compare implementations)
+@given(st.lists(st.integers()))
+def test_sort_vs_stdlib(lst):
+    assert my_sort(lst) == sorted(lst)
+
+
+# Metamorphic relations
+@given(st.lists(st.integers()))
+def test_sort_idempotent(lst):
+    """Sorting twice equals sorting once."""
+    assert sort(sort(lst)) == sort(lst)
+
+@given(st.lists(st.integers()), st.integers())
+def test_sort_append(lst, x):
+    """Appending and sorting vs inserting sorted."""
+    assert sort(lst + [x]) == sort(sorted(lst) + [x])
+```
+
+## Quick Reference
+
+| Strategy | Description |
+|----------|-------------|
+| `st.integers()` | Any integer |
+| `st.floats()` | Floats (configure nan, inf) |
+| `st.text()` | Unicode strings |
+| `st.binary()` | Byte strings |
+| `st.lists(st.X())` | Lists of X |
+| `st.dictionaries(k, v)` | Dict with key/value strategies |
+| `st.builds(Class, ...)` | Build objects |
+| `st.one_of(a, b)` | Either a or b |
+| `st.sampled_from([...])` | Pick from list |
+| `@st.composite` | Custom strategy |
+
+| Setting | Purpose |
+|---------|---------|
+| `max_examples=N` | Number of test cases |
+| `deadline=None` | Disable timing |
+| `derandomize=True` | Reproducible runs |
+| `verbosity=Verbosity.verbose` | Debug output |

+ 366 - 0
skills/python-pytest-patterns/references/test-architecture.md

@@ -0,0 +1,366 @@
+# Test Architecture Patterns
+
+Organize tests for maintainability, speed, and confidence.
+
+## Test Pyramid
+
+```
+                 ┌─────────────┐
+                 │     E2E     │  Few, slow, high confidence
+                 │   Browser   │
+                 ├─────────────┤
+                 │ Integration │  Moderate, real services
+                 │   API/DB    │
+                 ├─────────────┤
+                 │    Unit     │  Many, fast, isolated
+                 │  Functions  │
+                 └─────────────┘
+```
+
+| Layer | Count | Speed | Scope | Tools |
+|-------|-------|-------|-------|-------|
+| Unit | 70% | <1ms | Single function | pytest, mock |
+| Integration | 20% | <1s | Multiple components | testcontainers, FastAPI TestClient |
+| E2E | 10% | <30s | Full system | Playwright, Selenium |
+
+## Directory Structure
+
+```
+project/
+├── src/
+│   └── myapp/
+│       ├── models/
+│       ├── services/
+│       └── api/
+├── tests/
+│   ├── conftest.py           # Shared fixtures
+│   ├── unit/                  # Fast, isolated tests
+│   │   ├── conftest.py
+│   │   ├── test_models.py
+│   │   └── test_services.py
+│   ├── integration/           # Real services tests
+│   │   ├── conftest.py        # DB, Redis fixtures
+│   │   ├── test_api.py
+│   │   └── test_repositories.py
+│   ├── e2e/                   # End-to-end tests
+│   │   ├── conftest.py
+│   │   └── test_user_flows.py
+│   └── fixtures/              # Shared test data
+│       └── users.json
+└── pytest.ini
+```
+
+## pytest Configuration
+
+```ini
+# pytest.ini
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_functions = test_*
+python_classes = Test*
+
+# Markers for test categories
+markers =
+    unit: Unit tests (fast, isolated)
+    integration: Integration tests (requires services)
+    e2e: End-to-end tests (full system)
+    slow: Slow tests (>1s)
+
+# Default options
+addopts =
+    -ra                 # Show summary of all except passed
+    --strict-markers    # Error on unknown markers
+    -q                  # Quiet mode
+```
+
+## Test Isolation Strategies
+
+### 1. Database Isolation with Transactions
+
+```python
+@pytest.fixture
+def db_session(engine):
+    """Each test runs in a rolled-back transaction."""
+    connection = engine.connect()
+    transaction = connection.begin()
+    session = Session(bind=connection)
+
+    yield session
+
+    session.close()
+    transaction.rollback()
+    connection.close()
+```
+
+### 2. Schema Isolation (Parallel Safe)
+
+```python
+import uuid
+
+@pytest.fixture(scope="session")
+def test_schema(engine):
+    """Create isolated schema for test session."""
+    schema_name = f"test_{uuid.uuid4().hex[:8]}"
+
+    with engine.connect() as conn:
+        conn.execute(f"CREATE SCHEMA {schema_name}")
+        conn.execute(f"SET search_path TO {schema_name}")
+
+    yield schema_name
+
+    with engine.connect() as conn:
+        conn.execute(f"DROP SCHEMA {schema_name} CASCADE")
+```
+
+### 3. Container Isolation
+
+```python
+@pytest.fixture(scope="session")
+def isolated_postgres():
+    """Each test session gets fresh PostgreSQL."""
+    with PostgresContainer("postgres:15") as pg:
+        yield pg.get_connection_url()
+```
+
+## conftest.py Patterns
+
+### Root conftest.py
+
+```python
+# tests/conftest.py
+import pytest
+from typing import Generator
+
+# Session-scoped fixtures
+@pytest.fixture(scope="session")
+def app():
+    """Create application once per session."""
+    from myapp import create_app
+    return create_app(testing=True)
+
+@pytest.fixture(scope="session")
+def engine(app):
+    """Database engine for session."""
+    return app.extensions["db"].engine
+
+# Function-scoped (per-test)
+@pytest.fixture
+def client(app) -> Generator:
+    """Test client per test."""
+    with app.test_client() as client:
+        yield client
+```
+
+### Unit Test conftest.py
+
+```python
+# tests/unit/conftest.py
+import pytest
+from unittest.mock import Mock
+
+@pytest.fixture
+def mock_db():
+    """Mock database for unit tests."""
+    return Mock()
+
+@pytest.fixture
+def mock_redis():
+    """Mock Redis for unit tests."""
+    return Mock()
+
+@pytest.fixture(autouse=True)
+def no_network(monkeypatch):
+    """Prevent network calls in unit tests."""
+    import socket
+    monkeypatch.setattr(socket, "socket", Mock(side_effect=Exception("No network in unit tests!")))
+```
+
+### Integration Test conftest.py
+
+```python
+# tests/integration/conftest.py
+import pytest
+
+@pytest.fixture(scope="session")
+def postgres_container():
+    """PostgreSQL container for integration tests."""
+    from testcontainers.postgres import PostgresContainer
+    with PostgresContainer("postgres:15") as pg:
+        yield pg
+
+@pytest.fixture
+def db_session(postgres_container):
+    """Database session with rollback."""
+    # Transaction rollback pattern
+    ...
+```
+
+## Test Markers and Selection
+
+```python
+import pytest
+
+# Mark tests by category
+@pytest.mark.unit
+def test_calculate_total():
+    assert calculate_total([1, 2, 3]) == 6
+
+@pytest.mark.integration
+def test_save_to_database(db_session):
+    user = User(name="Test")
+    db_session.add(user)
+    db_session.commit()
+    assert user.id is not None
+
+@pytest.mark.e2e
+def test_user_signup_flow(browser):
+    browser.goto("/signup")
+    browser.fill("email", "test@example.com")
+    browser.click("Submit")
+    assert browser.url == "/dashboard"
+
+@pytest.mark.slow
+def test_data_migration():
+    migrate_all_records()  # Takes 30 seconds
+```
+
+```bash
+# Run specific categories
+pytest -m unit            # Only unit tests
+pytest -m integration     # Only integration tests
+pytest -m "not slow"      # Exclude slow tests
+pytest -m "unit or integration"  # Both
+```
+
+## Parallel Testing
+
+```python
+# pytest.ini
+[pytest]
+# Safe for parallel execution
+addopts = -n auto  # Use pytest-xdist
+
+# conftest.py - ensure isolation
+@pytest.fixture(scope="session")
+def worker_id(request):
+    """Get unique worker ID for parallel runs."""
+    if hasattr(request.config, "workerinput"):
+        return request.config.workerinput["workerid"]
+    return "master"
+
+@pytest.fixture(scope="session")
+def db_name(worker_id):
+    """Unique database per worker."""
+    return f"testdb_{worker_id}"
+```
+
+## Test Naming Conventions
+
+```python
+# Pattern: test_<unit>_<condition>_<expected>
+
+def test_user_creation_with_valid_data_succeeds():
+    pass
+
+def test_user_creation_with_missing_email_raises_validation_error():
+    pass
+
+def test_calculate_total_with_empty_list_returns_zero():
+    pass
+
+def test_api_users_get_without_auth_returns_401():
+    pass
+
+
+# Or use classes for grouping
+class TestUserCreation:
+    def test_with_valid_data_succeeds(self):
+        pass
+
+    def test_with_missing_email_raises_validation_error(self):
+        pass
+
+    def test_with_duplicate_email_raises_conflict_error(self):
+        pass
+```
+
+## Fixture Organization
+
+```python
+# tests/fixtures/factories.py
+import factory
+from faker import Faker
+
+fake = Faker()
+
+class UserFactory(factory.Factory):
+    class Meta:
+        model = User
+
+    name = factory.LazyAttribute(lambda _: fake.name())
+    email = factory.LazyAttribute(lambda _: fake.email())
+
+class OrderFactory(factory.Factory):
+    class Meta:
+        model = Order
+
+    user = factory.SubFactory(UserFactory)
+    total = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1, max_value=1000))
+
+
+# tests/conftest.py
+from tests.fixtures.factories import UserFactory, OrderFactory
+
+@pytest.fixture
+def user():
+    return UserFactory()
+
+@pytest.fixture
+def order(user):
+    return OrderFactory(user=user)
+```
+
+## Performance Testing
+
+```python
+# pip install pytest-benchmark
+
+def test_sort_performance(benchmark):
+    """Benchmark sorting algorithm."""
+    data = list(range(10000, 0, -1))
+    result = benchmark(sort, data)
+    assert result == sorted(data)
+
+
+# pip install pytest-timeout
+@pytest.mark.timeout(5)  # Fail if takes >5 seconds
+def test_with_timeout():
+    slow_operation()
+
+
+# Track memory
+# pip install pytest-memray
+@pytest.mark.limit_memory("100 MB")
+def test_memory_usage():
+    large_operation()
+```
+
+## Quick Reference
+
+| Pattern | When to Use |
+|---------|-------------|
+| Transaction rollback | Database tests, fast isolation |
+| TestContainers | Real service behavior needed |
+| Schema isolation | Parallel test execution |
+| Factory fixtures | Complex test data |
+| Markers | Categorize and filter tests |
+| conftest layers | Scope fixtures appropriately |
+
+| Command | Purpose |
+|---------|---------|
+| `pytest -m unit` | Run unit tests only |
+| `pytest -n auto` | Parallel execution |
+| `pytest --lf` | Last failed only |
+| `pytest -x` | Stop on first failure |
+| `pytest --cov=src` | Coverage report |

+ 229 - 0
skills/python-pytest-patterns/scripts/generate-conftest.sh

@@ -0,0 +1,229 @@
+#!/bin/bash
+# Generate conftest.py boilerplate
+# Usage: ./generate-conftest.sh [--async] [--db] [--api]
+
+set -e
+
+OUTPUT="tests/conftest.py"
+
+# Check if tests directory exists
+if [[ ! -d "tests" ]]; then
+    echo "Creating tests directory..."
+    mkdir -p tests
+fi
+
+# Check if conftest.py already exists
+if [[ -f "$OUTPUT" ]]; then
+    read -p "conftest.py already exists. Overwrite? [y/N] " -n 1 -r
+    echo
+    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+        exit 0
+    fi
+fi
+
+# Parse arguments
+ASYNC=""
+DB=""
+API=""
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --async)
+            ASYNC=1
+            shift
+            ;;
+        --db)
+            DB=1
+            shift
+            ;;
+        --api)
+            API=1
+            shift
+            ;;
+        *)
+            shift
+            ;;
+    esac
+done
+
+# Generate conftest.py
+cat > "$OUTPUT" << 'HEADER'
+"""
+Pytest configuration and fixtures.
+Generated by generate-conftest.sh
+"""
+import pytest
+HEADER
+
+# Add async imports if needed
+if [[ -n "$ASYNC" ]]; then
+    cat >> "$OUTPUT" << 'ASYNC_IMPORTS'
+import asyncio
+ASYNC_IMPORTS
+fi
+
+# Add database imports if needed
+if [[ -n "$DB" ]]; then
+    cat >> "$OUTPUT" << 'DB_IMPORTS'
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+ASYNC_IMPORTS
+fi
+
+# Add API imports if needed
+if [[ -n "$API" ]]; then
+    cat >> "$OUTPUT" << 'API_IMPORTS'
+from fastapi.testclient import TestClient
+# or: from flask.testing import FlaskClient
+ASYNC_IMPORTS
+fi
+
+# Add base fixtures
+cat >> "$OUTPUT" << 'BASE_FIXTURES'
+
+
+# ============================================================
+# Test Configuration
+# ============================================================
+
+def pytest_configure(config):
+    """Register custom markers."""
+    config.addinivalue_line("markers", "slow: marks tests as slow")
+    config.addinivalue_line("markers", "integration: marks integration tests")
+    config.addinivalue_line("markers", "e2e: marks end-to-end tests")
+
+
+def pytest_collection_modifyitems(config, items):
+    """Skip slow tests unless --slow flag is provided."""
+    if not config.getoption("--slow", default=False):
+        skip_slow = pytest.mark.skip(reason="use --slow to run")
+        for item in items:
+            if "slow" in item.keywords:
+                item.add_marker(skip_slow)
+
+
+def pytest_addoption(parser):
+    """Add custom CLI options."""
+    parser.addoption(
+        "--slow",
+        action="store_true",
+        default=False,
+        help="run slow tests"
+    )
+
+
+# ============================================================
+# Common Fixtures
+# ============================================================
+
+@pytest.fixture
+def sample_data():
+    """Provide sample test data."""
+    return {
+        "id": 1,
+        "name": "Test",
+        "active": True,
+    }
+
+
+@pytest.fixture
+def temp_file(tmp_path):
+    """Create a temporary file for testing."""
+    file_path = tmp_path / "test_file.txt"
+    file_path.write_text("test content")
+    return file_path
+BASE_FIXTURES
+
+# Add async fixtures if requested
+if [[ -n "$ASYNC" ]]; then
+    cat >> "$OUTPUT" << 'ASYNC_FIXTURES'
+
+
+# ============================================================
+# Async Fixtures
+# ============================================================
+
+@pytest.fixture(scope="session")
+def event_loop():
+    """Create event loop for async tests."""
+    loop = asyncio.new_event_loop()
+    yield loop
+    loop.close()
+
+
+@pytest.fixture
+async def async_client():
+    """Async HTTP client fixture."""
+    import aiohttp
+    async with aiohttp.ClientSession() as session:
+        yield session
+ASYNC_FIXTURES
+fi
+
+# Add database fixtures if requested
+if [[ -n "$DB" ]]; then
+    cat >> "$OUTPUT" << 'DB_FIXTURES'
+
+
+# ============================================================
+# Database Fixtures
+# ============================================================
+
+@pytest.fixture(scope="session")
+def db_engine():
+    """Create test database engine."""
+    engine = create_engine("sqlite:///:memory:")
+    # Create tables here
+    yield engine
+    engine.dispose()
+
+
+@pytest.fixture
+def db_session(db_engine):
+    """Create database session with transaction rollback."""
+    Session = sessionmaker(bind=db_engine)
+    session = Session()
+    yield session
+    session.rollback()
+    session.close()
+DB_FIXTURES
+fi
+
+# Add API fixtures if requested
+if [[ -n "$API" ]]; then
+    cat >> "$OUTPUT" << 'API_FIXTURES'
+
+
+# ============================================================
+# API Fixtures
+# ============================================================
+
+@pytest.fixture
+def app():
+    """Create test application."""
+    from myapp import create_app
+    app = create_app(testing=True)
+    return app
+
+
+@pytest.fixture
+def client(app):
+    """Create test client."""
+    return TestClient(app)
+
+
+@pytest.fixture
+def authenticated_client(client):
+    """Create authenticated test client."""
+    # Add authentication logic here
+    client.headers["Authorization"] = "Bearer test-token"
+    return client
+API_FIXTURES
+fi
+
+echo "Generated $OUTPUT"
+echo ""
+echo "Options used:"
+[[ -n "$ASYNC" ]] && echo "  --async: Async fixtures included"
+[[ -n "$DB" ]] && echo "  --db: Database fixtures included"
+[[ -n "$API" ]] && echo "  --api: API fixtures included"

+ 90 - 0
skills/python-pytest-patterns/scripts/run-tests.sh

@@ -0,0 +1,90 @@
+#!/bin/bash
+# Run pytest with recommended options
+# Usage: ./run-tests.sh [options]
+#
+# Options:
+#   --quick     Skip slow tests, minimal output
+#   --coverage  Run with coverage report
+#   --watch     Watch mode with pytest-watch
+#   --failed    Re-run only failed tests
+#   --debug     Enable debug output
+
+set -e
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+# Default options
+PYTEST_ARGS="-v"
+COVERAGE=""
+WATCH=""
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --quick)
+            PYTEST_ARGS="-q -x --tb=short"
+            shift
+            ;;
+        --coverage)
+            COVERAGE="--cov=src --cov-report=term-missing --cov-report=html"
+            shift
+            ;;
+        --watch)
+            WATCH=1
+            shift
+            ;;
+        --failed)
+            PYTEST_ARGS="$PYTEST_ARGS --lf"
+            shift
+            ;;
+        --debug)
+            PYTEST_ARGS="$PYTEST_ARGS -s --tb=long"
+            shift
+            ;;
+        *)
+            PYTEST_ARGS="$PYTEST_ARGS $1"
+            shift
+            ;;
+    esac
+done
+
+# Check if pytest is installed
+if ! command -v pytest &> /dev/null; then
+    echo -e "${RED}pytest not found. Install with: pip install pytest${NC}"
+    exit 1
+fi
+
+# Watch mode
+if [[ -n "$WATCH" ]]; then
+    if ! command -v ptw &> /dev/null; then
+        echo -e "${YELLOW}pytest-watch not found. Installing...${NC}"
+        pip install pytest-watch
+    fi
+    echo -e "${GREEN}Starting watch mode...${NC}"
+    ptw -- $PYTEST_ARGS $COVERAGE
+    exit 0
+fi
+
+# Run tests
+echo -e "${GREEN}Running pytest...${NC}"
+echo "pytest $PYTEST_ARGS $COVERAGE"
+echo ""
+
+pytest $PYTEST_ARGS $COVERAGE
+
+# Open coverage report if generated
+if [[ -n "$COVERAGE" ]] && [[ -f "htmlcov/index.html" ]]; then
+    echo ""
+    echo -e "${GREEN}Coverage report: htmlcov/index.html${NC}"
+    if command -v open &> /dev/null; then
+        read -p "Open coverage report? [y/N] " -n 1 -r
+        echo
+        if [[ $REPLY =~ ^[Yy]$ ]]; then
+            open htmlcov/index.html
+        fi
+    fi
+fi

+ 232 - 0
skills/python-typing-patterns/SKILL.md

@@ -0,0 +1,232 @@
+---
+name: python-typing-patterns
+description: "Python type hints and type safety patterns. Triggers on: type hints, typing, TypeVar, Generic, Protocol, mypy, pyright, type annotation, overload, TypedDict."
+compatibility: "Python 3.10+ (uses union syntax X | Y). Some patterns require 3.11+ (Self, TypeVarTuple)."
+allowed-tools: "Read Write"
+depends-on: []
+related-skills: [python-pytest-patterns]
+---
+
+# Python Typing Patterns
+
+Modern type hints for safe, documented Python code.
+
+## Basic Annotations
+
+```python
+# Variables
+name: str = "Alice"
+count: int = 42
+items: list[str] = ["a", "b"]
+mapping: dict[str, int] = {"key": 1}
+
+# Function signatures
+def greet(name: str, times: int = 1) -> str:
+    return f"Hello, {name}!" * times
+
+# None handling
+def find(id: int) -> str | None:
+    return db.get(id)  # May return None
+```
+
+## Collections
+
+```python
+from collections.abc import Sequence, Mapping, Iterable
+
+# Use collection ABCs for flexibility
+def process(items: Sequence[str]) -> list[str]:
+    """Accepts list, tuple, or any sequence."""
+    return [item.upper() for item in items]
+
+def lookup(data: Mapping[str, int], key: str) -> int:
+    """Accepts dict or any mapping."""
+    return data.get(key, 0)
+
+# Nested types
+Matrix = list[list[float]]
+Config = dict[str, str | int | bool]
+```
+
+## Optional and Union
+
+```python
+# Modern syntax (3.10+)
+def find(id: int) -> User | None:
+    pass
+
+def parse(value: str | int | float) -> str:
+    pass
+
+# With default None
+def fetch(url: str, timeout: float | None = None) -> bytes:
+    pass
+```
+
+## TypedDict
+
+```python
+from typing import TypedDict, Required, NotRequired
+
+class UserDict(TypedDict):
+    id: int
+    name: str
+    email: str | None
+
+class ConfigDict(TypedDict, total=False):  # All optional
+    debug: bool
+    log_level: str
+
+class APIResponse(TypedDict):
+    data: Required[list[dict]]
+    error: NotRequired[str]
+
+def process_user(user: UserDict) -> str:
+    return user["name"]  # Type-safe key access
+```
+
+## Callable
+
+```python
+from collections.abc import Callable
+
+# Function type
+Handler = Callable[[str, int], bool]
+
+def register(callback: Callable[[str], None]) -> None:
+    pass
+
+# With keyword args (use Protocol instead)
+from typing import Protocol
+
+class Processor(Protocol):
+    def __call__(self, data: str, *, verbose: bool = False) -> int:
+        ...
+```
+
+## Generics
+
+```python
+from typing import TypeVar
+
+T = TypeVar("T")
+
+def first(items: list[T]) -> T | None:
+    return items[0] if items else None
+
+# Bounded TypeVar
+from typing import SupportsFloat
+
+N = TypeVar("N", bound=SupportsFloat)
+
+def average(values: list[N]) -> float:
+    return sum(float(v) for v in values) / len(values)
+```
+
+## Protocol (Structural Typing)
+
+```python
+from typing import Protocol
+
+class Readable(Protocol):
+    def read(self, n: int = -1) -> bytes:
+        ...
+
+def load(source: Readable) -> dict:
+    """Accepts any object with read() method."""
+    data = source.read()
+    return json.loads(data)
+
+# Works with file, BytesIO, custom classes
+load(open("data.json", "rb"))
+load(io.BytesIO(b"{}"))
+```
+
+## Type Guards
+
+```python
+from typing import TypeGuard
+
+def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
+    return all(isinstance(x, str) for x in val)
+
+def process(items: list[object]) -> None:
+    if is_string_list(items):
+        # items is now list[str]
+        print(", ".join(items))
+```
+
+## Literal and Final
+
+```python
+from typing import Literal, Final
+
+Mode = Literal["read", "write", "append"]
+
+def open_file(path: str, mode: Mode) -> None:
+    pass
+
+# Constants
+MAX_SIZE: Final = 1024
+API_VERSION: Final[str] = "v2"
+```
+
+## Quick Reference
+
+| Type | Use Case |
+|------|----------|
+| `X \| None` | Optional value |
+| `list[T]` | Homogeneous list |
+| `dict[K, V]` | Dictionary |
+| `Callable[[Args], Ret]` | Function type |
+| `TypeVar("T")` | Generic parameter |
+| `Protocol` | Structural typing |
+| `TypedDict` | Dict with fixed keys |
+| `Literal["a", "b"]` | Specific values only |
+| `Final` | Cannot be reassigned |
+
+## Type Checker Commands
+
+```bash
+# mypy
+mypy src/ --strict
+
+# pyright
+pyright src/
+
+# In pyproject.toml
+[tool.mypy]
+strict = true
+python_version = "3.11"
+```
+
+## Additional Resources
+
+- `./references/generics-advanced.md` - TypeVar, ParamSpec, TypeVarTuple
+- `./references/protocols-patterns.md` - Structural typing, runtime protocols
+- `./references/type-narrowing.md` - Guards, isinstance, assert
+- `./references/mypy-config.md` - mypy/pyright configuration
+- `./references/runtime-validation.md` - Pydantic v2, typeguard, beartype
+- `./references/overloads.md` - @overload decorator patterns
+
+## Scripts
+
+- `./scripts/check-types.sh` - Run type checkers with common options
+
+## Assets
+
+- `./assets/pyproject-typing.toml` - Recommended mypy/pyright config
+
+---
+
+## See Also
+
+This is a **foundation skill** with no prerequisites.
+
+**Related Skills:**
+- `python-pytest-patterns` - Type-safe fixtures and mocking
+
+**Build on this skill:**
+- `python-async-patterns` - Async type annotations
+- `python-fastapi-patterns` - Pydantic models and validation
+- `python-database-patterns` - SQLAlchemy type annotations

+ 117 - 0
skills/python-typing-patterns/assets/pyproject-typing.toml

@@ -0,0 +1,117 @@
+# pyproject.toml - Type checker configuration
+# Copy these sections to your pyproject.toml
+
+# ============================================================
+# mypy Configuration
+# ============================================================
+
+[tool.mypy]
+# Python version to target
+python_version = "3.11"
+
+# Enable strict mode (recommended for new projects)
+strict = true
+
+# Additional strictness
+warn_return_any = true
+warn_unused_ignores = true
+warn_unreachable = true
+
+# Error reporting
+show_error_codes = true
+show_error_context = true
+show_column_numbers = true
+pretty = true
+
+# Paths
+files = ["src", "tests"]
+exclude = [
+    "migrations/",
+    "venv/",
+    ".venv/",
+    "__pycache__/",
+    "build/",
+    "dist/",
+]
+
+# Plugin support (uncomment as needed)
+# plugins = [
+#     "pydantic.mypy",
+#     "sqlalchemy.ext.mypy.plugin",
+# ]
+
+# ============================================================
+# Per-module overrides
+# ============================================================
+
+# Relax strictness for tests
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_untyped_calls = false
+
+# Ignore missing stubs for common libraries
+[[tool.mypy.overrides]]
+module = [
+    "requests.*",
+    "boto3.*",
+    "botocore.*",
+    "celery.*",
+    "redis.*",
+]
+ignore_missing_imports = true
+
+# Legacy code - gradually add types
+# [[tool.mypy.overrides]]
+# module = "legacy.*"
+# ignore_errors = true
+
+
+# ============================================================
+# pyright Configuration
+# ============================================================
+
+[tool.pyright]
+# Python version
+pythonVersion = "3.11"
+
+# Paths
+include = ["src"]
+exclude = [
+    "**/node_modules",
+    "**/__pycache__",
+    "venv",
+    ".venv",
+    "build",
+    "dist",
+]
+
+# Type checking mode: off, basic, standard, strict
+typeCheckingMode = "strict"
+
+# Report settings (strict mode enables all by default)
+reportMissingTypeStubs = false
+reportUnusedImport = "warning"
+reportUnusedVariable = "warning"
+reportUnusedFunction = "warning"
+
+# Useful additional checks
+reportUninitializedInstanceVariable = true
+reportIncompatibleMethodOverride = true
+reportIncompatibleVariableOverride = true
+
+
+# ============================================================
+# Recommended dev dependencies
+# ============================================================
+
+# [project.optional-dependencies]
+# dev = [
+#     "mypy>=1.8.0",
+#     "pyright>=1.1.350",
+#     # Common type stubs
+#     "types-requests",
+#     "types-redis",
+#     "types-PyYAML",
+#     "types-python-dateutil",
+# ]

+ 282 - 0
skills/python-typing-patterns/references/generics-advanced.md

@@ -0,0 +1,282 @@
+# Advanced Generics
+
+Deep dive into Python's generic type system.
+
+## TypeVar Basics
+
+```python
+from typing import TypeVar
+
+# Unconstrained TypeVar
+T = TypeVar("T")
+
+def identity(x: T) -> T:
+    return x
+
+# Usage - type is preserved
+reveal_type(identity(42))      # int
+reveal_type(identity("hello")) # str
+```
+
+## Bounded TypeVar
+
+```python
+from typing import TypeVar
+
+# Upper bound - T must be subtype of bound
+class Animal:
+    def speak(self) -> str:
+        return "..."
+
+class Dog(Animal):
+    def speak(self) -> str:
+        return "woof"
+
+A = TypeVar("A", bound=Animal)
+
+def make_speak(animal: A) -> A:
+    print(animal.speak())
+    return animal
+
+# Works with Animal or any subclass
+dog = make_speak(Dog())  # Returns Dog, not Animal
+```
+
+## Constrained TypeVar
+
+```python
+from typing import TypeVar
+
+# Constrained to specific types
+StrOrBytes = TypeVar("StrOrBytes", str, bytes)
+
+def concat(a: StrOrBytes, b: StrOrBytes) -> StrOrBytes:
+    return a + b
+
+# Must be same type
+concat("a", "b")     # OK -> str
+concat(b"a", b"b")   # OK -> bytes
+# concat("a", b"b")  # Error: can't mix
+```
+
+## Generic Classes
+
+```python
+from typing import Generic, TypeVar
+
+T = TypeVar("T")
+
+class Stack(Generic[T]):
+    def __init__(self) -> None:
+        self._items: list[T] = []
+
+    def push(self, item: T) -> None:
+        self._items.append(item)
+
+    def pop(self) -> T:
+        return self._items.pop()
+
+    def peek(self) -> T | None:
+        return self._items[-1] if self._items else None
+
+# Usage
+int_stack: Stack[int] = Stack()
+int_stack.push(1)
+int_stack.push(2)
+value = int_stack.pop()  # int
+
+str_stack: Stack[str] = Stack()
+str_stack.push("hello")
+```
+
+## Multiple Type Parameters
+
+```python
+from typing import Generic, TypeVar
+
+K = TypeVar("K")
+V = TypeVar("V")
+
+class Pair(Generic[K, V]):
+    def __init__(self, key: K, value: V) -> None:
+        self.key = key
+        self.value = value
+
+    def swap(self) -> "Pair[V, K]":
+        return Pair(self.value, self.key)
+
+pair: Pair[str, int] = Pair("age", 30)
+swapped = pair.swap()  # Pair[int, str]
+```
+
+## Self Type (Python 3.11+)
+
+```python
+from typing import Self
+
+class Builder:
+    def __init__(self) -> None:
+        self.value = ""
+
+    def add(self, text: str) -> Self:
+        self.value += text
+        return self
+
+    def build(self) -> str:
+        return self.value
+
+class HTMLBuilder(Builder):
+    def tag(self, name: str) -> Self:
+        self.value = f"<{name}>{self.value}</{name}>"
+        return self
+
+# Chaining works with correct types
+html = HTMLBuilder().add("Hello").tag("p").build()
+```
+
+## ParamSpec (Python 3.10+)
+
+```python
+from typing import ParamSpec, TypeVar, Callable
+
+P = ParamSpec("P")
+R = TypeVar("R")
+
+def with_logging(func: Callable[P, R]) -> Callable[P, R]:
+    """Decorator that preserves function signature."""
+    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+        print(f"Calling {func.__name__}")
+        return func(*args, **kwargs)
+    return wrapper
+
+@with_logging
+def greet(name: str, excited: bool = False) -> str:
+    return f"Hello, {name}{'!' if excited else '.'}"
+
+# Signature preserved:
+greet("Alice", excited=True)  # OK
+# greet(123)  # Type error
+```
+
+## TypeVarTuple (Python 3.11+)
+
+```python
+from typing import TypeVarTuple, Unpack
+
+Ts = TypeVarTuple("Ts")
+
+def concat_tuples(
+    a: tuple[*Ts],
+    b: tuple[*Ts]
+) -> tuple[*Ts, *Ts]:
+    return (*a, *b)
+
+# Usage
+result = concat_tuples((1, "a"), (2, "b"))
+# result: tuple[int, str, int, str]
+```
+
+## Covariance and Contravariance
+
+```python
+from typing import TypeVar
+
+# Covariant: Can use subtype
+T_co = TypeVar("T_co", covariant=True)
+
+class Reader(Generic[T_co]):
+    def read(self) -> T_co:
+        ...
+
+# Contravariant: Can use supertype
+T_contra = TypeVar("T_contra", contravariant=True)
+
+class Writer(Generic[T_contra]):
+    def write(self, value: T_contra) -> None:
+        ...
+
+# Invariant (default): Must be exact type
+T = TypeVar("T")  # Invariant
+
+class Container(Generic[T]):
+    def get(self) -> T:
+        ...
+    def set(self, value: T) -> None:
+        ...
+```
+
+## Generic Protocols
+
+```python
+from typing import Protocol, TypeVar
+
+T = TypeVar("T")
+
+class Comparable(Protocol[T]):
+    def __lt__(self, other: T) -> bool:
+        ...
+    def __gt__(self, other: T) -> bool:
+        ...
+
+def max_value(a: T, b: T) -> T:
+    return a if a > b else b
+
+# Works with any comparable type
+max_value(1, 2)        # int
+max_value("a", "b")    # str
+```
+
+## Type Aliases
+
+```python
+from typing import TypeAlias
+
+# Simple alias
+Vector: TypeAlias = list[float]
+Matrix: TypeAlias = list[Vector]
+
+# Generic alias
+from typing import TypeVar
+
+T = TypeVar("T")
+Result: TypeAlias = tuple[T, str | None]
+
+def parse(data: str) -> Result[int]:
+    try:
+        return (int(data), None)
+    except ValueError as e:
+        return (0, str(e))
+```
+
+## NewType
+
+```python
+from typing import NewType
+
+# Create distinct types for type safety
+UserId = NewType("UserId", int)
+OrderId = NewType("OrderId", int)
+
+def get_user(user_id: UserId) -> dict:
+    ...
+
+def get_order(order_id: OrderId) -> dict:
+    ...
+
+user_id = UserId(42)
+order_id = OrderId(42)
+
+get_user(user_id)   # OK
+# get_user(order_id)  # Type error!
+# get_user(42)        # Type error!
+```
+
+## Best Practices
+
+1. **Name TypeVars descriptively** - `T`, `K`, `V` for simple cases; `ItemT`, `KeyT` for complex
+2. **Use bounds** - When you need method access on type parameter
+3. **Prefer Protocol** - Over ABC for structural typing
+4. **Use Self** - Instead of quoted class names in return types
+5. **Covariance** - For read-only containers
+6. **Contravariance** - For write-only/function parameter types
+7. **Invariance** - For mutable containers (default, usually correct)

+ 317 - 0
skills/python-typing-patterns/references/mypy-config.md

@@ -0,0 +1,317 @@
+# mypy and pyright Configuration
+
+Type checker setup for strict, practical type safety.
+
+## mypy Configuration
+
+### pyproject.toml (Recommended)
+
+```toml
+[tool.mypy]
+python_version = "3.11"
+strict = true
+warn_return_any = true
+warn_unused_ignores = true
+show_error_codes = true
+show_error_context = true
+
+# Paths
+files = ["src", "tests"]
+exclude = [
+    "migrations/",
+    "venv/",
+    "__pycache__/",
+]
+
+# Per-module overrides
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+
+[[tool.mypy.overrides]]
+module = [
+    "requests.*",
+    "boto3.*",
+    "botocore.*",
+]
+ignore_missing_imports = true
+```
+
+### mypy.ini (Alternative)
+
+```ini
+[mypy]
+python_version = 3.11
+strict = True
+warn_return_any = True
+warn_unused_ignores = True
+show_error_codes = True
+
+[mypy-tests.*]
+disallow_untyped_defs = False
+
+[mypy-requests.*]
+ignore_missing_imports = True
+```
+
+## mypy Flags Explained
+
+### Strict Mode Components
+
+```toml
+[tool.mypy]
+# strict = true enables all of these:
+warn_unused_configs = true
+disallow_any_generics = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+check_untyped_defs = true
+disallow_untyped_decorators = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+warn_return_any = true
+no_implicit_reexport = true
+strict_equality = true
+extra_checks = true
+```
+
+### Commonly Adjusted Flags
+
+```toml
+[tool.mypy]
+# Allow untyped defs in some files
+disallow_untyped_defs = true
+
+# But not for tests
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+
+# Ignore third-party stubs
+ignore_missing_imports = true  # Global fallback
+
+# Show where errors occur
+show_error_context = true
+show_column_numbers = true
+show_error_codes = true
+
+# Error output format
+pretty = true
+```
+
+## pyright Configuration
+
+### pyrightconfig.json
+
+```json
+{
+  "include": ["src"],
+  "exclude": ["**/node_modules", "**/__pycache__", "venv"],
+  "pythonVersion": "3.11",
+  "pythonPlatform": "All",
+  "typeCheckingMode": "strict",
+  "reportMissingImports": true,
+  "reportMissingTypeStubs": false,
+  "reportUnusedImport": true,
+  "reportUnusedClass": true,
+  "reportUnusedFunction": true,
+  "reportUnusedVariable": true,
+  "reportDuplicateImport": true,
+  "reportPrivateUsage": true,
+  "reportConstantRedefinition": true,
+  "reportIncompatibleMethodOverride": true,
+  "reportIncompatibleVariableOverride": true,
+  "reportInconsistentConstructor": true,
+  "reportOverlappingOverload": true,
+  "reportUninitializedInstanceVariable": true
+}
+```
+
+### pyproject.toml (pyright)
+
+```toml
+[tool.pyright]
+include = ["src"]
+exclude = ["**/node_modules", "**/__pycache__", "venv"]
+pythonVersion = "3.11"
+typeCheckingMode = "strict"
+reportMissingTypeStubs = false
+```
+
+## Type Checking Modes
+
+### pyright Modes
+
+```json
+{
+  "typeCheckingMode": "off"    // No checking
+  "typeCheckingMode": "basic"  // Basic checks
+  "typeCheckingMode": "standard" // Standard checks
+  "typeCheckingMode": "strict"  // All checks enabled
+}
+```
+
+## Inline Type Ignores
+
+```python
+# Ignore specific error
+result = some_call()  # type: ignore[arg-type]
+
+# Ignore all errors on line
+result = some_call()  # type: ignore
+
+# With mypy error code
+value = data["key"]  # type: ignore[typeddict-item]
+
+# With pyright
+result = func()  # pyright: ignore[reportGeneralTypeIssues]
+```
+
+## Type Stub Files (.pyi)
+
+```python
+# mymodule.pyi - Type stubs for mymodule.py
+
+def process(data: dict[str, int]) -> list[int]: ...
+
+class Handler:
+    def __init__(self, name: str) -> None: ...
+    def handle(self, event: Event) -> bool: ...
+```
+
+### Stub Package Structure
+
+```
+stubs/
+├── mypackage/
+│   ├── __init__.pyi
+│   ├── module.pyi
+│   └── subpackage/
+│       └── __init__.pyi
+```
+
+```toml
+[tool.mypy]
+mypy_path = "stubs"
+```
+
+## CI Integration
+
+### GitHub Actions
+
+```yaml
+name: Type Check
+
+on: [push, pull_request]
+
+jobs:
+  mypy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+
+      - name: Install dependencies
+        run: |
+          pip install mypy
+          pip install -e .[dev]
+
+      - name: Run mypy
+        run: mypy src/
+
+  pyright:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+
+      - name: Install dependencies
+        run: pip install -e .[dev]
+
+      - name: Run pyright
+        uses: jakebailey/pyright-action@v2
+```
+
+### Pre-commit Hook
+
+```yaml
+# .pre-commit-config.yaml
+repos:
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v1.8.0
+    hooks:
+      - id: mypy
+        additional_dependencies: [types-requests]
+        args: [--strict]
+```
+
+## Common Type Stubs
+
+```bash
+# Install type stubs
+pip install types-requests
+pip install types-redis
+pip install types-PyYAML
+pip install boto3-stubs[essential]
+
+# Or use mypy to find missing stubs
+mypy --install-types src/
+```
+
+## Gradual Typing Strategy
+
+### Phase 1: Basic
+
+```toml
+[tool.mypy]
+python_version = "3.11"
+warn_return_any = true
+warn_unused_ignores = true
+```
+
+### Phase 2: Stricter
+
+```toml
+[tool.mypy]
+python_version = "3.11"
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+check_untyped_defs = true
+```
+
+### Phase 3: Strict
+
+```toml
+[tool.mypy]
+python_version = "3.11"
+strict = true
+
+# Temporarily ignore problem areas
+[[tool.mypy.overrides]]
+module = "legacy.*"
+ignore_errors = true
+```
+
+## Quick Reference
+
+| mypy Flag | Description |
+|-----------|-------------|
+| `--strict` | Enable all strict checks |
+| `--show-error-codes` | Show error codes for ignores |
+| `--ignore-missing-imports` | Skip untyped libraries |
+| `--python-version 3.11` | Target Python version |
+| `--install-types` | Install missing stubs |
+| `--config-file` | Specify config file |
+
+| pyright Mode | Description |
+|--------------|-------------|
+| `off` | No checking |
+| `basic` | Minimal checks |
+| `standard` | Recommended |
+| `strict` | All checks |

+ 271 - 0
skills/python-typing-patterns/references/overloads.md

@@ -0,0 +1,271 @@
+# Function Overloads
+
+Type-safe function signatures with @overload.
+
+## Basic Overloads
+
+```python
+from typing import overload, Literal
+
+# Overload signatures (no implementation)
+@overload
+def process(data: str) -> str: ...
+
+@overload
+def process(data: bytes) -> bytes: ...
+
+@overload
+def process(data: int) -> int: ...
+
+# Actual implementation
+def process(data: str | bytes | int) -> str | bytes | int:
+    if isinstance(data, str):
+        return data.upper()
+    elif isinstance(data, bytes):
+        return data.upper()
+    else:
+        return data * 2
+
+
+# Type checker knows the return type
+result = process("hello")  # str
+result = process(b"hello")  # bytes
+result = process(42)  # int
+```
+
+## Overloads with Literal
+
+```python
+from typing import overload, Literal
+
+@overload
+def fetch(url: str, format: Literal["json"]) -> dict: ...
+
+@overload
+def fetch(url: str, format: Literal["text"]) -> str: ...
+
+@overload
+def fetch(url: str, format: Literal["bytes"]) -> bytes: ...
+
+def fetch(url: str, format: str) -> dict | str | bytes:
+    response = requests.get(url)
+    if format == "json":
+        return response.json()
+    elif format == "text":
+        return response.text
+    else:
+        return response.content
+
+
+# Usage - return type is known
+data = fetch("https://api.example.com", "json")  # dict
+text = fetch("https://api.example.com", "text")  # str
+```
+
+## Overloads with Optional Parameters
+
+```python
+from typing import overload
+
+@overload
+def get_user(user_id: int) -> User: ...
+
+@overload
+def get_user(user_id: int, include_posts: Literal[True]) -> UserWithPosts: ...
+
+@overload
+def get_user(user_id: int, include_posts: Literal[False]) -> User: ...
+
+def get_user(user_id: int, include_posts: bool = False) -> User | UserWithPosts:
+    user = db.get_user(user_id)
+    if include_posts:
+        user.posts = db.get_posts(user_id)
+        return UserWithPosts(**user.__dict__)
+    return user
+
+
+# Type-safe usage
+user = get_user(1)  # User
+user_with_posts = get_user(1, include_posts=True)  # UserWithPosts
+```
+
+## Overloads with None Returns
+
+```python
+from typing import overload
+
+@overload
+def find(items: list[T], predicate: Callable[[T], bool]) -> T | None: ...
+
+@overload
+def find(items: list[T], predicate: Callable[[T], bool], default: T) -> T: ...
+
+def find(
+    items: list[T],
+    predicate: Callable[[T], bool],
+    default: T | None = None
+) -> T | None:
+    for item in items:
+        if predicate(item):
+            return item
+    return default
+
+
+# Without default - might be None
+result = find([1, 2, 3], lambda x: x > 5)  # int | None
+
+# With default - never None
+result = find([1, 2, 3], lambda x: x > 5, default=0)  # int
+```
+
+## Class Method Overloads
+
+```python
+from typing import overload, Self
+from dataclasses import dataclass
+
+@dataclass
+class Point:
+    x: float
+    y: float
+
+    @overload
+    @classmethod
+    def from_tuple(cls, coords: tuple[float, float]) -> Self: ...
+
+    @overload
+    @classmethod
+    def from_tuple(cls, coords: tuple[float, float, float]) -> "Point3D": ...
+
+    @classmethod
+    def from_tuple(cls, coords: tuple[float, ...]) -> "Point | Point3D":
+        if len(coords) == 2:
+            return cls(coords[0], coords[1])
+        elif len(coords) == 3:
+            return Point3D(coords[0], coords[1], coords[2])
+        raise ValueError("Expected 2 or 3 coordinates")
+```
+
+## Overloads with Generics
+
+```python
+from typing import overload, TypeVar, Sequence
+
+T = TypeVar("T")
+K = TypeVar("K")
+V = TypeVar("V")
+
+@overload
+def first(items: Sequence[T]) -> T | None: ...
+
+@overload
+def first(items: Sequence[T], default: T) -> T: ...
+
+def first(items: Sequence[T], default: T | None = None) -> T | None:
+    return items[0] if items else default
+
+
+@overload
+def get(d: dict[K, V], key: K) -> V | None: ...
+
+@overload
+def get(d: dict[K, V], key: K, default: V) -> V: ...
+
+def get(d: dict[K, V], key: K, default: V | None = None) -> V | None:
+    return d.get(key, default)
+```
+
+## Async Overloads
+
+```python
+from typing import overload
+
+@overload
+async def fetch_data(url: str, as_json: Literal[True]) -> dict: ...
+
+@overload
+async def fetch_data(url: str, as_json: Literal[False] = False) -> str: ...
+
+async def fetch_data(url: str, as_json: bool = False) -> dict | str:
+    async with aiohttp.ClientSession() as session:
+        async with session.get(url) as response:
+            if as_json:
+                return await response.json()
+            return await response.text()
+```
+
+## Property Overloads (Getter/Setter)
+
+```python
+from typing import overload
+
+class Temperature:
+    def __init__(self, celsius: float):
+        self._celsius = celsius
+
+    @property
+    def value(self) -> float:
+        return self._celsius
+
+    @overload
+    def convert(self, unit: Literal["C"]) -> float: ...
+
+    @overload
+    def convert(self, unit: Literal["F"]) -> float: ...
+
+    @overload
+    def convert(self, unit: Literal["K"]) -> float: ...
+
+    def convert(self, unit: str) -> float:
+        if unit == "C":
+            return self._celsius
+        elif unit == "F":
+            return self._celsius * 9/5 + 32
+        elif unit == "K":
+            return self._celsius + 273.15
+        raise ValueError(f"Unknown unit: {unit}")
+```
+
+## Common Patterns
+
+```python
+from typing import overload, Literal, TypeVar
+
+T = TypeVar("T")
+
+# Pattern 1: Return type based on flag
+@overload
+def parse(data: str, strict: Literal[True]) -> Result: ...
+@overload
+def parse(data: str, strict: Literal[False] = False) -> Result | None: ...
+
+# Pattern 2: Different return for different input types
+@overload
+def normalize(value: str) -> str: ...
+@overload
+def normalize(value: list[str]) -> list[str]: ...
+@overload
+def normalize(value: dict[str, str]) -> dict[str, str]: ...
+
+# Pattern 3: Optional vs required parameter
+@overload
+def create(name: str) -> Item: ...
+@overload
+def create(name: str, *, template: str) -> Item: ...
+```
+
+## Quick Reference
+
+| Pattern | Use Case |
+|---------|----------|
+| `@overload` | Define signature (no body) |
+| `Literal["value"]` | Specific string/int values |
+| `T \| None` vs `T` | Optional default changes return |
+| Implementation | Must handle all overload cases |
+
+| Rule | Description |
+|------|-------------|
+| No body in overloads | Use `...` (ellipsis) |
+| Implementation last | After all overloads |
+| Cover all cases | Implementation must accept all overload inputs |
+| Static only | Overloads are for type checkers, not runtime |

+ 316 - 0
skills/python-typing-patterns/references/protocols-patterns.md

@@ -0,0 +1,316 @@
+# Protocol Patterns
+
+Structural typing with Protocol for flexible, decoupled code.
+
+## Basic Protocol
+
+```python
+from typing import Protocol
+
+class Drawable(Protocol):
+    def draw(self) -> None:
+        ...
+
+class Circle:
+    def draw(self) -> None:
+        print("Drawing circle")
+
+class Square:
+    def draw(self) -> None:
+        print("Drawing square")
+
+def render(shape: Drawable) -> None:
+    shape.draw()
+
+# Both work - no inheritance needed
+render(Circle())
+render(Square())
+```
+
+## Protocol with Attributes
+
+```python
+from typing import Protocol
+
+class Named(Protocol):
+    name: str
+
+class HasId(Protocol):
+    id: int
+    name: str
+
+class User:
+    def __init__(self, id: int, name: str):
+        self.id = id
+        self.name = name
+
+def greet(entity: Named) -> str:
+    return f"Hello, {entity.name}"
+
+# Works with any object having 'name' attribute
+greet(User(1, "Alice"))
+```
+
+## Protocol with Methods
+
+```python
+from typing import Protocol
+
+class Closeable(Protocol):
+    def close(self) -> None:
+        ...
+
+class Flushable(Protocol):
+    def flush(self) -> None:
+        ...
+
+class CloseableAndFlushable(Closeable, Flushable, Protocol):
+    """Combined protocol."""
+    pass
+
+def cleanup(resource: CloseableAndFlushable) -> None:
+    resource.flush()
+    resource.close()
+```
+
+## Callable Protocol
+
+```python
+from typing import Protocol
+
+class Comparator(Protocol):
+    def __call__(self, a: int, b: int) -> int:
+        """Return negative, zero, or positive."""
+        ...
+
+def sort_with(items: list[int], cmp: Comparator) -> list[int]:
+    return sorted(items, key=lambda x: cmp(x, 0))
+
+# Lambda works
+sort_with([3, 1, 2], lambda a, b: a - b)
+
+# Function works
+def compare(a: int, b: int) -> int:
+    return a - b
+
+sort_with([3, 1, 2], compare)
+```
+
+## Generic Protocol
+
+```python
+from typing import Protocol, TypeVar
+
+T = TypeVar("T")
+
+class Container(Protocol[T]):
+    def get(self) -> T:
+        ...
+
+    def set(self, value: T) -> None:
+        ...
+
+class Box:
+    def __init__(self, value: int):
+        self._value = value
+
+    def get(self) -> int:
+        return self._value
+
+    def set(self, value: int) -> None:
+        self._value = value
+
+def process(container: Container[int]) -> int:
+    value = container.get()
+    container.set(value * 2)
+    return container.get()
+
+process(Box(5))  # Returns 10
+```
+
+## Runtime Checkable Protocol
+
+```python
+from typing import Protocol, runtime_checkable
+
+@runtime_checkable
+class Sized(Protocol):
+    def __len__(self) -> int:
+        ...
+
+# Now isinstance() works
+def process(obj: object) -> int:
+    if isinstance(obj, Sized):
+        return len(obj)
+    return 0
+
+process([1, 2, 3])  # 3
+process("hello")     # 5
+process(42)          # 0
+```
+
+## Protocol vs ABC
+
+```python
+from abc import ABC, abstractmethod
+from typing import Protocol
+
+# ABC - Requires explicit inheritance
+class AbstractReader(ABC):
+    @abstractmethod
+    def read(self) -> str:
+        pass
+
+class FileReader(AbstractReader):  # Must inherit
+    def read(self) -> str:
+        return "content"
+
+# Protocol - Structural (duck typing)
+class ReaderProtocol(Protocol):
+    def read(self) -> str:
+        ...
+
+class AnyReader:  # No inheritance needed
+    def read(self) -> str:
+        return "content"
+
+def process(reader: ReaderProtocol) -> str:
+    return reader.read()
+
+process(AnyReader())  # Works!
+process(FileReader())  # Also works!
+```
+
+## Common Protocols
+
+### Supports Protocols
+
+```python
+from typing import SupportsInt, SupportsFloat, SupportsBytes, SupportsAbs
+
+def to_int(value: SupportsInt) -> int:
+    return int(value)
+
+to_int(3.14)   # OK - float supports __int__
+to_int("42")   # Error - str doesn't support __int__
+```
+
+### Iterator Protocol
+
+```python
+from typing import Protocol, TypeVar
+
+T = TypeVar("T", covariant=True)
+
+class Iterator(Protocol[T]):
+    def __next__(self) -> T:
+        ...
+
+class Iterable(Protocol[T]):
+    def __iter__(self) -> Iterator[T]:
+        ...
+```
+
+### Context Manager Protocol
+
+```python
+from typing import Protocol, TypeVar
+
+T = TypeVar("T")
+
+class ContextManager(Protocol[T]):
+    def __enter__(self) -> T:
+        ...
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: object | None,
+    ) -> bool | None:
+        ...
+```
+
+## Real-World Patterns
+
+### Repository Pattern
+
+```python
+from typing import Protocol, TypeVar
+
+T = TypeVar("T")
+
+class Repository(Protocol[T]):
+    def get(self, id: int) -> T | None:
+        ...
+
+    def save(self, entity: T) -> None:
+        ...
+
+    def delete(self, id: int) -> bool:
+        ...
+
+class User:
+    id: int
+    name: str
+
+class InMemoryUserRepo:
+    def __init__(self):
+        self._data: dict[int, User] = {}
+
+    def get(self, id: int) -> User | None:
+        return self._data.get(id)
+
+    def save(self, entity: User) -> None:
+        self._data[entity.id] = entity
+
+    def delete(self, id: int) -> bool:
+        return self._data.pop(id, None) is not None
+
+def process_users(repo: Repository[User]) -> None:
+    user = repo.get(1)
+    if user:
+        repo.delete(user.id)
+```
+
+### Event Handler
+
+```python
+from typing import Protocol
+
+class Event:
+    pass
+
+class UserCreated(Event):
+    def __init__(self, user_id: int):
+        self.user_id = user_id
+
+class EventHandler(Protocol):
+    def can_handle(self, event: Event) -> bool:
+        ...
+
+    def handle(self, event: Event) -> None:
+        ...
+
+class UserCreatedHandler:
+    def can_handle(self, event: Event) -> bool:
+        return isinstance(event, UserCreated)
+
+    def handle(self, event: Event) -> None:
+        if isinstance(event, UserCreated):
+            print(f"User {event.user_id} created")
+
+def dispatch(event: Event, handlers: list[EventHandler]) -> None:
+    for handler in handlers:
+        if handler.can_handle(event):
+            handler.handle(event)
+```
+
+## Best Practices
+
+1. **Prefer Protocol over ABC** - For external interfaces
+2. **Use @runtime_checkable sparingly** - Has performance cost
+3. **Keep protocols minimal** - Single responsibility
+4. **Document expected behavior** - Protocols only define shape, not behavior
+5. **Combine protocols** - For complex requirements
+6. **Use Generic protocols** - For type-safe containers

+ 297 - 0
skills/python-typing-patterns/references/runtime-validation.md

@@ -0,0 +1,297 @@
+# Runtime Type Validation
+
+Enforce type hints at runtime with Pydantic, typeguard, and beartype.
+
+## Pydantic v2 Validation
+
+```python
+from pydantic import BaseModel, Field, field_validator, model_validator
+from pydantic import EmailStr, HttpUrl, PositiveInt
+from datetime import datetime
+from typing import Self
+
+class User(BaseModel):
+    """Model with automatic validation."""
+    id: PositiveInt
+    name: str = Field(..., min_length=1, max_length=100)
+    email: EmailStr
+    website: HttpUrl | None = None
+    created_at: datetime = Field(default_factory=datetime.now)
+
+    @field_validator("name")
+    @classmethod
+    def name_must_be_title_case(cls, v: str) -> str:
+        return v.title()
+
+    @model_validator(mode="after")
+    def check_consistency(self) -> Self:
+        # Cross-field validation
+        return self
+
+
+# Usage - raises ValidationError on invalid data
+user = User(id=1, name="john doe", email="john@example.com")
+print(user.name)  # "John Doe" (transformed)
+
+# From dict
+user = User.model_validate({"id": 1, "name": "jane", "email": "jane@example.com"})
+
+# Validation error
+try:
+    User(id=-1, name="", email="invalid")
+except ValidationError as e:
+    print(e.errors())
+```
+
+## Pydantic for Function Arguments
+
+```python
+from pydantic import validate_call, Field
+from typing import Annotated
+
+@validate_call
+def greet(
+    name: Annotated[str, Field(min_length=1)],
+    count: Annotated[int, Field(ge=1, le=10)] = 1,
+) -> str:
+    return f"Hello, {name}!" * count
+
+
+# Valid
+greet("World")  # OK
+greet("World", count=3)  # OK
+
+# Invalid - raises ValidationError
+greet("")  # Error: min_length
+greet("World", count=100)  # Error: le
+```
+
+## typeguard (Runtime Type Checking)
+
+```python
+from typeguard import typechecked, check_type
+from typing import TypeVar, Generic
+
+# Decorator for function checking
+@typechecked
+def process(items: list[int], multiplier: float) -> list[float]:
+    return [item * multiplier for item in items]
+
+# Valid
+process([1, 2, 3], 1.5)  # OK
+
+# Invalid - raises TypeCheckError at runtime
+process(["a", "b"], 1.5)  # Error: list[int] expected
+
+
+# Check types manually
+from typeguard import check_type
+
+value = [1, 2, 3]
+check_type(value, list[int])  # OK
+
+value = [1, "two", 3]
+check_type(value, list[int])  # TypeCheckError
+
+
+# Class checking
+@typechecked
+class DataProcessor(Generic[T]):
+    def __init__(self, data: list[T]):
+        self.data = data
+
+    def process(self) -> T:
+        return self.data[0]
+```
+
+## beartype (Fast Runtime Checking)
+
+```python
+from beartype import beartype
+from beartype.typing import List, Optional
+
+# ~200x faster than typeguard
+@beartype
+def fast_process(items: List[int], factor: float) -> List[float]:
+    return [i * factor for i in items]
+
+
+# With optional
+@beartype
+def find_user(user_id: int) -> Optional[dict]:
+    return None
+
+
+# Class decorator
+@beartype
+class FastProcessor:
+    def __init__(self, data: list[int]):
+        self.data = data
+
+    def sum(self) -> int:
+        return sum(self.data)
+```
+
+## TypedDict Runtime Validation
+
+```python
+from typing import TypedDict, Required, NotRequired
+from pydantic import TypeAdapter
+
+class UserDict(TypedDict):
+    id: Required[int]
+    name: Required[str]
+    email: NotRequired[str]
+
+
+# Using Pydantic to validate TypedDict
+adapter = TypeAdapter(UserDict)
+
+# Valid
+user = adapter.validate_python({"id": 1, "name": "John"})
+
+# Invalid - raises ValidationError
+adapter.validate_python({"id": "not-int", "name": "John"})
+
+
+# JSON parsing with validation
+user = adapter.validate_json('{"id": 1, "name": "John"}')
+```
+
+## dataclass Validation with Pydantic
+
+```python
+from dataclasses import dataclass
+from pydantic import TypeAdapter
+from typing import Annotated
+from annotated_types import Gt, Lt
+
+@dataclass
+class Point:
+    x: Annotated[float, Gt(-100), Lt(100)]
+    y: Annotated[float, Gt(-100), Lt(100)]
+
+
+# Create validator
+validator = TypeAdapter(Point)
+
+# Validate
+point = validator.validate_python({"x": 10.5, "y": 20.3})
+
+# Or with init
+point = validator.validate_python(Point(x=10.5, y=20.3))
+```
+
+## Custom Validators
+
+```python
+from pydantic import BaseModel, field_validator, ValidationInfo
+from pydantic_core import PydanticCustomError
+import re
+
+class Account(BaseModel):
+    username: str
+    password: str
+
+    @field_validator("username")
+    @classmethod
+    def validate_username(cls, v: str) -> str:
+        if not re.match(r"^[a-z][a-z0-9_]{2,19}$", v):
+            raise PydanticCustomError(
+                "invalid_username",
+                "Username must be 3-20 chars, start with letter, contain only a-z, 0-9, _"
+            )
+        return v
+
+    @field_validator("password")
+    @classmethod
+    def validate_password(cls, v: str, info: ValidationInfo) -> str:
+        if len(v) < 8:
+            raise ValueError("Password must be at least 8 characters")
+        if info.data.get("username") and info.data["username"] in v:
+            raise ValueError("Password cannot contain username")
+        return v
+```
+
+## Constrained Types
+
+```python
+from pydantic import (
+    BaseModel,
+    PositiveInt,
+    NegativeFloat,
+    conint,
+    constr,
+    conlist,
+)
+
+class Order(BaseModel):
+    quantity: PositiveInt  # > 0
+    discount: NegativeFloat | None = None  # < 0
+
+    # Custom constraints
+    product_code: constr(pattern=r"^[A-Z]{3}-\d{4}$")
+    priority: conint(ge=1, le=5)
+    tags: conlist(str, min_length=1, max_length=10)
+
+
+# Usage
+order = Order(
+    quantity=5,
+    product_code="ABC-1234",
+    priority=3,
+    tags=["urgent"]
+)
+```
+
+## When to Use Each
+
+| Tool | Speed | Strictness | Use Case |
+|------|-------|------------|----------|
+| Pydantic | Medium | High | API validation, config |
+| typeguard | Slow | Very high | Testing, debugging |
+| beartype | Fast | Medium | Production code |
+
+```python
+# Development: Use typeguard for strictest checking
+from typeguard import typechecked
+
+@typechecked
+def dev_function(x: list[int]) -> int:
+    return sum(x)
+
+
+# Production: Use beartype for minimal overhead
+from beartype import beartype
+
+@beartype
+def prod_function(x: list[int]) -> int:
+    return sum(x)
+
+
+# API boundaries: Use Pydantic for validation + serialization
+from pydantic import BaseModel
+
+class Request(BaseModel):
+    items: list[int]
+
+def api_function(request: Request) -> int:
+    return sum(request.items)
+```
+
+## Quick Reference
+
+| Library | Decorator | Check |
+|---------|-----------|-------|
+| Pydantic | `@validate_call` | `Model.model_validate()` |
+| typeguard | `@typechecked` | `check_type(val, Type)` |
+| beartype | `@beartype` | Automatic on call |
+
+| Pydantic Type | Constraint |
+|---------------|------------|
+| `PositiveInt` | `> 0` |
+| `NegativeInt` | `< 0` |
+| `conint(ge=0, le=100)` | `0 <= x <= 100` |
+| `constr(min_length=1)` | Non-empty string |
+| `EmailStr` | Valid email |
+| `HttpUrl` | Valid URL |

+ 271 - 0
skills/python-typing-patterns/references/type-narrowing.md

@@ -0,0 +1,271 @@
+# Type Narrowing
+
+Techniques for narrowing types in conditional branches.
+
+## isinstance Narrowing
+
+```python
+def process(value: str | int | list[str]) -> str:
+    if isinstance(value, str):
+        # value is str here
+        return value.upper()
+    elif isinstance(value, int):
+        # value is int here
+        return str(value * 2)
+    else:
+        # value is list[str] here
+        return ", ".join(value)
+```
+
+## None Checks
+
+```python
+def greet(name: str | None) -> str:
+    if name is None:
+        return "Hello, stranger"
+    # name is str here (not None)
+    return f"Hello, {name}"
+
+# Also works with truthiness
+def greet_truthy(name: str | None) -> str:
+    if name:
+        # name is str here
+        return f"Hello, {name}"
+    return "Hello, stranger"
+```
+
+## Assertion Narrowing
+
+```python
+def process(data: dict | None) -> str:
+    assert data is not None
+    # data is dict here
+    return str(data.get("key"))
+
+def validate(value: int | str) -> int:
+    assert isinstance(value, int), "Must be int"
+    # value is int here
+    return value * 2
+```
+
+## Type Guards
+
+```python
+from typing import TypeGuard
+
+def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
+    """Check if all elements are strings."""
+    return all(isinstance(x, str) for x in val)
+
+def process(items: list[object]) -> str:
+    if is_string_list(items):
+        # items is list[str] here
+        return ", ".join(items)
+    return "Not all strings"
+
+# With TypeVar
+from typing import TypeVar
+
+T = TypeVar("T")
+
+def is_not_none(val: T | None) -> TypeGuard[T]:
+    return val is not None
+
+def process_optional(value: str | None) -> str:
+    if is_not_none(value):
+        # value is str here
+        return value.upper()
+    return "default"
+```
+
+## TypeIs (Python 3.13+)
+
+```python
+from typing import TypeIs
+
+# TypeIs narrows more aggressively than TypeGuard
+def is_str(val: object) -> TypeIs[str]:
+    return isinstance(val, str)
+
+def process(value: object) -> str:
+    if is_str(value):
+        # value is str here
+        return value.upper()
+    return "not a string"
+```
+
+## Discriminated Unions
+
+```python
+from typing import Literal, TypedDict
+
+class SuccessResult(TypedDict):
+    status: Literal["success"]
+    data: dict
+
+class ErrorResult(TypedDict):
+    status: Literal["error"]
+    message: str
+
+Result = SuccessResult | ErrorResult
+
+def handle_result(result: Result) -> str:
+    if result["status"] == "success":
+        # result is SuccessResult
+        return str(result["data"])
+    else:
+        # result is ErrorResult
+        return f"Error: {result['message']}"
+```
+
+## Match Statement (Python 3.10+)
+
+```python
+def describe(value: int | str | list[int]) -> str:
+    match value:
+        case int(n):
+            return f"Integer: {n}"
+        case str(s):
+            return f"String: {s}"
+        case [first, *rest]:
+            return f"List starting with {first}"
+        case _:
+            return "Unknown"
+```
+
+## hasattr Narrowing
+
+```python
+from typing import Protocol
+
+class HasName(Protocol):
+    name: str
+
+def greet(obj: object) -> str:
+    if hasattr(obj, "name") and isinstance(obj.name, str):
+        # Type checkers may not narrow here
+        # Use Protocol + isinstance instead
+        return f"Hello, {obj.name}"
+    return "Hello"
+```
+
+## Callable Narrowing
+
+```python
+from collections.abc import Callable
+
+def execute(func_or_value: Callable[[], int] | int) -> int:
+    if callable(func_or_value):
+        # func_or_value is Callable[[], int]
+        return func_or_value()
+    else:
+        # func_or_value is int
+        return func_or_value
+```
+
+## Exhaustiveness Checking
+
+```python
+from typing import Literal, Never
+
+def assert_never(value: Never) -> Never:
+    raise AssertionError(f"Unexpected value: {value}")
+
+Status = Literal["pending", "active", "closed"]
+
+def handle_status(status: Status) -> str:
+    if status == "pending":
+        return "Waiting..."
+    elif status == "active":
+        return "In progress"
+    elif status == "closed":
+        return "Done"
+    else:
+        # If we add a new status, type checker will error here
+        assert_never(status)
+```
+
+## Narrowing in Loops
+
+```python
+from typing import TypeGuard
+
+def is_valid(item: str | None) -> TypeGuard[str]:
+    return item is not None
+
+def process_items(items: list[str | None]) -> list[str]:
+    result: list[str] = []
+    for item in items:
+        if is_valid(item):
+            # item is str here
+            result.append(item.upper())
+    return result
+
+# Or use filter with type guard
+def process_items_functional(items: list[str | None]) -> list[str]:
+    valid_items = filter(is_valid, items)
+    return [item.upper() for item in valid_items]
+```
+
+## Class Type Narrowing
+
+```python
+class Animal:
+    pass
+
+class Dog(Animal):
+    def bark(self) -> str:
+        return "Woof!"
+
+class Cat(Animal):
+    def meow(self) -> str:
+        return "Meow!"
+
+def make_sound(animal: Animal) -> str:
+    if isinstance(animal, Dog):
+        return animal.bark()  # animal is Dog
+    elif isinstance(animal, Cat):
+        return animal.meow()  # animal is Cat
+    return "..."
+```
+
+## Common Patterns
+
+### Optional Unwrapping
+
+```python
+def unwrap_or_default(value: T | None, default: T) -> T:
+    if value is not None:
+        return value
+    return default
+
+# With early return
+def process(data: dict | None) -> dict:
+    if data is None:
+        return {}
+    # data is dict for rest of function
+    return {k: v.upper() for k, v in data.items()}
+```
+
+### Safe Dictionary Access
+
+```python
+def get_nested(data: dict, *keys: str) -> object | None:
+    result: object = data
+    for key in keys:
+        if not isinstance(result, dict):
+            return None
+        result = result.get(key)
+        if result is None:
+            return None
+    return result
+```
+
+## Best Practices
+
+1. **Prefer isinstance** - Most reliable for type narrowing
+2. **Use TypeGuard** - For complex conditions
+3. **Check None explicitly** - `is None` or `is not None`
+4. **Use exhaustiveness checks** - Catch missing cases
+5. **Avoid hasattr** - Type checkers struggle with it
+6. **Match statements** - Clean pattern matching (3.10+)

+ 151 - 0
skills/python-typing-patterns/scripts/check-types.sh

@@ -0,0 +1,151 @@
+#!/bin/bash
+# Run type checkers with common options
+# Usage: ./check-types.sh [--mypy|--pyright|--both] [--strict] [path]
+
+set -e
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+# Defaults
+CHECKER="both"
+STRICT=""
+TARGET="src"
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --mypy)
+            CHECKER="mypy"
+            shift
+            ;;
+        --pyright)
+            CHECKER="pyright"
+            shift
+            ;;
+        --both)
+            CHECKER="both"
+            shift
+            ;;
+        --strict)
+            STRICT="--strict"
+            shift
+            ;;
+        *)
+            TARGET="$1"
+            shift
+            ;;
+    esac
+done
+
+# Check if target exists
+if [[ ! -e "$TARGET" ]]; then
+    echo -e "${RED}Target not found: $TARGET${NC}"
+    exit 1
+fi
+
+run_mypy() {
+    echo -e "${BLUE}=== Running mypy ===${NC}"
+
+    if ! command -v mypy &> /dev/null; then
+        echo -e "${YELLOW}mypy not found. Install with: pip install mypy${NC}"
+        return 1
+    fi
+
+    MYPY_ARGS="--show-error-codes --show-error-context --pretty"
+    if [[ -n "$STRICT" ]]; then
+        MYPY_ARGS="$MYPY_ARGS --strict"
+    fi
+
+    echo "mypy $MYPY_ARGS $TARGET"
+    echo ""
+
+    if mypy $MYPY_ARGS "$TARGET"; then
+        echo -e "${GREEN}✓ mypy passed${NC}"
+        return 0
+    else
+        echo -e "${RED}✗ mypy found errors${NC}"
+        return 1
+    fi
+}
+
+run_pyright() {
+    echo -e "${BLUE}=== Running pyright ===${NC}"
+
+    if ! command -v pyright &> /dev/null; then
+        echo -e "${YELLOW}pyright not found. Install with: pip install pyright${NC}"
+        return 1
+    fi
+
+    PYRIGHT_ARGS=""
+    if [[ -n "$STRICT" ]]; then
+        # Create temporary config for strict mode
+        TEMP_CONFIG=$(mktemp)
+        cat > "$TEMP_CONFIG" << EOF
+{
+  "typeCheckingMode": "strict"
+}
+EOF
+        PYRIGHT_ARGS="--project $TEMP_CONFIG"
+    fi
+
+    echo "pyright $PYRIGHT_ARGS $TARGET"
+    echo ""
+
+    if pyright $PYRIGHT_ARGS "$TARGET"; then
+        echo -e "${GREEN}✓ pyright passed${NC}"
+        [[ -n "$STRICT" ]] && rm -f "$TEMP_CONFIG"
+        return 0
+    else
+        echo -e "${RED}✗ pyright found errors${NC}"
+        [[ -n "$STRICT" ]] && rm -f "$TEMP_CONFIG"
+        return 1
+    fi
+}
+
+# Run checkers
+MYPY_STATUS=0
+PYRIGHT_STATUS=0
+
+case $CHECKER in
+    mypy)
+        run_mypy || MYPY_STATUS=$?
+        ;;
+    pyright)
+        run_pyright || PYRIGHT_STATUS=$?
+        ;;
+    both)
+        run_mypy || MYPY_STATUS=$?
+        echo ""
+        run_pyright || PYRIGHT_STATUS=$?
+        ;;
+esac
+
+# Summary
+echo ""
+echo -e "${BLUE}=== Summary ===${NC}"
+
+if [[ "$CHECKER" == "both" ]] || [[ "$CHECKER" == "mypy" ]]; then
+    if [[ $MYPY_STATUS -eq 0 ]]; then
+        echo -e "mypy:    ${GREEN}✓ passed${NC}"
+    else
+        echo -e "mypy:    ${RED}✗ failed${NC}"
+    fi
+fi
+
+if [[ "$CHECKER" == "both" ]] || [[ "$CHECKER" == "pyright" ]]; then
+    if [[ $PYRIGHT_STATUS -eq 0 ]]; then
+        echo -e "pyright: ${GREEN}✓ passed${NC}"
+    else
+        echo -e "pyright: ${RED}✗ failed${NC}"
+    fi
+fi
+
+# Exit with error if any checker failed
+if [[ $MYPY_STATUS -ne 0 ]] || [[ $PYRIGHT_STATUS -ne 0 ]]; then
+    exit 1
+fi

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio