diff --git a/CHANGELOG.md b/CHANGELOG.md index 911ae56..5435667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.1.0] - 2026-06-02 + +### Added +- **Python parity — 5 new Python addons** bringing Python to feature parity with Node.js: + - `--email` — SMTP email service via built-in `smtplib`; `app/core/email.py` + `app/services/external/email_service.py` with `send`, `send_welcome`, `send_password_reset`, `send_notification` + - `--s3` — AWS S3 / Cloudflare R2 / MinIO storage via `boto3`; `app/core/storage.py` + `app/services/external/storage_service.py` with `upload`, `get_presigned_url`, `delete`, `exists`; injects `boto3>=1.35.0` into `pyproject.toml` + - `--oauth` — Google + GitHub OAuth2 via `httpx` (already in base deps); `app/core/oauth.py`, `app/services/oauth_service.py`, `app/routes/api/v1/oauth.py`; routes at `/api/v1/oauth/google` and `/api/v1/oauth/github` + - `--api-docs` — Custom OpenAPI schema with rich description + tag metadata; `app/core/openapi.py` + `main.py` override; Swagger UI at `/docs`, ReDoc at `/redoc` + - `--websocket` — Native FastAPI WebSocket with JWT auth; `app/routes/ws/notifications.py` + `app/services/notification_service.py` (`NotificationManager` with `send_to_user`, `broadcast`) +- **Queue addons** — background job processing for both stacks: + - **Node.js `--queue`** — BullMQ + Redis: `src/plugins/queue.plugin.ts` (queue registry), `src/services/queue.service.ts` (wrapper with retry logic), `src/workers/example.worker.ts`; injects `bullmq>=5.0.0` + - **Python `--queue`** — arq + Redis (Redis already in base stack): `app/services/queue_service.py` (pool + `enqueue()` helper), `app/workers/example_worker.py` (`WorkerSettings` for arq CLI); injects `arq>=0.26.0` +- **`archgen config` command** — preset management via `.archgenrc.json`: + - `archgen config init` — interactive wizard to create `.archgenrc.json` with default language, author, docker/testing/ci preferences + - `archgen config show` — display active preset (searches upward from cwd) + - `archgen config reset` — delete `.archgenrc.json` from cwd + - `archgen create` now auto-loads preset; CLI flags always take priority +- **Interactive addon discovery** — `archgen create` interactive mode now shows a multiselect for all extra addons (websocket, oauth, api-docs, email, s3, queue) so users can discover and enable them without memorising flags +- **`--queue` flag** — new global flag for both Node and Python + +### Fixed +- CLI flag descriptions updated: removed stale "Node.js only" labels from `--websocket`, `--oauth`, `--api-docs`, `--email`, `--s3` which now also work for Python + +### Tests +- **17 new unit tests** in `python-plugin.test.ts` covering all 5 new Python addons (metadata + apply/skip logic) +- Total: **142 unit tests** (up from 125) + ## [1.0.8] - 2026-05-14 ### Added diff --git a/cli/command/config.ts b/cli/command/config.ts new file mode 100644 index 0000000..13b240f --- /dev/null +++ b/cli/command/config.ts @@ -0,0 +1,108 @@ +import path from "path"; +import { Command } from "commander"; +import prompts from "prompts"; +import chalk from "chalk"; +import { writePreset, loadPreset, findPresetFile, ArchGenPreset } from "../../core/config-preset"; +import { logger } from "../../core/logger"; + +export const configCommand = new Command("config") + .description("Manage archgen project presets (.archgenrc.json)") + .addCommand( + new Command("init") + .description("Create a .archgenrc.json preset in the current directory") + .action(async () => { + const existing = findPresetFile(process.cwd()); + if (existing) { + logger.warn(`Preset already exists at ${existing}`); + logger.info("Edit it directly or run 'archgen config show' to view it."); + return; + } + + const answers = await prompts([ + { + type: "select", + name: "language", + message: "Default language:", + choices: [ + { title: "Node.js (TypeScript + Fastify)", value: "node" }, + { title: "Python (FastAPI)", value: "python" }, + ], + }, + { + type: "text", + name: "author", + message: "Default author name:", + initial: "", + }, + { + type: "confirm", + name: "docker", + message: "Always include Docker?", + initial: false, + }, + { + type: "confirm", + name: "testing", + message: "Always include testing setup?", + initial: false, + }, + { + type: "confirm", + name: "ci", + message: "Always include GitHub Actions CI?", + initial: false, + }, + ], { + onCancel: () => { + logger.info("\nCancelled."); + process.exit(0); + }, + }); + + const preset: ArchGenPreset = { + language: answers.language, + ...(answers.author ? { author: answers.author } : {}), + docker: answers.docker, + testing: answers.testing, + ci: answers.ci, + }; + + const filePath = await writePreset(process.cwd(), preset); + console.log(); + console.log(chalk.green(` ✔ Created ${filePath}`)); + console.log(chalk.dim(" Edit it freely — all keys are optional.")); + console.log(); + }), + ) + .addCommand( + new Command("show") + .description("Show the active preset (searches upward from current directory)") + .action(() => { + const filePath = findPresetFile(process.cwd()); + if (!filePath) { + logger.info("No .archgenrc.json found. Run 'archgen config init' to create one."); + return; + } + const preset = loadPreset(filePath); + console.log(); + console.log(chalk.bold(` Active preset: ${path.relative(process.cwd(), filePath)}\n`)); + for (const [k, v] of Object.entries(preset)) { + console.log(` ${chalk.cyan(k.padEnd(14))} ${chalk.white(String(v))}`); + } + console.log(); + }), + ) + .addCommand( + new Command("reset") + .description("Delete .archgenrc.json in the current directory") + .action(async () => { + const filePath = path.join(process.cwd(), ".archgenrc.json"); + const { default: fs } = await import("fs-extra"); + if (!fs.existsSync(filePath)) { + logger.info("No .archgenrc.json in the current directory."); + return; + } + await fs.remove(filePath); + logger.success("Removed .archgenrc.json"); + }), + ); diff --git a/cli/command/index.ts b/cli/command/index.ts index 2209fbf..33df4fc 100644 --- a/cli/command/index.ts +++ b/cli/command/index.ts @@ -3,6 +3,7 @@ import { ArchGen } from "../../core/archgen"; import { ArchGenError } from "../../core/errors"; import { logger } from "../../core/logger"; import { promptMissingOptions } from "../prompts"; +import { findPresetFile, loadPreset, mergePreset } from "../../core/config-preset"; const VALID_NODE_DATABASES = ["mysql", "postgresql"]; const VALID_PYTHON_DATABASES = ["postgresql", "sqlite"]; @@ -13,14 +14,15 @@ export const createCommand = new Command("create") .option("--docker", "Include Docker setup", false) .option("--testing", "Include testing setup", false) .option("--ci", "Include GitHub Actions CI workflow", false) - .option("--husky", "Include Husky + lint-staged setup (Node.js only)") - .option("--websocket", "Include WebSocket support via Socket.io (Node.js only)") - .option("--oauth", "Include OAuth2 providers — Google + GitHub (Node.js only)") - .option("--api-docs", "Include Scalar API reference UI (Node.js only)") + .option("--husky", "Include Husky + lint-staged setup (Node only)") + .option("--websocket", "Include WebSocket support (Socket.io for Node, native for Python)") + .option("--oauth", "Include OAuth2 providers — Google + GitHub") + .option("--api-docs", "Include API reference UI (Scalar for Node, built-in OpenAPI for Python)") .option("--claude-code", "Include Claude Code setup (CLAUDE.md + .claude/skills/)") .option("--cursor", "Include Cursor agent setup (.cursor/skills/)") - .option("--email", "Include Resend email service (Node.js only)") - .option("--s3", "Include AWS S3 / Cloudflare R2 storage service (Node.js only)") + .option("--email", "Include email service (SMTP)") + .option("--s3", "Include AWS S3 / Cloudflare R2 / MinIO storage service") + .option("--queue", "Include background job queue (BullMQ for Node, arq for Python)") .option("--all", "Include all addons (docker + testing + ci)", false) .option("-a, --author ", "Author name") .option("-d, --description ", "Project description") @@ -37,6 +39,14 @@ export const createCommand = new Command("create") options.ci = true; } + // Merge .archgenrc.json preset (CLI flags take priority) + const presetFile = findPresetFile(); + if (presetFile) { + const preset = loadPreset(presetFile); + options = mergePreset(preset, options as Record) as typeof options; + logger.debug(`Loaded preset from ${presetFile}`); + } + // Database validation is deferred until after prompts resolve the language const finalOptions = await promptMissingOptions(projectName, options); diff --git a/cli/index.ts b/cli/index.ts index 587ce7a..79f6850 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -8,6 +8,7 @@ import { addCommand } from "./command/add"; import { upgradeCommand } from "./command/upgrade"; import { doctorCommand } from "./command/doctor"; import { completionCommand } from "./command/completion"; +import { configCommand } from "./command/config"; import { logger } from "../core/logger"; import { setSpinnerLevel } from "../core/spinner"; import { checkForUpdate } from "../core/update-notifier"; @@ -93,6 +94,7 @@ program.addCommand(infoCommand); program.addCommand(addCommand); program.addCommand(upgradeCommand); program.addCommand(doctorCommand); +program.addCommand(configCommand); program.addCommand(completionCommand); program.parse(); diff --git a/cli/prompts.ts b/cli/prompts.ts index f5f02d9..62992d6 100644 --- a/cli/prompts.ts +++ b/cli/prompts.ts @@ -112,6 +112,35 @@ export async function promptMissingOptions( }); } + const extraAddons = ["websocket", "oauth", "apiDocs", "email", "s3", "queue"] as const; + const anyExtraSet = extraAddons.some((k) => options[k] !== undefined); + if (!anyExtraSet) { + const fixedLang = options.language; + questions.push({ + type: (_prev, values) => { + const lang = fixedLang ?? (values as GenerateOptions).language; + return lang === "node" || lang === "python" ? "multiselect" : null; + }, + name: "extraAddons", + message: "Include extra addons? (Space to select, Enter to confirm)", + choices: (_prev, values) => { + const lang = fixedLang ?? (values as GenerateOptions).language; + const nodeOnly = lang === "node"; + const all = [ + { title: "WebSocket", value: "websocket", selected: false }, + { title: "OAuth2 (Google + GitHub)", value: "oauth", selected: false }, + { title: "API Docs", value: "apiDocs", selected: false }, + { title: "Email (SMTP)", value: "email", selected: false }, + { title: "Storage (S3 / R2 / MinIO)", value: "s3", selected: false }, + { title: "Queue (BullMQ / arq)", value: "queue", selected: false }, + { title: "Husky + lint-staged", value: "husky", selected: false, disabled: !nodeOnly }, + ]; + return nodeOnly ? all : all.filter((c) => c.value !== "husky"); + }, + hint: "none to skip", + }); + } + if (questions.length === 0) return options; const answers = await prompts(questions, { @@ -136,5 +165,16 @@ export async function promptMissingOptions( raw.cursor = (raw.aiAgents as string[]).includes("cursor"); delete raw.aiAgents; } + if (Array.isArray(raw.extraAddons)) { + const selected = raw.extraAddons as string[]; + raw.websocket = selected.includes("websocket"); + raw.oauth = selected.includes("oauth"); + raw.apiDocs = selected.includes("apiDocs"); + raw.email = selected.includes("email"); + raw.s3 = selected.includes("s3"); + raw.queue = selected.includes("queue"); + if (selected.includes("husky")) raw.husky = true; + delete raw.extraAddons; + } return raw as unknown as GenerateOptions; } diff --git a/core/config-preset.ts b/core/config-preset.ts new file mode 100644 index 0000000..3681524 --- /dev/null +++ b/core/config-preset.ts @@ -0,0 +1,61 @@ +import fs from "fs-extra"; +import path from "path"; + +export interface ArchGenPreset { + language?: string; + author?: string; + description?: string; + database?: string; + docker?: boolean; + testing?: boolean; + ci?: boolean; + husky?: boolean; + websocket?: boolean; + oauth?: boolean; + apiDocs?: boolean; + email?: boolean; + s3?: boolean; + queue?: boolean; + claudeCode?: boolean; + cursor?: boolean; +} + +const CONFIG_FILE = ".archgenrc.json"; + +export function findPresetFile(startDir: string = process.cwd()): string | null { + let dir = startDir; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, CONFIG_FILE); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +export function loadPreset(filePath: string): ArchGenPreset { + try { + return fs.readJsonSync(filePath) as ArchGenPreset; + } catch { + return {}; + } +} + +export function mergePreset(preset: ArchGenPreset, cliOptions: Record): Record { + const merged: Record = { ...preset }; + for (const [k, v] of Object.entries(cliOptions)) { + if (v !== undefined && v !== false) { + merged[k] = v; + } else if (v === false && merged[k] === undefined) { + merged[k] = false; + } + } + return merged; +} + +export async function writePreset(dir: string, preset: ArchGenPreset): Promise { + const filePath = path.join(dir, CONFIG_FILE); + await fs.writeJson(filePath, preset, { spaces: 2 }); + return filePath; +} diff --git a/package.json b/package.json index 2d4a2c0..9296c3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kidkender/archgen", - "version": "1.0.8", + "version": "1.1.0", "description": "Generate production-ready Node.js and Python project structures in seconds", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/plugins/node/config.ts b/plugins/node/config.ts index 63a5cd5..1c986bc 100644 --- a/plugins/node/config.ts +++ b/plugins/node/config.ts @@ -3,5 +3,5 @@ import { PluginConfig } from "../../types"; export const nodeConfig: PluginConfig = { name: "node-typescript", description: "Node.js Typescript backend with Fastify", - addons: ["docker", "testing", "ci", "husky", "websocket", "oauth", "api-docs", "claude-code", "cursor", "email", "s3"], + addons: ["docker", "testing", "ci", "husky", "websocket", "oauth", "api-docs", "claude-code", "cursor", "email", "s3", "queue"], }; diff --git a/plugins/node/index.ts b/plugins/node/index.ts index 272b0ea..42f54fc 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -97,6 +97,11 @@ export class NodePlugin extends BasePlugin { path: path.join(addonsPath, "s3"), label: "Storage (AWS S3)", }, + { + condition: !!options.queue, + path: path.join(addonsPath, "queue"), + label: "Queue (BullMQ)", + }, ]; } @@ -117,6 +122,7 @@ export class NodePlugin extends BasePlugin { extraDeps["@aws-sdk/client-s3"] = "^3.600.0"; extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; } + if (addon === "queue") extraDeps["bullmq"] = "^5.0.0"; if (Object.keys(extraDeps).length === 0) return; @@ -147,6 +153,7 @@ export class NodePlugin extends BasePlugin { extraDeps["@aws-sdk/client-s3"] = "^3.600.0"; extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; } + if (options.queue) extraDeps["bullmq"] = "^5.0.0"; if (Object.keys(extraDeps).length === 0) return; @@ -231,6 +238,12 @@ export class NodePlugin extends BasePlugin { console.log(" For Cloudflare R2 / MinIO, set S3_ENDPOINT too"); console.log(" Register storagePlugin in src/app.ts, then inject StorageService into your routes"); } + if (options.queue) { + console.log(""); + console.log(" Queue (BullMQ) — register queuePlugin in src/app.ts"); + console.log(" Use fastify.queues.getQueue('example') to enqueue jobs"); + console.log(" Run workers: node dist/src/workers/example.worker.js"); + } console.log(""); } } diff --git a/plugins/node/template/addons/queue/src/plugins/queue.plugin.ts b/plugins/node/template/addons/queue/src/plugins/queue.plugin.ts new file mode 100644 index 0000000..f854ffa --- /dev/null +++ b/plugins/node/template/addons/queue/src/plugins/queue.plugin.ts @@ -0,0 +1,43 @@ +import fp from "fastify-plugin"; +import { FastifyPluginAsync } from "fastify"; +import { Queue, Worker, QueueEvents } from "bullmq"; +import { env } from "../config/env"; + +interface QueueRegistry { + getQueue(name: string): Queue; + close(): Promise; +} + +declare module "fastify" { + interface FastifyInstance { + queues: QueueRegistry; + } +} + +const queuePlugin: FastifyPluginAsync = fp(async (fastify) => { + const connection = { url: env.REDIS_URL }; + const registry = new Map(); + + const queues: QueueRegistry = { + getQueue(name: string): Queue { + if (!registry.has(name)) { + registry.set(name, new Queue(name, { connection })); + } + return registry.get(name)!; + }, + async close(): Promise { + await Promise.all([...registry.values()].map((q) => q.close())); + }, + }; + + fastify.decorate("queues", queues); + + fastify.addHook("onClose", async () => { + await queues.close(); + }); + + fastify.log.info("Queue plugin initialized (BullMQ)"); +}); + +export { Queue, Worker, QueueEvents }; +export default queuePlugin; diff --git a/plugins/node/template/addons/queue/src/services/queue.service.ts b/plugins/node/template/addons/queue/src/services/queue.service.ts new file mode 100644 index 0000000..5a051af --- /dev/null +++ b/plugins/node/template/addons/queue/src/services/queue.service.ts @@ -0,0 +1,30 @@ +import { FastifyInstance } from "fastify"; +import { JobsOptions } from "bullmq"; +import { ExampleJobData } from "../workers/example.worker"; + +export class QueueService { + constructor(private readonly fastify: FastifyInstance) {} + + async addExampleJob(data: ExampleJobData, opts?: JobsOptions): Promise { + const queue = this.fastify.queues.getQueue("example"); + const job = await queue.add("process", data, { + attempts: 3, + backoff: { type: "exponential", delay: 1000 }, + removeOnComplete: 100, + removeOnFail: 50, + ...opts, + }); + return job.id ?? "unknown"; + } + + async getQueueStats(name: string) { + const queue = this.fastify.queues.getQueue(name); + const [waiting, active, completed, failed] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + ]); + return { name, waiting, active, completed, failed }; + } +} diff --git a/plugins/node/template/addons/queue/src/workers/example.worker.ts b/plugins/node/template/addons/queue/src/workers/example.worker.ts new file mode 100644 index 0000000..78fc5b1 --- /dev/null +++ b/plugins/node/template/addons/queue/src/workers/example.worker.ts @@ -0,0 +1,28 @@ +import { Worker, Job } from "bullmq"; +import { env } from "../config/env"; + +const connection = { url: env.REDIS_URL }; + +export interface ExampleJobData { + message: string; + userId?: number; +} + +export const exampleWorker = new Worker( + "example", + async (job: Job) => { + console.log(`[Worker] Processing job ${job.id}: ${job.data.message}`); + // TODO: replace with real job logic + await new Promise((res) => setTimeout(res, 100)); + return { processed: true }; + }, + { connection }, +); + +exampleWorker.on("completed", (job) => { + console.log(`[Worker] Job ${job.id} completed`); +}); + +exampleWorker.on("failed", (job, err) => { + console.error(`[Worker] Job ${job?.id} failed: ${err.message}`); +}); diff --git a/plugins/python/config.ts b/plugins/python/config.ts index 7aa3725..595c075 100644 --- a/plugins/python/config.ts +++ b/plugins/python/config.ts @@ -4,5 +4,5 @@ import { PluginConfig } from "../../types"; export const pythonConfig: PluginConfig = { name: "python-fastapi", description: "Python FastAPI backend with full production features", - addons: ["docker", "testing", "ci", "claude-code", "cursor"] + addons: ["docker", "testing", "ci", "claude-code", "cursor", "email", "s3", "oauth", "api-docs", "websocket", "queue"] } diff --git a/plugins/python/index.ts b/plugins/python/index.ts index f6c81e7..d73c3f2 100644 --- a/plugins/python/index.ts +++ b/plugins/python/index.ts @@ -1,8 +1,10 @@ import path from "path"; +import fs from "fs-extra"; import { BasePlugin, AddonEntry } from "../../core/base-plugin"; import { TemplateVariables } from "../../core/template-engine"; -import { GenerateOptions, StackInfo } from "../../types"; +import { AddAddonOptions, GenerateOptions, StackInfo } from "../../types"; import { pythonConfig } from "./config"; +import { logger } from "../../core/logger"; export class PythonPlugin extends BasePlugin { readonly name = pythonConfig.name; @@ -65,9 +67,86 @@ export class PythonPlugin extends BasePlugin { path: path.join(addonsPath, "cursor"), label: "Cursor agent setup", }, + { + condition: !!options.email, + path: path.join(addonsPath, "email"), + label: "Email (SMTP)", + }, + { + condition: !!options.s3, + path: path.join(addonsPath, "s3"), + label: "Storage (AWS S3)", + }, + { + condition: !!options.oauth, + path: path.join(addonsPath, "oauth"), + label: "OAuth2 (Google + GitHub)", + }, + { + condition: !!options.apiDocs, + path: path.join(addonsPath, "api-docs"), + label: "API docs (OpenAPI)", + }, + { + condition: !!options.websocket, + path: path.join(addonsPath, "websocket"), + label: "WebSocket (native FastAPI)", + }, + { + condition: !!options.queue, + path: path.join(addonsPath, "queue"), + label: "Queue (arq + Redis)", + }, ]; } + async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { + await super.applyAddon(projectPath, addon, options); + if (options.dryRun) return; + + if (addon === "s3") { + const pyprojectPath = path.join(projectPath, "pyproject.toml"); + await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); + logger.info("Updated pyproject.toml with boto3"); + } + if (addon === "queue") { + const pyprojectPath = path.join(projectPath, "pyproject.toml"); + await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); + logger.info("Updated pyproject.toml with arq"); + } + } + + async generate(projectName: string, options: GenerateOptions): Promise { + await super.generate(projectName, options); + if (options.dryRun) return; + + if (options.s3) { + const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); + const pyprojectPath = path.join(outputPath, "pyproject.toml"); + await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); + } + if (options.queue) { + const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); + const pyprojectPath = path.join(outputPath, "pyproject.toml"); + await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); + } + } + + private async _injectPyprojectDep(pyprojectPath: string, dep: string): Promise { + try { + let content = await fs.readFile(pyprojectPath, "utf8"); + if (content.includes(dep.split(">=")[0])) return; + // Insert before the closing ] of the dependencies array + content = content.replace( + /^(\s*"pyjwt[^"]*",?\n)(\])/m, + `$1 "${dep}",\n$2`, + ); + await fs.writeFile(pyprojectPath, content, "utf8"); + } catch { + logger.warn(`Could not inject ${dep} into pyproject.toml`); + } + } + protected async readProjectName(projectPath: string): Promise { const raw = await this.fs.readFile(path.join(projectPath, "pyproject.toml")); const match = raw.match(/^name\s*=\s*"([^"]+)"/m); @@ -100,6 +179,40 @@ export class PythonPlugin extends BasePlugin { } console.log(` uvicorn main:app --reload`); } + if (options.email) { + console.log(""); + console.log(" Email (SMTP) — set MAIL_HOST, MAIL_USERNAME, MAIL_PASSWORD, MAIL_FROM_ADDRESS in .env"); + console.log(" Import email_service from app.services.external.email_service and call send()"); + } + if (options.s3) { + console.log(""); + console.log(" Storage (S3) — set S3_BUCKET, S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in .env"); + console.log(" For Cloudflare R2 / MinIO set S3_ENDPOINT too"); + console.log(" Import storage_service from app.services.external.storage_service"); + } + if (options.oauth) { + console.log(""); + console.log(" OAuth — Google + GitHub routes at /api/v1/oauth/*"); + console.log(" Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, APP_URL in .env"); + } + if (options.apiDocs) { + console.log(""); + console.log(" API Docs — Swagger UI: http://localhost:8000/docs"); + console.log(" ReDoc: http://localhost:8000/redoc"); + } + if (options.websocket) { + console.log(""); + console.log(" WebSocket — connect to ws://localhost:8000/ws/notifications?token="); + console.log(" Import notification_manager from app.services.notification_service"); + console.log(" Call await notification_manager.send_to_user(user_id, type_, title, message)"); + } + if (options.queue) { + console.log(""); + console.log(" Queue (arq) — Redis already in stack, arq uses the same connection"); + console.log(" Enqueue: from app.services.queue_service import enqueue"); + console.log(" await enqueue('example_job', message='hello')"); + console.log(" Run worker: arq app.workers.example_worker.WorkerSettings"); + } if (options.claudeCode) { console.log(""); console.log(" Claude Code — open this project in Claude Code to use pre-configured skills"); diff --git a/plugins/python/template/addons/api-docs/app/core/openapi.py b/plugins/python/template/addons/api-docs/app/core/openapi.py new file mode 100644 index 0000000..2cce32d --- /dev/null +++ b/plugins/python/template/addons/api-docs/app/core/openapi.py @@ -0,0 +1,43 @@ +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +tags_metadata = [ + { + "name": "health", + "description": "Service health and readiness checks.", + }, + { + "name": "Auth", + "description": "Register, login, and token management.", + }, + { + "name": "Users", + "description": "User profile and account management.", + }, + { + "name": "OAuth", + "description": "Third-party OAuth2 login (Google, GitHub).", + }, +] + + +def custom_openapi(app: FastAPI) -> dict: + if app.openapi_schema: + return app.openapi_schema + schema = get_openapi( + title=app.title, + version=app.version, + description=( + "## {{PROJECT_NAME}} API\n\n" + "Production-ready REST API built with **FastAPI**.\n\n" + "### Authentication\n" + "Most endpoints require a Bearer token obtained from `/api/v1/auth/login`.\n\n" + "```\nAuthorization: Bearer \n```" + ), + routes=app.routes, + tags=tags_metadata, + contact={"name": "{{AUTHOR}}"}, + license_info={"name": "ISC"}, + ) + app.openapi_schema = schema + return schema diff --git a/plugins/python/template/addons/api-docs/main.py b/plugins/python/template/addons/api-docs/main.py new file mode 100644 index 0000000..ab60994 --- /dev/null +++ b/plugins/python/template/addons/api-docs/main.py @@ -0,0 +1,75 @@ +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.database import init_db +from app.core.logging import get_logger, setup_logging +from app.core.openapi import custom_openapi +from app.core.redis_client import redis_client +from app.middleware import ( + AuthenticationMiddleware, + ErrorHandlingMiddleware, + LoggingMiddleware, + RateLimitMiddleware, +) +from app.routes import router +from app.schedulers import start_schedulers, stop_schedulers + +setup_logging() +logger = get_logger(__name__) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifespan events""" + logger.info("Starting application...") + await init_db() + logger.info("Database initialized") + await redis_client.connect() + logger.info("Redis connected") + start_schedulers() + logger.info("Schedulers started") + yield + logger.info("Shutting down application...") + stop_schedulers() + await redis_client.disconnect() + + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + lifespan=lifespan, +) + +# Custom OpenAPI schema with tags and rich description +app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign] + +app.add_middleware( + CORSMiddleware, # ty:ignore[invalid-argument-type] + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.add_middleware(ErrorHandlingMiddleware) +app.add_middleware(LoggingMiddleware) +app.add_middleware(RateLimitMiddleware) +app.add_middleware(AuthenticationMiddleware) + +app.state.settings = settings +app.include_router(router) + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG, + ) diff --git a/plugins/python/template/addons/email/.env.example b/plugins/python/template/addons/email/.env.example new file mode 100644 index 0000000..ba0900a --- /dev/null +++ b/plugins/python/template/addons/email/.env.example @@ -0,0 +1,9 @@ +# Email (SMTP) +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=your-app-password +MAIL_FROM_ADDRESS=your-email@gmail.com +MAIL_FROM_NAME={{PROJECT_NAME}} +MAIL_USE_TLS=true +MAIL_USE_SSL=false diff --git a/plugins/python/template/addons/email/app/core/email.py b/plugins/python/template/addons/email/app/core/email.py new file mode 100644 index 0000000..1a984d9 --- /dev/null +++ b/plugins/python/template/addons/email/app/core/email.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class EmailSettings(BaseSettings): + MAIL_HOST: str = "smtp.gmail.com" + MAIL_PORT: int = 587 + MAIL_USERNAME: str = "" + MAIL_PASSWORD: str = "" + MAIL_FROM_ADDRESS: str = "" + MAIL_FROM_NAME: str = "{{PROJECT_NAME}}" + MAIL_USE_TLS: bool = True + MAIL_USE_SSL: bool = False + + model_config = SettingsConfigDict(env_file=".env", case_sensitive=True, extra="ignore") + + +email_settings = EmailSettings() diff --git a/plugins/python/template/addons/email/app/services/external/email_service.py b/plugins/python/template/addons/email/app/services/external/email_service.py new file mode 100644 index 0000000..ddad407 --- /dev/null +++ b/plugins/python/template/addons/email/app/services/external/email_service.py @@ -0,0 +1,73 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Union + +from app.core.email import email_settings +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +class EmailService: + def __init__(self) -> None: + self.settings = email_settings + + def _create_connection(self) -> smtplib.SMTP: + if self.settings.MAIL_USE_SSL: + smtp: smtplib.SMTP = smtplib.SMTP_SSL(self.settings.MAIL_HOST, self.settings.MAIL_PORT) + else: + smtp = smtplib.SMTP(self.settings.MAIL_HOST, self.settings.MAIL_PORT) + if self.settings.MAIL_USE_TLS: + smtp.starttls() + smtp.login(self.settings.MAIL_USERNAME, self.settings.MAIL_PASSWORD) + return smtp + + def send( + self, + to: Union[str, list[str]], + subject: str, + html: str, + text: str | None = None, + ) -> bool: + recipients = [to] if isinstance(to, str) else to + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{self.settings.MAIL_FROM_NAME} <{self.settings.MAIL_FROM_ADDRESS}>" + msg["To"] = ", ".join(recipients) + if text: + msg.attach(MIMEText(text, "plain")) + msg.attach(MIMEText(html, "html")) + try: + with self._create_connection() as smtp: + smtp.sendmail(self.settings.MAIL_FROM_ADDRESS, recipients, msg.as_string()) + logger.info(f"Email sent to {recipients}") + return True + except Exception as exc: + logger.error(f"Failed to send email: {exc}") + return False + + def send_welcome(self, to: str, name: str) -> bool: + return self.send( + to=to, + subject=f"Welcome to {{PROJECT_NAME}}, {name}!", + html=f"

