From 8431f08123af8fe4e78bacfb54c80b07611e101c Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 23:08:18 +0300 Subject: [PATCH] feat: add Python GraphQL category (Strawberry) Add pythonGraphql as a new category in the Python ecosystem with Strawberry as the initial option. Strawberry uses Python dataclasses and type hints for a code-first GraphQL schema approach. - Schema: PythonGraphqlSchema with "strawberry" and "none" values - Templates: graphql_schema.py.hbs with Book query/mutation example - Framework integration: FastAPI (GraphQLRouter), Flask (GraphQLView), Django (GraphQLView), Litestar (make_graphql_controller) - Dependencies: strawberry-graphql with framework-specific extras ([fastapi], [flask], [django], [litestar]) - Full CLI, web builder, MCP, and smoke test wiring --- apps/cli/src/constants.ts | 1 + apps/cli/src/helpers/core/command-handlers.ts | 1 + apps/cli/src/index.ts | 4 ++ apps/cli/src/mcp.ts | 6 ++- apps/cli/src/prompts/config-prompts.ts | 8 +++ apps/cli/src/prompts/python-ecosystem.ts | 28 ++++++++++ apps/cli/src/utils/bts-config.ts | 1 + apps/cli/src/utils/config-processing.ts | 5 ++ .../utils/generate-reproducible-command.ts | 1 + apps/cli/test/add-history-commands.test.ts | 3 ++ .../generate-reproducible-command.test.ts | 5 ++ apps/cli/test/template-snapshots.test.ts | 5 ++ apps/web/src/lib/constant.ts | 23 ++++++++ apps/web/src/lib/preview-config.ts | 1 + apps/web/src/lib/stack-defaults.ts | 2 + .../web/src/lib/stack-option-normalization.ts | 1 + apps/web/src/lib/stack-url-keys.ts | 1 + apps/web/src/lib/stack-url-state.ts | 3 ++ apps/web/src/lib/stack-utils.ts | 4 ++ apps/web/src/lib/tech-icons.ts | 1 + apps/web/src/lib/tech-resource-links.ts | 1 + .../templates/python-base/pyproject.toml.hbs | 17 ++++++ .../python-base/src/app/graphql_schema.py.hbs | 52 +++++++++++++++++++ .../templates/python-base/src/app/main.py.hbs | 35 ++++++++++++- .../python-base/tests/test_main.py.hbs | 12 +++++ packages/types/src/compatibility.ts | 4 ++ packages/types/src/option-metadata.ts | 7 +++ packages/types/src/schemas.ts | 6 +++ packages/types/src/types.ts | 2 + testing/lib/generate-combos/options.ts | 3 ++ testing/lib/generate-combos/render.ts | 2 + testing/lib/generate-combos/types.ts | 1 + testing/lib/presets.ts | 3 ++ 33 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 packages/template-generator/templates/python-base/src/app/graphql_schema.py.hbs diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index f4def88b7..fe257a831 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -81,6 +81,7 @@ export const DEFAULT_CONFIG_BASE = { pythonAi: [], pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", // Go ecosystem defaults goWebFramework: "gin", diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index 86fd819ae..19de58ef8 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -173,6 +173,7 @@ export async function createProjectHandler( pythonAi: [], pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "none", goWebFramework: "none", goOrm: "none", diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4297362ed..b89e1d89e 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -111,6 +111,8 @@ import { type PythonAuth, PythonTaskQueueSchema, type PythonTaskQueue, + PythonGraphqlSchema, + type PythonGraphql, PythonQualitySchema, type PythonQuality, GoWebFrameworkSchema, @@ -269,6 +271,7 @@ export const router = os.router({ pythonAi: z.array(PythonAiSchema).optional().describe("Python AI/ML frameworks"), pythonAuth: PythonAuthSchema.optional().describe("Python auth library (authlib, jwt)"), pythonTaskQueue: PythonTaskQueueSchema.optional().describe("Python task queue (celery)"), + pythonGraphql: PythonGraphqlSchema.optional().describe("Python GraphQL framework (strawberry)"), pythonQuality: PythonQualitySchema.optional().describe("Python code quality (ruff)"), // Go ecosystem options goWebFramework: GoWebFrameworkSchema.optional().describe("Go web framework (gin, echo, fiber)"), @@ -593,6 +596,7 @@ export async function createVirtual( pythonAi: options.pythonAi || [], pythonAuth: options.pythonAuth || "none", pythonTaskQueue: options.pythonTaskQueue || "none", + pythonGraphql: options.pythonGraphql || "none", pythonQuality: options.pythonQuality || "none", // Go ecosystem options goWebFramework: options.goWebFramework || "none", diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index e314a8126..2ecfae4d0 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -41,6 +41,7 @@ import { PythonAuthSchema, PythonOrmSchema, PythonQualitySchema, + PythonGraphqlSchema, PythonTaskQueueSchema, PythonValidationSchema, PythonWebFrameworkSchema, @@ -196,6 +197,7 @@ const SCHEMA_MAP: Record = { pythonAi: PythonAiSchema, pythonAuth: PythonAuthSchema, pythonTaskQueue: PythonTaskQueueSchema, + pythonGraphql: PythonGraphqlSchema, pythonQuality: PythonQualitySchema, goWebFramework: GoWebFrameworkSchema, goOrm: GoOrmSchema, @@ -213,7 +215,7 @@ const ECOSYSTEM_CATEGORIES: Record = { "search", "fileStorage", "astroIntegration", ], rust: ["rustWebFramework", "rustFrontend", "rustOrm", "rustApi", "rustCli", "rustLibraries", "rustLogging", "rustErrorHandling", "rustCaching"], - python: ["pythonWebFramework", "pythonOrm", "pythonValidation", "pythonAi", "pythonAuth", "pythonTaskQueue", "pythonQuality"], + python: ["pythonWebFramework", "pythonOrm", "pythonValidation", "pythonAi", "pythonAuth", "pythonTaskQueue", "pythonGraphql", "pythonQuality"], go: ["goWebFramework", "goOrm", "goApi", "goCli", "goLogging"], shared: ["ecosystem", "packageManager", "addons", "examples", "webDeploy", "serverDeploy", "dbSetup"], }; @@ -339,6 +341,7 @@ function buildProjectConfig( pythonAi: (input.pythonAi as ProjectConfig["pythonAi"]) ?? [], pythonAuth: (input.pythonAuth as ProjectConfig["pythonAuth"]) ?? "none", pythonTaskQueue: (input.pythonTaskQueue as ProjectConfig["pythonTaskQueue"]) ?? "none", + pythonGraphql: (input.pythonGraphql as ProjectConfig["pythonGraphql"]) ?? "none", pythonQuality: (input.pythonQuality as ProjectConfig["pythonQuality"]) ?? "none", goWebFramework: (input.goWebFramework as ProjectConfig["goWebFramework"]) ?? "none", goOrm: (input.goOrm as ProjectConfig["goOrm"]) ?? "none", @@ -435,6 +438,7 @@ function buildCompatibilityInput(input: Record): CompatibilityI pythonAi: ((input.pythonAi as string[]) ?? []).join(",") || "none", pythonAuth: (input.pythonAuth as string) ?? "none", pythonTaskQueue: (input.pythonTaskQueue as string) ?? "none", + pythonGraphql: (input.pythonGraphql as string) ?? "none", pythonQuality: (input.pythonQuality as string) ?? "none", goWebFramework: (input.goWebFramework as string) ?? "none", goOrm: (input.goOrm as string) ?? "none", diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 5f25a1412..bf275086d 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -37,6 +37,7 @@ import type { PythonAuth, PythonOrm, PythonQuality, + PythonGraphql, PythonTaskQueue, PythonValidation, PythonWebFramework, @@ -104,6 +105,7 @@ import { getPaymentsChoice } from "./payments"; import { getPythonAiChoice, getPythonAuthChoice, + getPythonGraphqlChoice, getPythonOrmChoice, getPythonQualityChoice, getPythonTaskQueueChoice, @@ -189,6 +191,7 @@ type PromptGroupResults = { pythonAi: PythonAi[]; pythonAuth: PythonAuth; pythonTaskQueue: PythonTaskQueue; + pythonGraphql: PythonGraphql; pythonQuality: PythonQuality; // Go ecosystem goWebFramework: GoWebFramework; @@ -482,6 +485,10 @@ export async function gatherConfig( if (results.ecosystem !== "python") return Promise.resolve("none" as PythonTaskQueue); return getPythonTaskQueueChoice(flags.pythonTaskQueue); }, + pythonGraphql: ({ results }) => { + if (results.ecosystem !== "python") return Promise.resolve("none" as PythonGraphql); + return getPythonGraphqlChoice(flags.pythonGraphql); + }, pythonQuality: ({ results }) => { if (results.ecosystem !== "python") return Promise.resolve("none" as PythonQuality); return getPythonQualityChoice(flags.pythonQuality); @@ -590,6 +597,7 @@ export async function gatherConfig( pythonAi: result.pythonAi, pythonAuth: result.pythonAuth, pythonTaskQueue: result.pythonTaskQueue, + pythonGraphql: result.pythonGraphql, pythonQuality: result.pythonQuality, // Go ecosystem options goWebFramework: result.goWebFramework, diff --git a/apps/cli/src/prompts/python-ecosystem.ts b/apps/cli/src/prompts/python-ecosystem.ts index c7e50bdb8..b8a3ca66d 100644 --- a/apps/cli/src/prompts/python-ecosystem.ts +++ b/apps/cli/src/prompts/python-ecosystem.ts @@ -1,6 +1,7 @@ import type { PythonAi, PythonAuth, + PythonGraphql, PythonOrm, PythonQuality, PythonTaskQueue, @@ -226,6 +227,33 @@ export async function getPythonTaskQueueChoice(pythonTaskQueue?: PythonTaskQueue return response; } +export async function getPythonGraphqlChoice(pythonGraphql?: PythonGraphql) { + if (pythonGraphql !== undefined) return pythonGraphql; + + const options = [ + { + value: "strawberry" as const, + label: "Strawberry", + hint: "Python GraphQL library using dataclasses and type hints", + }, + { + value: "none" as const, + label: "None", + hint: "No GraphQL framework", + }, + ]; + + const response = await navigableSelect({ + message: "Select Python GraphQL framework", + options, + initialValue: "none", + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} + export async function getPythonQualityChoice(pythonQuality?: PythonQuality) { if (pythonQuality !== undefined) return pythonQuality; diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index c663643c3..aab077bf4 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -64,6 +64,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { pythonAi: projectConfig.pythonAi, pythonAuth: projectConfig.pythonAuth, pythonTaskQueue: projectConfig.pythonTaskQueue, + pythonGraphql: projectConfig.pythonGraphql, pythonQuality: projectConfig.pythonQuality, goWebFramework: projectConfig.goWebFramework, goOrm: projectConfig.goOrm, diff --git a/apps/cli/src/utils/config-processing.ts b/apps/cli/src/utils/config-processing.ts index dd7a2f5d0..5dcb844fd 100644 --- a/apps/cli/src/utils/config-processing.ts +++ b/apps/cli/src/utils/config-processing.ts @@ -39,6 +39,7 @@ import type { PythonAuth, PythonOrm, PythonQuality, + PythonGraphql, PythonTaskQueue, PythonValidation, PythonWebFramework, @@ -342,6 +343,10 @@ export function processFlags(options: CLIInput, projectName?: string) { config.pythonTaskQueue = options.pythonTaskQueue as PythonTaskQueue; } + if (options.pythonGraphql !== undefined) { + config.pythonGraphql = options.pythonGraphql as PythonGraphql; + } + if (options.pythonQuality !== undefined) { config.pythonQuality = options.pythonQuality as PythonQuality; } diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 8334eafcf..179e99c31 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -141,6 +141,7 @@ function getPythonFlags(config: ProjectConfig) { flags.push(formatArrayFlag("python-ai", config.pythonAi)); flags.push(`--python-auth ${config.pythonAuth}`); flags.push(`--python-task-queue ${config.pythonTaskQueue}`); + flags.push(`--python-graphql ${config.pythonGraphql}`); flags.push(`--python-quality ${config.pythonQuality}`); appendSharedNonTypeScriptFlags(flags, config); diff --git a/apps/cli/test/add-history-commands.test.ts b/apps/cli/test/add-history-commands.test.ts index 6bc363c63..177b04be5 100644 --- a/apps/cli/test/add-history-commands.test.ts +++ b/apps/cli/test/add-history-commands.test.ts @@ -202,6 +202,7 @@ describe("CLI history command", () => { "--python-ai none " + "--python-auth none " + "--python-task-queue celery " + + "--python-graphql none " + "--python-quality ruff " + "--addons none " + "--examples none " + @@ -231,6 +232,8 @@ describe("CLI history command", () => { "none", "--python-task-queue", "celery", + "--python-graphql", + "none", "--python-quality", "ruff", "--addons", diff --git a/apps/cli/test/generate-reproducible-command.test.ts b/apps/cli/test/generate-reproducible-command.test.ts index 92bde28b3..8f62eba23 100644 --- a/apps/cli/test/generate-reproducible-command.test.ts +++ b/apps/cli/test/generate-reproducible-command.test.ts @@ -70,6 +70,7 @@ function makeConfig(overrides: Partial = {}): ProjectConfig { pythonAi: [], pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "none", goWebFramework: "none", goOrm: "none", @@ -121,6 +122,7 @@ describe("generateReproducibleCommand", () => { pythonAi: [], pythonAuth: "none", pythonTaskQueue: "celery", + pythonGraphql: "none", pythonQuality: "ruff", aiDocs: ["claude-md"], }); @@ -134,6 +136,7 @@ describe("generateReproducibleCommand", () => { "--python-ai none " + "--python-auth none " + "--python-task-queue celery " + + "--python-graphql none " + "--python-quality ruff " + "--addons none " + "--examples none " + @@ -186,6 +189,7 @@ describe("generateReproducibleCommand", () => { pythonAi: ["langchain", "openai-sdk"], pythonAuth: "none", pythonTaskQueue: "celery", + pythonGraphql: "none", pythonQuality: "ruff", aiDocs: ["claude-md", "agents-md"], }); @@ -201,6 +205,7 @@ describe("generateReproducibleCommand", () => { "--python-ai langchain openai-sdk " + "--python-auth none " + "--python-task-queue celery " + + "--python-graphql none " + "--python-quality ruff " + "--addons skills " + "--examples none " + diff --git a/apps/cli/test/template-snapshots.test.ts b/apps/cli/test/template-snapshots.test.ts index c76026962..034090701 100644 --- a/apps/cli/test/template-snapshots.test.ts +++ b/apps/cli/test/template-snapshots.test.ts @@ -503,6 +503,7 @@ describe("Template Snapshots - Python Ecosystem", () => { pythonValidation: "pydantic" as const, pythonAi: [] as const, pythonTaskQueue: "celery" as const, + pythonGraphql: "none" as const, pythonQuality: "ruff" as const, }, }, @@ -515,6 +516,7 @@ describe("Template Snapshots - Python Ecosystem", () => { pythonValidation: "pydantic" as const, pythonAi: ["langchain"] as const, pythonTaskQueue: "none" as const, + pythonGraphql: "none" as const, pythonQuality: "ruff" as const, }, }, @@ -527,6 +529,7 @@ describe("Template Snapshots - Python Ecosystem", () => { pythonValidation: "pydantic" as const, pythonAi: ["openai-sdk", "anthropic-sdk"] as const, pythonTaskQueue: "none" as const, + pythonGraphql: "none" as const, pythonQuality: "none" as const, }, }, @@ -539,6 +542,7 @@ describe("Template Snapshots - Python Ecosystem", () => { pythonValidation: "pydantic" as const, pythonAi: [] as const, pythonTaskQueue: "none" as const, + pythonGraphql: "none" as const, pythonQuality: "ruff" as const, }, }, @@ -551,6 +555,7 @@ describe("Template Snapshots - Python Ecosystem", () => { pythonValidation: "pydantic" as const, pythonAi: [] as const, pythonTaskQueue: "none" as const, + pythonGraphql: "none" as const, pythonQuality: "ruff" as const, }, }, diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index aacc43d6e..8aa133713 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -3003,6 +3003,24 @@ export const TECH_OPTIONS: Record< default: true, }, ], + pythonGraphql: [ + { + id: "strawberry", + name: "Strawberry", + description: "Python GraphQL library using dataclasses and type hints", + icon: "https://cdn.simpleicons.org/graphql/E10098", + color: "from-pink-500 to-red-600", + default: false, + }, + { + id: "none", + name: "No GraphQL", + description: "Skip Python GraphQL framework selection", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], pythonQuality: [ { id: "ruff", @@ -3274,6 +3292,7 @@ export const ECOSYSTEM_CATEGORIES: Record = { "pythonAi", "pythonAuth", "pythonTaskQueue", + "pythonGraphql", "pythonQuality", "aiDocs", "git", @@ -4153,6 +4172,7 @@ export const PRESET_TEMPLATES: { pythonAi: "none", pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", aiDocs: ["claude-md"], git: "true", @@ -4173,6 +4193,7 @@ export const PRESET_TEMPLATES: { pythonAi: "none", pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", aiDocs: ["claude-md"], git: "true", @@ -4193,6 +4214,7 @@ export const PRESET_TEMPLATES: { pythonAi: "langchain", pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", aiDocs: ["claude-md"], git: "true", @@ -4213,6 +4235,7 @@ export const PRESET_TEMPLATES: { pythonAi: "anthropic-sdk", pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", aiDocs: ["claude-md"], git: "true", diff --git a/apps/web/src/lib/preview-config.ts b/apps/web/src/lib/preview-config.ts index 8f3194a1f..ae951e08c 100644 --- a/apps/web/src/lib/preview-config.ts +++ b/apps/web/src/lib/preview-config.ts @@ -115,6 +115,7 @@ export function stackStateToProjectConfig(input: Partial): ProjectCo pythonAi: stack.pythonAi === "none" ? [] : ([stack.pythonAi] as ProjectConfig["pythonAi"]), pythonAuth: stack.pythonAuth as ProjectConfig["pythonAuth"], pythonTaskQueue: stack.pythonTaskQueue as ProjectConfig["pythonTaskQueue"], + pythonGraphql: stack.pythonGraphql as ProjectConfig["pythonGraphql"], pythonQuality: stack.pythonQuality as ProjectConfig["pythonQuality"], goWebFramework: stack.goWebFramework as ProjectConfig["goWebFramework"], goOrm: stack.goOrm as ProjectConfig["goOrm"], diff --git a/apps/web/src/lib/stack-defaults.ts b/apps/web/src/lib/stack-defaults.ts index c835a0821..7afb52ef4 100644 --- a/apps/web/src/lib/stack-defaults.ts +++ b/apps/web/src/lib/stack-defaults.ts @@ -69,6 +69,7 @@ export type StackState = { pythonAi: string; pythonAuth: string; pythonTaskQueue: string; + pythonGraphql: string; pythonQuality: string; goWebFramework: string; goOrm: string; @@ -146,6 +147,7 @@ export const DEFAULT_STACK: StackState = { pythonAi: "none", pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", goWebFramework: "gin", goOrm: "gorm", diff --git a/apps/web/src/lib/stack-option-normalization.ts b/apps/web/src/lib/stack-option-normalization.ts index b7b5afd18..b0c84aa2f 100644 --- a/apps/web/src/lib/stack-option-normalization.ts +++ b/apps/web/src/lib/stack-option-normalization.ts @@ -64,6 +64,7 @@ const STACK_OPTION_CATEGORY_BY_KEY: Partial = [ "pythonAi", "pythonAuth", "pythonTaskQueue", + "pythonGraphql", "pythonQuality", "aiDocs", "git", @@ -363,6 +364,9 @@ function generatePythonCommand(stack: StackState, projectName: string) { if (stack.pythonTaskQueue !== "none") { flags.push(`--python-task-queue ${stack.pythonTaskQueue}`); } + if (stack.pythonGraphql !== "none") { + flags.push(`--python-graphql ${stack.pythonGraphql}`); + } if (stack.pythonQuality !== "none") { flags.push(`--python-quality ${stack.pythonQuality}`); } diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index c3d8aaa95..71d1f1a28 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -336,6 +336,7 @@ export const ICON_REGISTRY: Record = { authlib: { type: "si", slug: "auth0", hex: "EB5424" }, jwt: { type: "si", slug: "jsonwebtokens", hex: "000000" }, celery: { type: "si", slug: "celery", hex: "37814A" }, + strawberry: { type: "si", slug: "graphql", hex: "E10098" }, ruff: { type: "si", slug: "ruff", hex: "D7FF64" }, // ─── Go ──────────────────────────────────────────────────────────────────── diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index f562efcc0..de9f6bd37 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -742,6 +742,7 @@ const BASE_LINKS: LinkMap = { authlib: { docsUrl: "https://docs.authlib.org/en/latest/", githubUrl: "https://github.com/lepture/authlib" }, jwt: { docsUrl: "https://python-jose.readthedocs.io/en/latest/", githubUrl: "https://github.com/mpdavis/python-jose" }, celery: { docsUrl: "https://docs.celeryq.dev/", githubUrl: "https://github.com/celery/celery" }, + strawberry: { docsUrl: "https://strawberry.rocks/docs", githubUrl: "https://github.com/strawberry-graphql/strawberry" }, ruff: { docsUrl: "https://docs.astral.sh/ruff/", githubUrl: "https://github.com/astral-sh/ruff" }, gin: { docsUrl: "https://gin-gonic.com/docs/", githubUrl: "https://github.com/gin-gonic/gin" }, echo: { diff --git a/packages/template-generator/templates/python-base/pyproject.toml.hbs b/packages/template-generator/templates/python-base/pyproject.toml.hbs index c2c052453..5c55b24e4 100644 --- a/packages/template-generator/templates/python-base/pyproject.toml.hbs +++ b/packages/template-generator/templates/python-base/pyproject.toml.hbs @@ -71,6 +71,23 @@ dependencies = [ {{/if}} {{#if (eq pythonTaskQueue "celery")}} "celery[redis]>=5.6.3", +{{/if}} +{{#if (eq pythonGraphql "strawberry")}} +{{#if (eq pythonWebFramework "fastapi")}} + "strawberry-graphql[fastapi]>=0.262.0", +{{/if}} +{{#if (eq pythonWebFramework "flask")}} + "strawberry-graphql[flask]>=0.262.0", +{{/if}} +{{#if (eq pythonWebFramework "django")}} + "strawberry-graphql[django]>=0.262.0", +{{/if}} +{{#if (eq pythonWebFramework "litestar")}} + "strawberry-graphql[litestar]>=0.262.0", +{{/if}} +{{#if (eq pythonWebFramework "none")}} + "strawberry-graphql>=0.262.0", +{{/if}} {{/if}} "python-dotenv>=1.2.2", ] diff --git a/packages/template-generator/templates/python-base/src/app/graphql_schema.py.hbs b/packages/template-generator/templates/python-base/src/app/graphql_schema.py.hbs new file mode 100644 index 000000000..a6cb19102 --- /dev/null +++ b/packages/template-generator/templates/python-base/src/app/graphql_schema.py.hbs @@ -0,0 +1,52 @@ +{{#if (eq pythonGraphql "strawberry")}} +"""GraphQL schema using Strawberry.""" + +import strawberry +from typing import Optional + + +@strawberry.type +class Book: + title: str + author: str + year: Optional[int] = None + + +books_db: list[Book] = [ + Book(title="The Great Gatsby", author="F. Scott Fitzgerald", year=1925), + Book(title="To Kill a Mockingbird", author="Harper Lee", year=1960), +] + + +@strawberry.input +class BookInput: + title: str + author: str + year: Optional[int] = None + + +@strawberry.type +class Query: + @strawberry.field + def books(self) -> list[Book]: + return books_db + + @strawberry.field + def book(self, title: str) -> Optional[Book]: + for book in books_db: + if book.title.lower() == title.lower(): + return book + return None + + +@strawberry.type +class Mutation: + @strawberry.mutation + def add_book(self, input: BookInput) -> Book: + book = Book(title=input.title, author=input.author, year=input.year) + books_db.append(book) + return book + + +schema = strawberry.Schema(query=Query, mutation=Mutation) +{{/if}} diff --git a/packages/template-generator/templates/python-base/src/app/main.py.hbs b/packages/template-generator/templates/python-base/src/app/main.py.hbs index 08a095321..01c380396 100644 --- a/packages/template-generator/templates/python-base/src/app/main.py.hbs +++ b/packages/template-generator/templates/python-base/src/app/main.py.hbs @@ -152,6 +152,11 @@ from app.celery_schemas import ( {{#if (ne pythonAuth "none")}} from app.auth import create_access_token, verify_token {{/if}} +{{#if (eq pythonGraphql "strawberry")}} +from strawberry.fastapi import GraphQLRouter + +from app.graphql_schema import schema as graphql_schema +{{/if}} load_dotenv() @@ -192,6 +197,10 @@ app.add_middleware( allow_headers=["*"], ) +{{#if (eq pythonGraphql "strawberry")}} +graphql_app = GraphQLRouter(graphql_schema) +app.include_router(graphql_app, prefix="/graphql") +{{/if}} @app.get("/") async def root(): @@ -923,6 +932,11 @@ from django.urls import path {{#if (ne pythonAuth "none")}} from app.auth import create_access_token, verify_token {{/if}} +{{#if (eq pythonGraphql "strawberry")}} +from strawberry.django.views import GraphQLView + +from app.graphql_schema import schema as graphql_schema +{{/if}} load_dotenv() @@ -989,6 +1003,9 @@ urlpatterns = [ path("auth/token", auth_token), path("auth/me", auth_me), {{/if}} +{{#if (eq pythonGraphql "strawberry")}} + path("graphql", GraphQLView.as_view(schema=graphql_schema)), +{{/if}} ] @@ -1008,6 +1025,11 @@ from app.settings import get_settings {{#if (ne pythonAuth "none")}} from app.auth import create_access_token, verify_token {{/if}} +{{#if (eq pythonGraphql "strawberry")}} +from strawberry.flask.views import GraphQLView + +from app.graphql_schema import schema as graphql_schema +{{/if}} load_dotenv() @@ -1020,6 +1042,9 @@ app.config["APP_NAME"] = settings.app_name app = Flask(__name__) {{/if}} CORS(app) +{{#if (eq pythonGraphql "strawberry")}} +app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql_view", schema=graphql_schema)) +{{/if}} @app.route("/") @@ -1075,6 +1100,11 @@ from litestar.exceptions import HTTPException from app.auth import create_access_token, verify_token {{/if}} +{{#if (eq pythonGraphql "strawberry")}} +from strawberry.litestar import make_graphql_controller + +from app.graphql_schema import schema as graphql_schema +{{/if}} load_dotenv() @@ -1117,8 +1147,11 @@ async def auth_me(authorization: str = "") -> dict: cors_config = CORSConfig(allow_origins=["*"]) +{{#if (eq pythonGraphql "strawberry")}} +GraphQLController = make_graphql_controller(graphql_schema, path="/graphql") +{{/if}} app = Litestar( - route_handlers=[index, health{{#if (ne pythonAuth "none")}}, auth_token, auth_me{{/if}}], + route_handlers=[index, health{{#if (ne pythonAuth "none")}}, auth_token, auth_me{{/if}}{{#if (eq pythonGraphql "strawberry")}}, GraphQLController{{/if}}], cors_config=cors_config, ) {{/if}} diff --git a/packages/template-generator/templates/python-base/tests/test_main.py.hbs b/packages/template-generator/templates/python-base/tests/test_main.py.hbs index e83de7b3e..0676da901 100644 --- a/packages/template-generator/templates/python-base/tests/test_main.py.hbs +++ b/packages/template-generator/templates/python-base/tests/test_main.py.hbs @@ -386,6 +386,18 @@ def test_send_message_invalid_email(client): ) assert response.status_code == 422 {{/if}} +{{#if (eq pythonGraphql "strawberry")}} + + +def test_graphql_endpoint(client): + """Test GraphQL endpoint returns data.""" + query = '{ "query": "{ books { title author } }" }' + response = client.post("/graphql", content=query, headers={"Content-Type": "application/json"}) + assert response.status_code == 200 + data = response.json() + assert "data" in data + assert "books" in data["data"] +{{/if}} {{/if}} {{#if (eq pythonWebFramework "django")}} import pytest diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 082b077bd..0415db8f5 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -72,6 +72,7 @@ export type CompatibilityCategory = | "pythonAi" | "pythonAuth" | "pythonTaskQueue" + | "pythonGraphql" | "pythonQuality" | "goWebFramework" | "goOrm" @@ -157,6 +158,7 @@ export type CompatibilityInput = { pythonAi: string; pythonAuth: string; pythonTaskQueue: string; + pythonGraphql: string; pythonQuality: string; goWebFramework: string; goOrm: string; @@ -225,6 +227,7 @@ const CATEGORY_ORDER: CompatibilityCategory[] = [ "pythonAi", "pythonAuth", "pythonTaskQueue", + "pythonGraphql", "pythonQuality", "goWebFramework", "goOrm", @@ -314,6 +317,7 @@ export const getCategoryDisplayName = (categoryKey: string): string => { pythonAi: "Python AI / ML", pythonAuth: "Python Auth", pythonTaskQueue: "Python Task Queue", + pythonGraphql: "Python GraphQL", pythonQuality: "Python Code Quality", }; diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index 715e577cd..92e0db061 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -33,6 +33,7 @@ import { PYTHON_AI_VALUES, PYTHON_AUTH_VALUES, PYTHON_ORM_VALUES, + PYTHON_GRAPHQL_VALUES, PYTHON_QUALITY_VALUES, PYTHON_TASK_QUEUE_VALUES, PYTHON_VALIDATION_VALUES, @@ -131,6 +132,7 @@ export type OptionCategory = | "pythonAi" | "pythonAuth" | "pythonTaskQueue" + | "pythonGraphql" | "pythonQuality" | "goWebFramework" | "goOrm" @@ -305,6 +307,7 @@ const CATEGORY_VALUE_IDS: Record = { pythonAi: PYTHON_AI_VALUES, pythonAuth: PYTHON_AUTH_VALUES, pythonTaskQueue: PYTHON_TASK_QUEUE_VALUES, + pythonGraphql: PYTHON_GRAPHQL_VALUES, pythonQuality: PYTHON_QUALITY_VALUES, goWebFramework: GO_WEB_FRAMEWORK_VALUES, goOrm: GO_ORM_VALUES, @@ -595,6 +598,9 @@ const EXACT_LABEL_OVERRIDES: Partial; export type PythonAi = z.infer; export type PythonAuth = z.infer; export type PythonTaskQueue = z.infer; +export type PythonGraphql = z.infer; export type PythonQuality = z.infer; export type GoWebFramework = z.infer; export type GoOrm = z.infer; diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index 46c7ae116..13def6f17 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -37,6 +37,7 @@ import { PYTHON_AI_VALUES, PYTHON_AUTH_VALUES, PYTHON_ORM_VALUES, + PYTHON_GRAPHQL_VALUES, PYTHON_QUALITY_VALUES, PYTHON_TASK_QUEUE_VALUES, PYTHON_VALIDATION_VALUES, @@ -316,6 +317,7 @@ function makePythonDraft(args: GeneratorArgs): CandidateDraft { pythonAi: sampleArray(PYTHON_AI_VALUES, 0.5, 1), pythonAuth: sampleScalar(PYTHON_AUTH_VALUES, 0.5), pythonTaskQueue: sampleScalar(PYTHON_TASK_QUEUE_VALUES, 0.55), + pythonGraphql: sampleScalar(PYTHON_GRAPHQL_VALUES, 0.5), pythonQuality: sampleScalar(PYTHON_QUALITY_VALUES, 0.35), }, }; @@ -420,6 +422,7 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje pythonAi: [], pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "none", goWebFramework: "none", goOrm: "none", diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index 03df1e7eb..110afdaec 100644 --- a/testing/lib/generate-combos/render.ts +++ b/testing/lib/generate-combos/render.ts @@ -48,6 +48,7 @@ export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): str ? fingerprint.pythonAi.filter((value) => value !== "none").join("-") : undefined, typeof fingerprint.pythonTaskQueue === "string" ? fingerprint.pythonTaskQueue : undefined, + typeof fingerprint.pythonGraphql === "string" ? fingerprint.pythonGraphql : undefined, typeof fingerprint.pythonQuality === "string" ? fingerprint.pythonQuality : undefined, ], go: [ @@ -128,6 +129,7 @@ export function buildCommand(name: string, config: ProjectConfig): string { ["python-validation", config.pythonValidation], ["python-ai", withExplicitNone(config.pythonAi)], ["python-task-queue", config.pythonTaskQueue], + ["python-graphql", config.pythonGraphql], ["python-quality", config.pythonQuality], ]; diff --git a/testing/lib/generate-combos/types.ts b/testing/lib/generate-combos/types.ts index e18e61708..83303e9a5 100644 --- a/testing/lib/generate-combos/types.ts +++ b/testing/lib/generate-combos/types.ts @@ -88,6 +88,7 @@ export const TEMPLATE_FINGERPRINT_KEYS = [ "pythonValidation", "pythonAi", "pythonTaskQueue", + "pythonGraphql", "pythonQuality", "goWebFramework", "goOrm", diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts index 07097c7d8..6ad506fd1 100644 --- a/testing/lib/presets.ts +++ b/testing/lib/presets.ts @@ -64,6 +64,7 @@ export function makeBaseConfig(name: string, ecosystem: Ecosystem): ProjectConfi pythonAi: [], pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "none", goWebFramework: "none", goOrm: "none", @@ -329,6 +330,7 @@ const SMOKE_TEST_PRESETS: Record = { pythonAi: [], pythonAuth: "none", pythonTaskQueue: "celery", + pythonGraphql: "none", pythonQuality: "ruff", }, }, @@ -341,6 +343,7 @@ const SMOKE_TEST_PRESETS: Record = { pythonAi: ["langchain"], pythonAuth: "none", pythonTaskQueue: "none", + pythonGraphql: "none", pythonQuality: "ruff", }, },