diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..92052cc --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "fastsqla", + "interface": { + "displayName": "FastSQLA" + }, + "plugins": [ + { + "name": "fastsqla", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..5690e11 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "fastsqla", + "description": "Agent Skills for building FastAPI applications with FastSQLA.", + "owner": { + "name": "Hadrien David", + "email": "h@driendavid.com" + }, + "plugins": [ + { + "name": "fastsqla", + "source": "./", + "description": "Use FastSQLA's current setup, async session, and pagination APIs correctly.", + "version": "1.0.0", + "author": { + "name": "Hadrien David", + "email": "h@driendavid.com" + }, + "homepage": "https://hadrien.github.io/FastSQLA/", + "repository": "https://github.com/hadrien/FastSQLA", + "license": "MIT", + "keywords": ["fastapi", "sqlalchemy", "async", "pagination", "sqlmodel"], + "category": "development" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..42dd18b --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin.json", + "name": "fastsqla", + "version": "1.0.0", + "description": "Use FastSQLA's current setup, async session, and pagination APIs correctly.", + "author": { + "name": "Hadrien David", + "email": "h@driendavid.com" + }, + "homepage": "https://hadrien.github.io/FastSQLA/", + "repository": "https://github.com/hadrien/FastSQLA", + "license": "MIT", + "keywords": ["fastapi", "sqlalchemy", "async", "pagination", "sqlmodel"], + "skills": "./skills/" +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..1d14309 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "fastsqla", + "version": "1.0.0", + "description": "Use FastSQLA's current setup, async session, and pagination APIs correctly.", + "author": { + "name": "Hadrien David", + "email": "h@driendavid.com", + "url": "https://github.com/hadrien" + }, + "homepage": "https://hadrien.github.io/FastSQLA/", + "repository": "https://github.com/hadrien/FastSQLA", + "license": "MIT", + "keywords": ["fastapi", "sqlalchemy", "async", "pagination", "sqlmodel"], + "skills": "./skills/", + "interface": { + "displayName": "FastSQLA", + "shortDescription": "FastSQLA setup, sessions, and pagination", + "longDescription": "Build FastAPI applications with FastSQLA's current async SQLAlchemy and SQLModel APIs.", + "developerName": "Hadrien David", + "category": "Productivity", + "capabilities": ["Read", "Write"], + "websiteURL": "https://hadrien.github.io/FastSQLA/", + "defaultPrompt": [ + "Set up FastSQLA in this FastAPI application.", + "Use FastSQLA to add an async database endpoint.", + "Paginate this SQLAlchemy query with FastSQLA." + ] + } +} diff --git a/README.md b/README.md index cdc1f7c..940a733 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ providing boilerplate and intuitive helpers. Additionally, it offers built-in customizable pagination and automatically manages the `SQLAlchemy` session lifecycle following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it). - ## Features * Easy setup at app startup using @@ -308,3 +307,33 @@ You can also check the generated openapi doc by opening your browser to ## License This project is licensed under the terms of the [MIT license](https://github.com/hadrien/FastSQLA/blob/main/LICENSE). + +## For coding agents and LLMs + +FastSQLA publishes agent-readable documentation alongside the website: + +- [`llms.txt`](https://hadrien.github.io/FastSQLA/llms.txt) is the concise documentation + index. +- [`llms-full.txt`](https://hadrien.github.io/FastSQLA/llms-full.txt) contains the + complete documentation in one file. +- Every indexed page has a Markdown twin, such as + [`setup/index.md`](https://hadrien.github.io/FastSQLA/setup/index.md). +- [Context7](https://context7.com/hadrien/fastsqla) serves the current documentation and + FastSQLA-specific usage rules. + +The repository also bundles Agent Skills for setup, session management, and pagination. +Install all three as one plugin: + +### Claude Code + +```bash +claude plugin marketplace add hadrien/FastSQLA +claude plugin install fastsqla@fastsqla +``` + +### Codex + +```bash +codex plugin marketplace add hadrien/FastSQLA +codex plugin add fastsqla@fastsqla +``` diff --git a/context7.json b/context7.json index fd8c004..ae86cd8 100644 --- a/context7.json +++ b/context7.json @@ -1 +1,16 @@ -{"url": "https://context7.com/hadrien/fastsqla","public_key": "pk_HGTiXpaLrQ2YW61qLOKbF"} +{ + "$schema": "https://context7.com/schema/context7.json", + "url": "https://context7.com/hadrien/fastsqla", + "public_key": "pk_HGTiXpaLrQ2YW61qLOKbF", + "projectTitle": "FastSQLA", + "description": "Async SQLAlchemy sessions and pagination for FastAPI, with optional SQLModel support.", + "folders": ["docs", "skills"], + "excludeFiles": ["CHANGELOG.md"], + "rules": [ + "Configure FastAPI with fastsqla.lifespan or new_lifespan(); do not construct engines or sessions manually.", + "Declare fastsqla.Session as an endpoint parameter; FastSQLA commits on success and rolls back on exceptions.", + "Use fastsqla.open_session() outside request handlers, and only while the application lifespan is active.", + "Use fastsqla.Paginate and Page[T] for list endpoints; call await paginate(select(...)).", + "Install FastSQLA[sqlmodel] when using SQLModel; FastSQLA selects SQLModel's AsyncSession automatically." + ] +} diff --git a/docs/javascripts/markdown-actions.js b/docs/javascripts/markdown-actions.js new file mode 100644 index 0000000..1fd9ac5 --- /dev/null +++ b/docs/javascripts/markdown-actions.js @@ -0,0 +1,48 @@ +const bindMarkdownCopy = () => { + const button = document.querySelector("[data-copy-markdown]"); + if (!button || button.dataset.copyBound === "true") { + return; + } + + button.dataset.copyBound = "true"; + button.addEventListener("click", async () => { + const actions = button.closest("[data-markdown-url]"); + const status = actions?.querySelector("[role='status']"); + const label = button.querySelector("[data-copy-label]"); + const resetCopyState = () => { + if (label) label.textContent = "Copy for LLM"; + if (status) status.textContent = ""; + }; + + try { + if (!actions || !status || !label || !navigator.clipboard) { + throw new Error("Markdown copy controls are unavailable."); + } + + const response = await fetch(actions.dataset.markdownUrl); + if (!response.ok) { + throw new Error(`Markdown request failed with status ${response.status}.`); + } + + await navigator.clipboard.writeText(await response.text()); + label.textContent = "Copied"; + status.textContent = "Page Markdown copied"; + window.setTimeout(resetCopyState, 2000); + } catch (error) { + if (label) { + label.textContent = "Copy failed"; + } + if (status) { + status.textContent = "Page Markdown could not be copied"; + } + window.setTimeout(resetCopyState, 2000); + console.error("Unable to copy the Markdown page.", error); + } + }); +}; + +if (typeof document$ === "undefined") { + document.addEventListener("DOMContentLoaded", bindMarkdownCopy); +} else { + document$.subscribe(bindMarkdownCopy); +} diff --git a/docs/stylesheets/markdown-actions.css b/docs/stylesheets/markdown-actions.css new file mode 100644 index 0000000..17ffae6 --- /dev/null +++ b/docs/stylesheets/markdown-actions.css @@ -0,0 +1,82 @@ +.md-typeset h1:has(+ .md-content__markdown-actions) { + margin-block-end: 0.4rem; +} + +.md-content__markdown-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 0.4rem; + margin-block-end: 1.2rem; +} + +.md-content__markdown-action { + display: inline-flex; + min-width: 2.2rem; + height: 2.2rem; + align-items: center; + justify-content: center; + gap: 0.3rem; + padding: 0 0.4rem; + border: 0; + border-radius: 0.15rem; + appearance: none; + background: transparent; + color: var(--md-default-fg-color--light); + cursor: pointer; + font: inherit; + font-size: 0.6rem; + font-weight: 500; + line-height: 1; + position: relative; + text-decoration: none; + transition: color 125ms ease-out; +} + +.md-typeset .md-content__markdown-action:is(:hover, :focus-visible) { + --markdown-action-border: var(--markdown-action-brand, var(--md-accent-fg-color)); + --markdown-action-bg: var(--markdown-action-brand-bg, var(--md-accent-fg-color--transparent)); + color: var(--markdown-action-brand, var(--md-accent-fg-color)); + text-decoration: none; +} + +.md-typeset .md-content__markdown-action:focus-visible { + outline: 0.1rem solid var(--markdown-action-brand, var(--md-accent-fg-color)); + outline-offset: 0.1rem; +} + +.md-content__markdown-action--icon { + width: 2.2rem; + padding: 0; +} + +.md-content__markdown-action::before { + content: ""; position: absolute; + inset: 0.3rem 0; + border: 0.05rem solid var(--markdown-action-border, var(--md-default-fg-color--light)); + border-radius: inherit; + background: var(--markdown-action-bg, transparent); + transition: border-color 125ms ease-out, background-color 125ms ease-out; +} +.md-content__markdown-action--icon::before { inset: 0.3rem; } +.md-content__markdown-action--chatgpt { --markdown-action-brand: #10a37f; --markdown-action-brand-bg: #10a37f14; } +.md-content__markdown-action--claude { --markdown-action-brand: #d97757; --markdown-action-brand-bg: #d9775714; } + +.md-content__markdown-agents { + display: inline-flex; + gap: 0; +} + +.md-content__markdown-action-icon, +.md-content__markdown-action > svg { + width: 0.8rem; + height: 0.8rem; + flex: 0 0 auto; + fill: currentcolor; +} + +@media (prefers-reduced-motion: reduce) { + .md-content__markdown-action { + transition: none; + } +} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..a4da0c8 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,147 @@ +# Troubleshooting + +Search for the exact error text below, then apply the correction for that failure mode. + +## `Missing sqlalchemy_url in environ.` + +The default [`fastsqla.lifespan`][fastsqla.lifespan] reads the database URL from +`SQLALCHEMY_URL`. The variable was absent when FastAPI started. + +Set an async SQLAlchemy URL before starting the application: + +```bash +export SQLALCHEMY_URL=sqlite+aiosqlite:///db.sqlite +uvicorn example:app +``` + +For configuration in code, create the lifespan explicitly: + +```python +from fastapi import FastAPI +from fastsqla import new_lifespan + +app = FastAPI(lifespan=new_lifespan("sqlite+aiosqlite:///db.sqlite")) +``` + +## `The asyncio extension requires an async driver to be used` + +The URL selects a synchronous database driver. For example, `sqlite:///db.sqlite` loads +Python's synchronous `pysqlite` driver and produces: + +```text +The asyncio extension requires an async driver to be used. +The loaded 'pysqlite' is not async. +``` + +Install the asynchronous driver for the database and include it in the URL: + +| Database | Install | URL prefix | +|------------|-------------------------|------------------------| +| PostgreSQL | `pip install asyncpg` | `postgresql+asyncpg://` | +| SQLite | `pip install aiosqlite` | `sqlite+aiosqlite:///` | +| MySQL | `pip install aiomysql` | `mysql+aiomysql://` | + +## `Could not locate a bind configured on SQL expression or this Session.` + +A [`Session`][fastsqla.Session] or [`open_session()`][fastsqla.open_session] operation ran +outside the FastSQLA lifespan. The lifespan binds the shared session factory at startup +and clears it at shutdown. + +Attach the lifespan to FastAPI: + +```python +from fastapi import FastAPI +from fastsqla import lifespan + +app = FastAPI(lifespan=lifespan) +``` + +Use `Session` only in endpoint parameters. Use `open_session()` for background work that +runs after application startup and finishes before shutdown: + +```python +from fastsqla import open_session +from sqlalchemy import select + +async def refresh_cache() -> None: + async with open_session() as session: + heroes = (await session.scalars(select(Hero))).all() +``` + +Do not import or configure `SessionFactory`; it is an internal lifecycle detail. + +## `MissingGreenlet: greenlet_spawn has not been called` + +SQLAlchemy attempted implicit database I/O while ordinary Python code accessed an +unloaded ORM attribute. This often happens when response serialization touches a +lazy-loaded relationship. + +Load relationships explicitly inside the awaited query. `selectinload()` is a good +default for collections: + +```python +from fastsqla import Session +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +async def get_team(team_id: int, session: Session) -> Team: + stmt = ( + select(Team) + .where(Team.id == team_id) + .options(selectinload(Team.heroes)) + ) + return (await session.scalars(stmt)).one() +``` + +See SQLAlchemy's guidance on +[preventing implicit I/O with `AsyncSession`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#preventing-implicit-io-when-using-asyncsession). + +## `Input should be greater than or equal to 0` + +The built-in [`Paginate`][fastsqla.Paginate] dependency rejects a negative `offset`. +Start at zero: + +```text +GET /heroes?offset=0&limit=10 +``` + +## `Input should be less than or equal to 100` + +The built-in `Paginate` dependency accepts `limit` values from 1 through 100. Use a +smaller value or define an intentional maximum: + +```python +from typing import Annotated + +from fastapi import Depends +from fastsqla import PaginateType, new_pagination + +LargePage = Annotated[ + PaginateType[HeroModel], + Depends(new_pagination(min_page_size=10, max_page_size=250)), +] +``` + +## `This Session's transaction has been rolled back due to a previous exception` + +A database operation such as `flush()` raised an exception, but application code caught +it and then tried to keep using the invalid transaction. + +Translate the original database error into an exception and let FastSQLA roll back: + +```python +from fastapi import HTTPException +from fastsqla import Session +from sqlalchemy.exc import IntegrityError + +async def create_hero(new_hero: HeroInput, session: Session) -> Hero: + hero = Hero(**new_hero.model_dump()) + session.add(hero) + try: + await session.flush() + except IntegrityError as exc: + raise HTTPException(status_code=409, detail="Hero already exists") from exc + return hero +``` + +Do not catch and ignore `IntegrityError`, and do not call `commit()` inside an endpoint. diff --git a/mkdocs.yml b/mkdocs.yml index 4f51665..93fb275 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: FastSQLA -site_url: https://mydomain.org/FastSQLA +site_url: https://hadrien.github.io/FastSQLA/ repo_name: hadrien/FastSQLA repo_url: https://github.com/hadrien/fastsqla edit_uri: edit/main/docs/ @@ -12,9 +12,11 @@ nav: - Object-relational mapping: orm.md - SQLAlchemy Session: session.md - Pagination: pagination.md + - Troubleshooting: troubleshooting.md - Changelog: changelog.md theme: + custom_dir: overrides favicon: images/favicon.png icon: logo: material/database @@ -52,6 +54,21 @@ theme: icon: material/weather-night name: Switch to system preference +extra: + markdown_pages: &markdown_pages + - index.md + - setup.md + - session.md + - orm.md + - pagination.md + - troubleshooting.md + +extra_css: + - stylesheets/markdown-actions.css + +extra_javascript: + - javascripts/markdown-actions.js + plugins: - autorefs - glightbox @@ -71,12 +88,7 @@ plugins: Async SQLAlchemy 2.0+ extension for FastAPI with SQLModel support, built-in pagination & more. full_output: llms-full.txt sections: - Sections: - - index.md - - setup.md - - session.md - - orm.md - - pagination.md + Sections: *markdown_pages markdown_extensions: - abbr @@ -97,4 +109,4 @@ markdown_extensions: permalink: true watch: - docs - - src \ No newline at end of file + - src diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..6c642f0 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% set has_markdown_twin = page and page.file.src_uri in config.extra.markdown_pages %} +{% if has_markdown_twin %} + {% set markdown_path = page.url ~ "index.md" %} +{% endif %} + +{% block extrahead %} + {{ super() }} + {% if has_markdown_twin %} + + {% endif %} +{% endblock %} + +{% block content %} + {% set rendered_content %}{{ super() }}{% endset %} + {% if has_markdown_twin %} + {% set markdown_url = config.site_url.rstrip('/') ~ "/" ~ markdown_path %} + {% set llm_prompt = "Read " ~ markdown_url ~ " and answer questions about the content." %} + {% set markdown_actions %} +
+ + + + View Markdown + + + + {% include ".icons/fontawesome/brands/openai.svg" %} + + + {% include ".icons/simple/claude.svg" %} + + + +
+ {% endset %} + {{ rendered_content | replace("", "" ~ markdown_actions, 1) }} + {% else %} + {{ rendered_content }} + {% endif %} +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 47ff39e..87c4734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ "Topic :: Software Development", "Typing :: Typed", ] -keywords = ["FastAPI", "SQLAlchemy", "AsyncIO"] +keywords = ["fastapi", "sqlalchemy", "async", "session", "pagination", "sqlmodel"] dependencies = ["fastapi>=0.115.6", "sqlalchemy[asyncio]>=2.0.37", "structlog>=24.4.0"] [project.urls] diff --git a/skills/fastsqla-pagination/SKILL.md b/skills/fastsqla-pagination/SKILL.md index 6427679..064b777 100644 --- a/skills/fastsqla-pagination/SKILL.md +++ b/skills/fastsqla-pagination/SKILL.md @@ -55,12 +55,12 @@ Default query parameters added to the endpoint: ```python from fastapi import FastAPI -from fastsqla import Base, Page, Paginate +from fastsqla import Base, Page, Paginate, lifespan from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Mapped, mapped_column -app = FastAPI() +app = FastAPI(lifespan=lifespan) class Hero(Base): __tablename__ = "hero" @@ -78,6 +78,10 @@ async def list_heroes(paginate: Paginate[HeroModel]) -> Page[HeroModel]: return await paginate(select(Hero)) ``` +Set `SQLALCHEMY_URL` to an async SQLAlchemy URL for a database that contains the +mapped `hero` table before starting the app. For example, +`sqlite+aiosqlite:///app.db` uses the `aiosqlite` driver. + A request to `GET /heroes?offset=20&limit=10` returns the third page of results. ## Adding Filters @@ -105,7 +109,7 @@ For custom pagination behavior, use `new_pagination()` to create a new dependenc | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `min_page_size` | `int` | `10` | Default and minimum `limit` value | +| `min_page_size` | `int` | `10` | Default `limit` value | | `max_page_size` | `int` | `100` | Maximum allowed `limit` value | | `query_count_dependency` | `Callable[..., Awaitable[int]] \| None` | `None` | FastAPI dependency returning total item count. When `None`, uses `SELECT COUNT(*) FROM (subquery)`. | | `result_processor` | `Callable[[Result], Iterable]` | `lambda r: iter(r.unique().scalars())` | Transforms the SQLAlchemy `Result` into an iterable of items |