Welcome, {name}!

Thanks for joining {{PROJECT_NAME}}. We're glad to have you.

", + ) + + def send_password_reset(self, to: str, reset_link: str) -> bool: + return self.send( + to=to, + subject="Reset your password", + html=f""" +

Password Reset Request

+

Click the link below to reset your password. This link expires in 1 hour.

+ Reset Password +

If you didn't request this, you can safely ignore this email.

+ """, + ) + + def send_notification(self, to: Union[str, list[str]], subject: str, message: str) -> bool: + return self.send(to=to, subject=subject, html=f"

{message}

") + + +email_service = EmailService() diff --git a/plugins/python/template/addons/oauth/.env.example b/plugins/python/template/addons/oauth/.env.example new file mode 100644 index 0000000..7f27412 --- /dev/null +++ b/plugins/python/template/addons/oauth/.env.example @@ -0,0 +1,6 @@ +# OAuth (Google + GitHub) +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GITHUB_CLIENT_ID=your-github-client-id +GITHUB_CLIENT_SECRET=your-github-client-secret +APP_URL=http://localhost:8000 diff --git a/plugins/python/template/addons/oauth/app/core/oauth.py b/plugins/python/template/addons/oauth/app/core/oauth.py new file mode 100644 index 0000000..1b24885 --- /dev/null +++ b/plugins/python/template/addons/oauth/app/core/oauth.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class OAuthSettings(BaseSettings): + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + GITHUB_CLIENT_ID: str = "" + GITHUB_CLIENT_SECRET: str = "" + APP_URL: str = "http://localhost:8000" + + model_config = SettingsConfigDict(env_file=".env", case_sensitive=True, extra="ignore") + + +oauth_settings = OAuthSettings() diff --git a/plugins/python/template/addons/oauth/app/routes/api/v1/__init__.py b/plugins/python/template/addons/oauth/app/routes/api/v1/__init__.py new file mode 100644 index 0000000..4f06d08 --- /dev/null +++ b/plugins/python/template/addons/oauth/app/routes/api/v1/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.routes.api.v1 import auth, oauth, users + +api_router = APIRouter() + +api_router.include_router(users.router) +api_router.include_router(auth.router) +api_router.include_router(oauth.router) + +__all__ = ["api_router"] diff --git a/plugins/python/template/addons/oauth/app/routes/api/v1/oauth.py b/plugins/python/template/addons/oauth/app/routes/api/v1/oauth.py new file mode 100644 index 0000000..a01e87a --- /dev/null +++ b/plugins/python/template/addons/oauth/app/routes/api/v1/oauth.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.oauth import oauth_settings +from app.services.oauth_service import OAuthService + +router = APIRouter(prefix="/oauth", tags=["OAuth"]) + + +@router.get("/google", summary="Redirect to Google OAuth") +async def google_login() -> dict: + url = ( + "https://accounts.google.com/o/oauth2/v2/auth" + f"?client_id={oauth_settings.GOOGLE_CLIENT_ID}" + f"&redirect_uri={oauth_settings.APP_URL}/api/v1/oauth/google/callback" + "&response_type=code" + "&scope=openid%20email%20profile" + ) + return {"url": url} + + +@router.get("/google/callback", summary="Google OAuth callback") +async def google_callback(code: str, db: AsyncSession = Depends(get_db)) -> dict: + if not code: + raise HTTPException(status_code=400, detail="Authorization code not provided") + service = OAuthService(db) + profile = await service.get_google_user(code) + user = await service.find_or_create_user( + email=profile["email"], + name=profile.get("name", ""), + ) + token = service.create_token(user) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/github", summary="Redirect to GitHub OAuth") +async def github_login() -> dict: + url = ( + "https://github.com/login/oauth/authorize" + f"?client_id={oauth_settings.GITHUB_CLIENT_ID}" + f"&redirect_uri={oauth_settings.APP_URL}/api/v1/oauth/github/callback" + "&scope=user:email" + ) + return {"url": url} + + +@router.get("/github/callback", summary="GitHub OAuth callback") +async def github_callback(code: str, db: AsyncSession = Depends(get_db)) -> dict: + if not code: + raise HTTPException(status_code=400, detail="Authorization code not provided") + service = OAuthService(db) + profile = await service.get_github_user(code) + email = profile.get("email") or f"{profile['login']}@github.local" + name = profile.get("name") or profile["login"] + user = await service.find_or_create_user(email=email, name=name) + token = service.create_token(user) + return {"access_token": token, "token_type": "bearer"} diff --git a/plugins/python/template/addons/oauth/app/services/oauth_service.py b/plugins/python/template/addons/oauth/app/services/oauth_service.py new file mode 100644 index 0000000..01789de --- /dev/null +++ b/plugins/python/template/addons/oauth/app/services/oauth_service.py @@ -0,0 +1,80 @@ +from datetime import timedelta + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.core.oauth import oauth_settings +from app.models.user import User +from app.services.auth_service import AuthService + +logger = get_logger(__name__) + + +class OAuthService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + self._auth = AuthService(db) + + async def find_or_create_user(self, email: str, name: str) -> User: + result = await self.db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + if not user: + username = name.lower().replace(" ", "_") + user = User(email=email, username=username, hashed_password="") + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + logger.info(f"Created OAuth user: {email}") + return user + + def create_token(self, user: User) -> str: + return self._auth.create_access_token( + data={"sub": str(user.id), "email": user.email}, + expires_delta=timedelta(minutes=60), + ) + + async def get_google_user(self, code: str) -> dict: + async with httpx.AsyncClient() as client: + token_resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": oauth_settings.GOOGLE_CLIENT_ID, + "client_secret": oauth_settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": f"{oauth_settings.APP_URL}/api/v1/oauth/google/callback", + "grant_type": "authorization_code", + }, + ) + token_resp.raise_for_status() + access_token = token_resp.json()["access_token"] + user_resp = await client.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + user_resp.raise_for_status() + return user_resp.json() + + async def get_github_user(self, code: str) -> dict: + async with httpx.AsyncClient() as client: + token_resp = await client.post( + "https://github.com/login/oauth/access_token", + data={ + "code": code, + "client_id": oauth_settings.GITHUB_CLIENT_ID, + "client_secret": oauth_settings.GITHUB_CLIENT_SECRET, + }, + headers={"Accept": "application/json"}, + ) + token_resp.raise_for_status() + access_token = token_resp.json()["access_token"] + user_resp = await client.get( + "https://api.github.com/user", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github.v3+json", + }, + ) + user_resp.raise_for_status() + return user_resp.json() diff --git a/plugins/python/template/addons/queue/app/services/queue_service.py b/plugins/python/template/addons/queue/app/services/queue_service.py new file mode 100644 index 0000000..61f0cfa --- /dev/null +++ b/plugins/python/template/addons/queue/app/services/queue_service.py @@ -0,0 +1,38 @@ +from arq import create_pool +from arq.connections import RedisSettings, ArqRedis + +from app.core.config import settings +from app.core.logging import get_logger + +logger = get_logger(__name__) + +_pool: ArqRedis | None = None + + +def _redis_settings() -> RedisSettings: + url = settings.REDIS_URL or "redis://localhost:6379/0" + return RedisSettings.from_dsn(url) + + +async def get_queue() -> ArqRedis: + global _pool + if _pool is None: + _pool = await create_pool(_redis_settings()) + logger.info("arq Redis pool created") + return _pool + + +async def enqueue(function_name: str, *args, **kwargs) -> str: + pool = await get_queue() + job = await pool.enqueue_job(function_name, *args, **kwargs) + job_id = job.job_id if job else "unknown" + logger.info(f"Enqueued job: {function_name} (id={job_id})") + return job_id + + +async def close_queue() -> None: + global _pool + if _pool: + await _pool.aclose() + _pool = None + logger.info("arq Redis pool closed") diff --git a/plugins/python/template/addons/queue/app/workers/example_worker.py b/plugins/python/template/addons/queue/app/workers/example_worker.py new file mode 100644 index 0000000..b1f21ce --- /dev/null +++ b/plugins/python/template/addons/queue/app/workers/example_worker.py @@ -0,0 +1,19 @@ +import asyncio + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +async def example_job(ctx: dict, message: str, user_id: int | None = None) -> dict: + """Example background job — replace with real logic.""" + logger.info(f"Processing job: message={message!r} user_id={user_id}") + await asyncio.sleep(0.1) # simulate work + return {"processed": True, "message": message} + + +# arq worker settings — imported by arq CLI as WorkerSettings +class WorkerSettings: + functions = [example_job] + # Adjust Redis URL via REDIS_URL env var loaded by arq from the env + # To run: arq app.workers.example_worker.WorkerSettings diff --git a/plugins/python/template/addons/queue/pyproject.toml b/plugins/python/template/addons/queue/pyproject.toml new file mode 100644 index 0000000..61be022 --- /dev/null +++ b/plugins/python/template/addons/queue/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +description = "{{DESCRIPTION}}" +authors = [{name = "{{AUTHOR}}"}] +requires-python=">=3.11" + +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "pydantic[email]>=2.5.0", + "pydantic-settings>=2.1.0", + "sqlalchemy[asyncio]>=2.0.25", + "alembic>=1.13.0", + "asyncpg>=0.29.0", + "redis>=5.0.0", + "passlib[bcrypt]>=1.7.4", + "python-multipart>=0.0.6", + "aiofiles>=23.2.1", + "httpx>=0.26.0", + "python-dotenv>=1.0.0", + "apscheduler>=3.10.0", + "pyjwt>=2.11.0", + "arq>=0.26.0", +] +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "black>=23.12.0", + "ruff>=0.1.9", + "mypy>=1.8.0", +] + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] + + +[tool.ruff] +target-version = "py311" +line-length = 100 +indent-width = 4 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade +] + +ignore=[ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/plugins/python/template/addons/s3/.env.example b/plugins/python/template/addons/s3/.env.example new file mode 100644 index 0000000..780926c --- /dev/null +++ b/plugins/python/template/addons/s3/.env.example @@ -0,0 +1,6 @@ +# Storage (AWS S3 / Cloudflare R2 / MinIO) +S3_BUCKET=your-bucket-name +S3_REGION=us-east-1 +# S3_ENDPOINT=https://your-r2-endpoint.r2.cloudflarestorage.com +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key diff --git a/plugins/python/template/addons/s3/app/core/storage.py b/plugins/python/template/addons/s3/app/core/storage.py new file mode 100644 index 0000000..d711dbf --- /dev/null +++ b/plugins/python/template/addons/s3/app/core/storage.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class StorageSettings(BaseSettings): + S3_BUCKET: str = "" + S3_REGION: str = "us-east-1" + S3_ENDPOINT: Optional[str] = None + AWS_ACCESS_KEY_ID: str = "" + AWS_SECRET_ACCESS_KEY: str = "" + + model_config = SettingsConfigDict(env_file=".env", case_sensitive=True, extra="ignore") + + +storage_settings = StorageSettings() diff --git a/plugins/python/template/addons/s3/app/services/external/storage_service.py b/plugins/python/template/addons/s3/app/services/external/storage_service.py new file mode 100644 index 0000000..f0e95ce --- /dev/null +++ b/plugins/python/template/addons/s3/app/services/external/storage_service.py @@ -0,0 +1,57 @@ +from typing import Any + +import boto3 +from botocore.exceptions import ClientError + +from app.core.logging import get_logger +from app.core.storage import storage_settings + +logger = get_logger(__name__) + + +class StorageService: + def __init__(self) -> None: + self.settings = storage_settings + kwargs: dict[str, Any] = { + "region_name": self.settings.S3_REGION, + "aws_access_key_id": self.settings.AWS_ACCESS_KEY_ID, + "aws_secret_access_key": self.settings.AWS_SECRET_ACCESS_KEY, + } + if self.settings.S3_ENDPOINT: + kwargs["endpoint_url"] = self.settings.S3_ENDPOINT + self.client = boto3.client("s3", **kwargs) + + def upload(self, key: str, body: bytes, content_type: str = "application/octet-stream") -> str: + self.client.put_object( + Bucket=self.settings.S3_BUCKET, + Key=key, + Body=body, + ContentType=content_type, + ) + if self.settings.S3_ENDPOINT: + url = f"{self.settings.S3_ENDPOINT}/{self.settings.S3_BUCKET}/{key}" + else: + url = f"https://{self.settings.S3_BUCKET}.s3.{self.settings.S3_REGION}.amazonaws.com/{key}" + logger.info(f"Uploaded {key} to S3") + return url + + def get_presigned_url(self, key: str, expires_in: int = 3600) -> str: + return self.client.generate_presigned_url( + "get_object", + Params={"Bucket": self.settings.S3_BUCKET, "Key": key}, + ExpiresIn=expires_in, + ) + + def delete(self, key: str) -> None: + self.client.delete_object(Bucket=self.settings.S3_BUCKET, Key=key) + logger.info(f"Deleted {key} from S3") + + def exists(self, key: str) -> bool: + try: + self.client.head_object(Bucket=self.settings.S3_BUCKET, Key=key) + return True + except ClientError: + return False + + +storage_service = StorageService() diff --git a/plugins/python/template/addons/s3/pyproject.toml b/plugins/python/template/addons/s3/pyproject.toml new file mode 100644 index 0000000..345f841 --- /dev/null +++ b/plugins/python/template/addons/s3/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +description = "{{DESCRIPTION}}" +authors = [{name = "{{AUTHOR}}"}] +requires-python=">=3.11" + +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "pydantic[email]>=2.5.0", + "pydantic-settings>=2.1.0", + "sqlalchemy[asyncio]>=2.0.25", + "alembic>=1.13.0", + "asyncpg>=0.29.0", + "redis>=5.0.0", + "passlib[bcrypt]>=1.7.4", + "python-multipart>=0.0.6", + "aiofiles>=23.2.1", + "httpx>=0.26.0", + "python-dotenv>=1.0.0", + "apscheduler>=3.10.0", + "pyjwt>=2.11.0", + "boto3>=1.35.0", +] +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "black>=23.12.0", + "ruff>=0.1.9", + "mypy>=1.8.0", +] + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] + + +[tool.ruff] +target-version = "py311" +line-length = 100 +indent-width = 4 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade +] + +ignore=[ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/plugins/python/template/addons/websocket/app/routes/__init__.py b/plugins/python/template/addons/websocket/app/routes/__init__.py new file mode 100644 index 0000000..abb7e8e --- /dev/null +++ b/plugins/python/template/addons/websocket/app/routes/__init__.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from app.core.config import settings +from app.routes.api.v1 import api_router +from app.routes.ws import ws_router + +from .health import router as health_router + +router = APIRouter() + +router.include_router(health_router) +router.include_router(api_router, prefix=settings.API_V1_PREFIX) +router.include_router(ws_router) + +__all__ = ["router"] diff --git a/plugins/python/template/addons/websocket/app/routes/ws/__init__.py b/plugins/python/template/addons/websocket/app/routes/ws/__init__.py new file mode 100644 index 0000000..0d09223 --- /dev/null +++ b/plugins/python/template/addons/websocket/app/routes/ws/__init__.py @@ -0,0 +1,7 @@ +from app.routes.ws.notifications import router as notifications_router +from fastapi import APIRouter + +ws_router = APIRouter() +ws_router.include_router(notifications_router) + +__all__ = ["ws_router"] diff --git a/plugins/python/template/addons/websocket/app/routes/ws/notifications.py b/plugins/python/template/addons/websocket/app/routes/ws/notifications.py new file mode 100644 index 0000000..efe6d00 --- /dev/null +++ b/plugins/python/template/addons/websocket/app/routes/ws/notifications.py @@ -0,0 +1,43 @@ +import jwt +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status +from fastapi.websockets import WebSocketState + +from app.core.config import settings +from app.core.logging import get_logger +from app.services.notification_service import notification_manager + +logger = get_logger(__name__) +router = APIRouter() + + +def _authenticate(token: str | None) -> dict | None: + if not token: + return None + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except jwt.PyJWTError: + return None + + +@router.websocket("/ws/notifications") +async def websocket_notifications(websocket: WebSocket) -> None: + token = websocket.query_params.get("token") + payload = _authenticate(token) + if not payload: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + user_id = str(payload.get("sub", "")) + await notification_manager.connect(user_id, websocket) + logger.info(f"WebSocket connected: user_id={user_id}") + + try: + while websocket.client_state == WebSocketState.CONNECTED: + data = await websocket.receive_text() + # Echo back as acknowledgement; extend here for command handling + await websocket.send_json({"type": "ack", "data": data}) + except WebSocketDisconnect: + pass + finally: + notification_manager.disconnect(user_id, websocket) + logger.info(f"WebSocket disconnected: user_id={user_id}") diff --git a/plugins/python/template/addons/websocket/app/services/notification_service.py b/plugins/python/template/addons/websocket/app/services/notification_service.py new file mode 100644 index 0000000..d797954 --- /dev/null +++ b/plugins/python/template/addons/websocket/app/services/notification_service.py @@ -0,0 +1,69 @@ +import uuid +from collections import defaultdict +from datetime import datetime, timezone + +from fastapi import WebSocket + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +class NotificationManager: + """Manages active WebSocket connections keyed by user_id.""" + + def __init__(self) -> None: + # user_id → set of WebSocket connections (a user may have multiple tabs open) + self._connections: dict[str, set[WebSocket]] = defaultdict(set) + + async def connect(self, user_id: str, websocket: WebSocket) -> None: + await websocket.accept() + self._connections[user_id].add(websocket) + + def disconnect(self, user_id: str, websocket: WebSocket) -> None: + self._connections[user_id].discard(websocket) + if not self._connections[user_id]: + del self._connections[user_id] + + def _build_payload(self, type_: str, title: str, message: str, data: dict | None = None) -> dict: + return { + "id": str(uuid.uuid4()), + "type": type_, + "title": title, + "message": message, + "data": data or {}, + "createdAt": datetime.now(timezone.utc).isoformat(), + } + + async def send_to_user( + self, + user_id: str, + type_: str, + title: str, + message: str, + data: dict | None = None, + ) -> None: + payload = self._build_payload(type_, title, message, data) + dead: list[WebSocket] = [] + for ws in list(self._connections.get(user_id, [])): + try: + await ws.send_json(payload) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(user_id, ws) + + async def broadcast( + self, + type_: str, + title: str, + message: str, + data: dict | None = None, + ) -> None: + payload = self._build_payload(type_, title, message, data) + for user_id in list(self._connections): + await self.send_to_user(user_id, type_, title, message, data) + _ = payload # payload built once, reused per user + + +notification_manager = NotificationManager() diff --git a/tests/unit/python-plugin.test.ts b/tests/unit/python-plugin.test.ts index 7eae9c2..05cb1c4 100644 --- a/tests/unit/python-plugin.test.ts +++ b/tests/unit/python-plugin.test.ts @@ -151,3 +151,171 @@ describe("PythonPlugin", () => { ); }); }); + +// ─── v1.1 Python parity addon entries ───────────────────────────────────────── + +describe("PythonPlugin — v1.1 addons metadata", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("exposes email in addon list", () => { + expect(plugin.addons).toContain("email"); + }); + + it("exposes s3 in addon list", () => { + expect(plugin.addons).toContain("s3"); + }); + + it("exposes oauth in addon list", () => { + expect(plugin.addons).toContain("oauth"); + }); + + it("exposes api-docs in addon list", () => { + expect(plugin.addons).toContain("api-docs"); + }); + + it("exposes websocket in addon list", () => { + expect(plugin.addons).toContain("websocket"); + }); +}); + +describe("PythonPlugin — email addon", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("applies email addon when email=true", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", email: true }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(true); + }); + + it("skips email addon when email=false", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", email: false }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(false); + }); + + it("skips email addon when email is undefined", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(false); + }); +}); + +describe("PythonPlugin — s3 addon", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("applies s3 addon when s3=true", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", s3: true }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "s3")))).toBe(true); + }); + + it("skips s3 addon when s3=false", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", s3: false }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "s3")))).toBe(false); + }); +}); + +describe("PythonPlugin — oauth addon", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("applies oauth addon when oauth=true", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", oauth: true }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "oauth")))).toBe(true); + }); + + it("skips oauth addon when oauth=false", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", oauth: false }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "oauth")))).toBe(false); + }); +}); + +describe("PythonPlugin — api-docs addon", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("applies api-docs addon when apiDocs=true", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", apiDocs: true }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "api-docs")))).toBe(true); + }); + + it("skips api-docs addon when apiDocs=false", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", apiDocs: false }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "api-docs")))).toBe(false); + }); +}); + +describe("PythonPlugin — websocket addon", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new PythonPlugin(); + }); + + it("applies websocket addon when websocket=true", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", websocket: true }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "websocket")))).toBe(true); + }); + + it("skips websocket addon when websocket=false", async () => { + await plugin.generate("my-api", { language: "python", outputDir: "/tmp/my-api", websocket: false }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "websocket")))).toBe(false); + }); + + it("applies multiple addons together correctly", async () => { + await plugin.generate("my-api", { + language: "python", + outputDir: "/tmp/my-api", + email: true, + oauth: true, + websocket: true, + }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(true); + expect(calls.some((p) => p.includes(path.join("addons", "oauth")))).toBe(true); + expect(calls.some((p) => p.includes(path.join("addons", "websocket")))).toBe(true); + }); +}); diff --git a/types/index.ts b/types/index.ts index dfeaaca..d14446a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -18,6 +18,7 @@ export interface GenerateOptions { cursor?: boolean; email?: boolean; s3?: boolean; + queue?: boolean; /** Resolved absolute output path — set internally by ArchGen before calling plugin.generate() */ outputDir?: string; }