diff --git a/README.md b/README.md index 841fe718a..c0f11cc1a 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,10 @@ There are a few ways to add a Python Project from the Python Environments panel: #### Create New Project from Template -The **Python Envs: Create New Project from Template** command simplifies the process of starting a new Python project by scaffolding it for you. Whether in a new workspace or an existing one, this command configures the environment and boilerplate file structure, so you don’t have to worry about the initial setup, and only the code you want to write. There are currently two project types supported: +The **Python Envs: Create New Project from Template** command simplifies the process of starting a new Python project by scaffolding it for you. Whether in a new workspace or an existing one, this command configures the environment and boilerplate file structure, so you don’t have to worry about the initial setup, and only the code you want to write. There are currently three project types supported: - Package: A structured Python package with files like `__init__.py` and setup configurations. +- FastAPI: A FastAPI web API with `pyproject.toml`, an application module, tests, and a debug configuration that launches Uvicorn. - Script: A simple project for standalone Python scripts, ideal for quick tasks or just to get you started. ## Command Reference diff --git a/docs/managing-python-projects.md b/docs/managing-python-projects.md index feaa3a81f..305b1de0c 100644 --- a/docs/managing-python-projects.md +++ b/docs/managing-python-projects.md @@ -75,11 +75,12 @@ Use this to scaffold a brand new Python project with the correct structure and f 2. Run **Python Envs: Create New Project from Template**. 3. Choose a template type: - **Package**: A structured Python package with `pyproject.toml`, tests folder, and package directory + - **FastAPI**: A FastAPI web API with `pyproject.toml`, an application module, and tests - **Script**: A simple standalone Python file using PEP 723 inline metadata 4. Enter a name for your project. -5. If you're creating a package, choose whether to create a virtual environment. +5. If you're creating a package or a FastAPI project, choose whether to create a virtual environment. -The extension creates the project structure and adds it to your workspace. For packages, it can also create a virtual environment. +The extension creates the project structure and adds it to your workspace. For packages and FastAPI projects, it can also create a virtual environment. #### Package template structure @@ -95,6 +96,24 @@ my_package_project/ └── __init__.py ``` +#### FastAPI template structure + +When you create a FastAPI project named `my_api`, the extension generates: + +``` +my_api_project/ +├── pyproject.toml # Project metadata, FastAPI, and Uvicorn +├── dev-requirements.txt # Development dependencies, including pytest and httpx +├── my_api/ # Application package +│ ├── __init__.py +│ ├── main.py # FastAPI app +│ └── __main__.py # `python -m my_api` runs Uvicorn +└── tests/ + └── test_my_api.py +``` + +The workspace `launch.json` gains a **Python: FastAPI (my_api)** configuration that debugs `uvicorn my_api.main:app`. + #### Script template When you create a script, the extension generates a single `.py` file with PEP 723 inline script metadata, which allows you to specify dependencies directly in the file. diff --git a/files/templates/copilot-instructions-text/fastapi-copilot-instructions.md b/files/templates/copilot-instructions-text/fastapi-copilot-instructions.md new file mode 100644 index 000000000..7e63d9ffa --- /dev/null +++ b/files/templates/copilot-instructions-text/fastapi-copilot-instructions.md @@ -0,0 +1,10 @@ +# Copilot Instructions for + +- The FastAPI project `` is a Python Project located in the folder `_project`. +- You need to call the `Get Python Environment Information` tool on the `_project` path to get the Python executable details. +- Substitute the Python executable you get from the `Get Python Environment Information` tool anywhere you see `` in these instructions. + - Run command for ``: ` -m uvicorn .main:app --reload` + - Command to run tests for ``: ` -m pytest` +- To run an editable install for the package ``, use the `Install Python Package` tool with the `_project` path and arguments `['-e', '.']`. +- In the workspace `launch.json` file, configurations related to this project have the prefix `Python: FastAPI ()`. +- The project `` has a defined `pyproject.toml` file that you should use and keep up to date. Runtime dependencies include FastAPI and Uvicorn. diff --git a/files/templates/newFastApiTemplate/dev-requirements.txt b/files/templates/newFastApiTemplate/dev-requirements.txt new file mode 100644 index 000000000..7d90d5fe8 --- /dev/null +++ b/files/templates/newFastApiTemplate/dev-requirements.txt @@ -0,0 +1,5 @@ +pytest +httpx +setuptools +wheel +# TODO: update the necessary requirements to develop this FastAPI project diff --git a/files/templates/newFastApiTemplate/package_name/__init__.py b/files/templates/newFastApiTemplate/package_name/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/files/templates/newFastApiTemplate/package_name/__main__.py b/files/templates/newFastApiTemplate/package_name/__main__.py new file mode 100644 index 000000000..1ba5959c6 --- /dev/null +++ b/files/templates/newFastApiTemplate/package_name/__main__.py @@ -0,0 +1,11 @@ +import uvicorn + +from package_name.main import app + + +def main() -> None: + uvicorn.run(app, host="127.0.0.1", port=8000) + + +if __name__ == "__main__": + main() diff --git a/files/templates/newFastApiTemplate/package_name/main.py b/files/templates/newFastApiTemplate/package_name/main.py new file mode 100644 index 000000000..4ead69efc --- /dev/null +++ b/files/templates/newFastApiTemplate/package_name/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def root() -> dict[str, str]: + return {"message": "Hello World"} diff --git a/files/templates/newFastApiTemplate/pyproject.toml b/files/templates/newFastApiTemplate/pyproject.toml new file mode 100644 index 000000000..aedb3c77d --- /dev/null +++ b/files/templates/newFastApiTemplate/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "package_name" +version = "1.0.0" +description = "" #TODO: Add a short description of your FastAPI project +authors = [{name = "Your Name", email = "your@email.com"}] #TODO: Add your name and email +requires-python = ">=3.9" +dependencies = [ + "fastapi", + "uvicorn[standard]", +] +dynamic = ["optional-dependencies"] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +optional-dependencies = {dev = {file = ["dev-requirements.txt"]}} # add packages for development in the dev-requirements.txt file + +[project.scripts] +package_name = "package_name.__main__:main" diff --git a/files/templates/newFastApiTemplate/tests/test_package_name.py b/files/templates/newFastApiTemplate/tests/test_package_name.py new file mode 100644 index 000000000..c6ef79317 --- /dev/null +++ b/files/templates/newFastApiTemplate/tests/test_package_name.py @@ -0,0 +1,11 @@ +from fastapi.testclient import TestClient + +from package_name.main import app + +client = TestClient(app) + + +def test_read_root() -> None: + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Hello World"} diff --git a/src/extension.ts b/src/extension.ts index e088619c2..4b090cb7e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,7 @@ import { Uri, window, } from 'vscode'; -import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api'; +import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi } from './api'; import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { InlineScriptPackagesNotManagedError } from './common/inlineScript/errors'; @@ -42,10 +42,11 @@ import { getPythonApi, setPythonApi } from './extensionApi'; import { createManagerReady } from './features/common/managerReady'; import { AutoFindProjects } from './features/creators/autoFindProjects'; import { ExistingProjects } from './features/creators/existingProjects'; +import { NewFastApiProject } from './features/creators/newFastApiProject'; import { NewPackageProject } from './features/creators/newPackageProject'; import { NewScriptProject } from './features/creators/newScriptProject'; import type { ProjectCreators } from './features/creators/projectCreators'; -import { ProjectCreatorsImpl } from './features/creators/projectCreators'; +import { creatorNameForQuickCreateProjectType, ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, clearEnvironmentCachesCommand, @@ -206,6 +207,7 @@ export async function activate(context: ExtensionContext): Promise c.name === 'newPackage'); - } - if (projectType === 'python-script') { - selected = creators.find((c) => c.name === 'newScript'); - } + const creatorName = creatorNameForQuickCreateProjectType(projectType); + const selected = creatorName ? creators.find((creator) => creator.name === creatorName) : undefined; if (!selected) { throw new Error(`Project creator for type "${projectType}" not found.`); } diff --git a/src/features/creators/newFastApiProject.ts b/src/features/creators/newFastApiProject.ts new file mode 100644 index 000000000..5ea00042b --- /dev/null +++ b/src/features/creators/newFastApiProject.ts @@ -0,0 +1,210 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { commands, l10n, MarkdownString, QuickInputButtons, Uri, window, workspace } from 'vscode'; +import { PythonEnvironment, PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../../api'; +import { NEW_PROJECT_TEMPLATES_FOLDER, VENV_MANAGER_ID } from '../../common/constants'; +import { traceError } from '../../common/logging'; +import { showInputBoxWithButtons } from '../../common/window.apis'; +import type { EnvironmentManagers } from '../envManagers'; +import type { PythonProjectManager } from '../projectManager'; +import { + isCopilotInstalled, + manageCopilotInstructionsFile, + manageLaunchJsonFile, + promptForVenv, + replaceInFilesAndNames, +} from './creationHelpers'; + +/** Quick-create project type accepted by `python-envs.createNewProjectFromTemplate`. */ +export const FAST_API_PROJECT_TYPE = 'python-fastapi'; + +/** Stable creator id. Not localized, so command lookup keeps working in every locale. */ +export const FAST_API_CREATOR_NAME = 'newFastAPI'; + +const FAST_API_TEMPLATE_DIRECTORY = 'newFastApiTemplate'; +const FAST_API_COPILOT_INSTRUCTIONS = 'fastapi-copilot-instructions.md'; + +/** + * Validates a FastAPI project name using the same PEP 508-style rules as the package template. + * The name is also the Python package imported by the generated application. + * @param value - The project name entered by the user or supplied to quick create. + * @returns An error message when the name is invalid, otherwise `null`. + */ +export function validateFastApiProjectName(value: string): string | null { + // following PyPI (PEP 508) rules for package names + if (!/^([a-z_]|[a-z0-9_][a-z0-9._-]*[a-z0-9_])$/i.test(value)) { + return l10n.t( + 'Invalid project name. Use only letters, numbers, underscores, hyphens, or periods. Must start and end with a letter or number.', + ); + } + if (/^[-._0-9]$/i.test(value)) { + return l10n.t('Single-character project names cannot be a number, hyphen, or period.'); + } + return null; +} + +/** + * Scaffolds a FastAPI project from `files/templates/newFastApiTemplate`. + * Interactive creation asks for a name and whether to create a virtual environment. + * Quick create requires a name and root, then creates the environment without prompts. + */ +export class NewFastApiProject implements PythonProjectCreator { + public readonly name = FAST_API_CREATOR_NAME; + public readonly displayName = l10n.t('FastAPI'); + public readonly description = l10n.t('Creates a FastAPI project in your current workspace'); + public readonly tooltip = new MarkdownString(l10n.t('Create a new FastAPI project')); + public readonly supportsQuickCreate = true; + + constructor( + private readonly envManagers: EnvironmentManagers, + private readonly projectManager: PythonProjectManager, + ) {} + + async create(options?: PythonProjectCreatorOptions): Promise { + let packageName = options?.name; + let createVenv: boolean | undefined; + let createCopilotInstructions: boolean | undefined; + if (options?.quickCreate === true) { + if (!packageName) { + throw new Error('FastAPI project name is required in quickCreate mode.'); + } + createVenv = true; + createCopilotInstructions = true; + } else if (!packageName) { + try { + packageName = await showInputBoxWithButtons({ + prompt: l10n.t('What is the name of the FastAPI project? (e.g. my_api)'), + ignoreFocusOut: true, + showBackButton: true, + validateInput: validateFastApiProjectName, + }); + } catch (ex) { + if (ex === QuickInputButtons.Back) { + await commands.executeCommand('python-envs.createNewProjectFromTemplate'); + } + } + if (!packageName) { + return undefined; + } + const callback = () => { + return this.create(options); + }; + createVenv = await promptForVenv(callback); + if (createVenv === undefined) { + return undefined; + } + if (isCopilotInstalled()) { + createCopilotInstructions = true; + } + } + + const validationError = validateFastApiProjectName(packageName); + if (validationError) { + if (options?.quickCreate === true) { + throw new Error(validationError); + } + window.showErrorMessage(validationError); + return undefined; + } + + const templateFolder = path.join(NEW_PROJECT_TEMPLATES_FOLDER, FAST_API_TEMPLATE_DIRECTORY); + if (!(await fs.pathExists(templateFolder))) { + window.showErrorMessage(l10n.t('Template folder does not exist, aborting creation.')); + traceError(`FastAPI template folder not found at: ${templateFolder}`); + return undefined; + } + + let destRoot = options?.rootUri?.fsPath; + if (!destRoot) { + const workspaceFolders = workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + window.showErrorMessage(l10n.t('No workspace folder is open or provided, aborting creation.')); + traceError('No workspace folder is open for FastAPI project creation.'); + return undefined; + } + destRoot = workspaceFolders[0].uri.fsPath; + } + + const projectDestinationFolder = path.join(destRoot, `${packageName}_project`); + if (await fs.pathExists(projectDestinationFolder)) { + window.showErrorMessage( + l10n.t( + 'A project folder by that name already exists, aborting creation. Please retry with a unique project name given your workspace.', + ), + ); + return undefined; + } + await fs.copy(templateFolder, projectDestinationFolder); + await replaceInFilesAndNames(projectDestinationFolder, 'package_name', packageName); + + const createdProject: PythonProject = { + name: packageName, + uri: Uri.file(projectDestinationFolder), + }; + await this.projectManager.add(createdProject); + + let createdEnv: PythonEnvironment | undefined; + if (createVenv) { + const en = this.envManagers.getEnvironmentManager(undefined); + if (en?.supportsQuickCreate) { + createdEnv = await en.create(Uri.file(projectDestinationFolder), { quickCreate: true }); + } else if (!options?.quickCreate && en?.supportsCreate) { + createdEnv = await en.create(Uri.file(projectDestinationFolder), {}); + } else { + const venvManager = this.envManagers.managers.find( + (manager) => manager.id === VENV_MANAGER_ID || manager.supportsQuickCreate, + ); + if (venvManager) { + createdEnv = await venvManager.create(Uri.file(projectDestinationFolder), { quickCreate: true }); + } else { + const action = await window.showErrorMessage( + l10n.t( + 'A virtual environment could not be created for the new FastAPI project "{0}" because your default environment manager does not support this operation and no alternative was available.', + packageName, + ), + l10n.t('Create custom environment'), + ); + if (action === l10n.t('Create custom environment')) { + await commands.executeCommand('python-envs.createAny', { + uri: createdProject.uri, + selectEnvironment: true, + }); + createdEnv = await this.envManagers.getEnvironment(createdProject.uri); + } + } + } + } + + createdEnv = createdEnv || (await this.envManagers.getEnvironment(Uri.file(projectDestinationFolder))); + if (!createdEnv) { + window.showErrorMessage( + l10n.t('Project created but unable to be correlated to correct Python environment.'), + ); + return undefined; + } + await this.envManagers.setEnvironment(createdProject.uri, createdEnv); + + if (createCopilotInstructions) { + const instructionsPath = path.join( + NEW_PROJECT_TEMPLATES_FOLDER, + 'copilot-instructions-text', + FAST_API_COPILOT_INSTRUCTIONS, + ); + await manageCopilotInstructionsFile(destRoot, instructionsPath, [ + { searchValue: '', replaceValue: packageName }, + ]); + } + + const launchJsonConfig = { + name: `Python: FastAPI (${packageName})`, + type: 'debugpy', + request: 'launch', + module: 'uvicorn', + args: [`${packageName}.main:app`], + jinja: true, + }; + await manageLaunchJsonFile(destRoot, JSON.stringify(launchJsonConfig)); + + return createdProject; + } +} diff --git a/src/features/creators/projectCreators.ts b/src/features/creators/projectCreators.ts index 507365507..705078f90 100644 --- a/src/features/creators/projectCreators.ts +++ b/src/features/creators/projectCreators.ts @@ -1,5 +1,24 @@ import { Disposable } from 'vscode'; import type { PythonProjectCreator } from '../../api'; +import { FAST_API_CREATOR_NAME, FAST_API_PROJECT_TYPE } from './newFastApiProject'; + +/** + * Maps a quick-create project type to the registered creator name. + * @param projectType - Value passed to `python-envs.createNewProjectFromTemplate`. + * @returns The creator `name` to look up, or `undefined` when the type is unknown. + */ +export function creatorNameForQuickCreateProjectType(projectType: string): string | undefined { + switch (projectType) { + case 'python-package': + return 'newPackage'; + case 'python-script': + return 'newScript'; + case FAST_API_PROJECT_TYPE: + return FAST_API_CREATOR_NAME; + default: + return undefined; + } +} export interface ProjectCreators extends Disposable { registerPythonProjectCreator(creator: PythonProjectCreator): Disposable; diff --git a/src/test/features/creators/newFastApiProject.unit.test.ts b/src/test/features/creators/newFastApiProject.unit.test.ts new file mode 100644 index 000000000..42ac2689b --- /dev/null +++ b/src/test/features/creators/newFastApiProject.unit.test.ts @@ -0,0 +1,487 @@ +import assert from 'assert'; +import fsExtra from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { commands, l10n, QuickInputButtons, Uri, window, workspace, WorkspaceFolder } from 'vscode'; +import { PythonEnvironment, PythonProject, PythonProjectCreatorOptions } from '../../../api'; +import { NEW_PROJECT_TEMPLATES_FOLDER, VENV_MANAGER_ID } from '../../../common/constants'; +import * as windowApis from '../../../common/window.apis'; +import * as creationHelpers from '../../../features/creators/creationHelpers'; +import { + FAST_API_CREATOR_NAME, + FAST_API_PROJECT_TYPE, + NewFastApiProject, + validateFastApiProjectName, +} from '../../../features/creators/newFastApiProject'; +import { creatorNameForQuickCreateProjectType } from '../../../features/creators/projectCreators'; +import type { EnvironmentManagers } from '../../../features/envManagers'; +import type { PythonProjectManager } from '../../../features/projectManager'; + +// Real template, resolved from the compiled test location +// (out/test/features/creators/ → workspaceRoot/files/templates/...). Unit tests do not +// use NEW_PROJECT_TEMPLATES_FOLDER for file contents: that path is anchored at `out/`. +const TEMPLATE_PATH = path.join(__dirname, '..', '..', '..', '..', 'files', 'templates', 'newFastApiTemplate'); +const COPILOT_INSTRUCTIONS_PATH = path.join( + __dirname, + '..', + '..', + '..', + '..', + 'files', + 'templates', + 'copilot-instructions-text', + 'fastapi-copilot-instructions.md', +); + +function createEnvironment(): PythonEnvironment { + return { envId: { id: 'fastapi-env' } } as unknown as PythonEnvironment; +} + +function createManagers(options?: { + supportsQuickCreate?: boolean; + supportsCreate?: boolean; + includeVenvFallback?: boolean; + environment?: PythonEnvironment | undefined; +}): { + envManagers: EnvironmentManagers; + defaultCreate: sinon.SinonStub; + venvCreate: sinon.SinonStub; + getEnvironment: sinon.SinonStub; + setEnvironment: sinon.SinonStub; +} { + const environment = options && 'environment' in options ? options.environment : createEnvironment(); + const defaultCreate = sinon.stub().resolves(environment); + const venvCreate = sinon.stub().resolves(environment ?? createEnvironment()); + const defaultManager = { + id: 'default-manager', + supportsQuickCreate: options?.supportsQuickCreate ?? true, + supportsCreate: options?.supportsCreate ?? true, + create: defaultCreate, + }; + const venvManager = { + id: VENV_MANAGER_ID, + supportsQuickCreate: true, + supportsCreate: true, + create: venvCreate, + }; + const getEnvironment = sinon.stub().resolves(environment); + const setEnvironment = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().returns(defaultManager), + managers: options?.includeVenvFallback ? [defaultManager, venvManager] : [defaultManager], + getEnvironment, + setEnvironment, + } as unknown as EnvironmentManagers; + return { envManagers, defaultCreate, venvCreate, getEnvironment, setEnvironment }; +} + +function asProject(created: PythonProject | Uri | undefined): PythonProject { + assert.ok(created); + assert.ok('uri' in created); + return created; +} + +async function collectFiles(root: string): Promise { + const entries = await fsExtra.readdir(root, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFiles(fullPath))); + } else { + files.push(fullPath); + } + } + return files; +} + +suite('FastAPI quick-create project type', () => { + test('maps built-in project types onto creator names', () => { + assert.strictEqual(creatorNameForQuickCreateProjectType('python-package'), 'newPackage'); + assert.strictEqual(creatorNameForQuickCreateProjectType('python-script'), 'newScript'); + assert.strictEqual(creatorNameForQuickCreateProjectType(FAST_API_PROJECT_TYPE), FAST_API_CREATOR_NAME); + assert.strictEqual(creatorNameForQuickCreateProjectType('python-unknown'), undefined); + }); +}); + +suite('newFastApiTemplate / NewFastApiProject', () => { + let tmpDir: string; + let workspaceFolders: readonly WorkspaceFolder[] | undefined; + let previousWorkspaceFolders: PropertyDescriptor | undefined; + let previousShowError: unknown; + let previousExecuteCommand: unknown; + let showError: sinon.SinonStub; + + setup(async () => { + tmpDir = await fsExtra.mkdtemp(path.join(os.tmpdir(), 'new-fastapi-test-')); + workspaceFolders = [ + { + index: 0, + name: 'test-workspace', + uri: Uri.file(tmpDir), + }, + ]; + previousWorkspaceFolders = Object.getOwnPropertyDescriptor(workspace, 'workspaceFolders'); + Object.defineProperty(workspace, 'workspaceFolders', { + configurable: true, + get: () => workspaceFolders, + }); + previousShowError = (window as unknown as { showErrorMessage?: unknown }).showErrorMessage; + previousExecuteCommand = (commands as unknown as { executeCommand?: unknown }).executeCommand; + showError = sinon.stub().resolves(undefined); + (window as unknown as { showErrorMessage: sinon.SinonStub }).showErrorMessage = showError; + }); + + teardown(async () => { + sinon.restore(); + (window as unknown as { showErrorMessage?: unknown }).showErrorMessage = previousShowError; + (commands as unknown as { executeCommand?: unknown }).executeCommand = previousExecuteCommand; + if (previousWorkspaceFolders) { + Object.defineProperty(workspace, 'workspaceFolders', previousWorkspaceFolders); + } + await fsExtra.remove(tmpDir); + }); + + function stubExecuteCommand(): sinon.SinonStub { + const execute = sinon.stub().resolves(undefined); + (commands as unknown as { executeCommand: sinon.SinonStub }).executeCommand = execute; + return execute; + } + + function useRealTemplateCopy(): { instructions: sinon.SinonStub } { + const compiledTemplate = path.resolve(path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'newFastApiTemplate')); + const originalPathExists = fsExtra.pathExists.bind(fsExtra); + const originalCopy = fsExtra.copy.bind(fsExtra); + sinon.stub(fsExtra, 'pathExists').callsFake(async (checkedPath: fsExtra.PathLike) => { + if (path.resolve(String(checkedPath)) === compiledTemplate) { + return true; + } + return originalPathExists(String(checkedPath)); + }); + sinon.stub(fsExtra, 'copy').callsFake(async (source, destination, options) => { + const resolvedSource = path.resolve(String(source)); + if (resolvedSource === compiledTemplate) { + await originalCopy(TEMPLATE_PATH, destination, options); + return; + } + await originalCopy(source, destination, options); + }); + return { + instructions: sinon.stub(creationHelpers, 'manageCopilotInstructionsFile').resolves(), + }; + } + + test('shipped template is a FastAPI application with a runnable module and test', async () => { + const pyproject = await fsExtra.readFile(path.join(TEMPLATE_PATH, 'pyproject.toml'), 'utf8'); + const main = await fsExtra.readFile(path.join(TEMPLATE_PATH, 'package_name', 'main.py'), 'utf8'); + const entry = await fsExtra.readFile(path.join(TEMPLATE_PATH, 'package_name', '__main__.py'), 'utf8'); + const testFile = await fsExtra.readFile(path.join(TEMPLATE_PATH, 'tests', 'test_package_name.py'), 'utf8'); + const devRequirements = await fsExtra.readFile(path.join(TEMPLATE_PATH, 'dev-requirements.txt'), 'utf8'); + const instructions = await fsExtra.readFile(COPILOT_INSTRUCTIONS_PATH, 'utf8'); + + assert.match(pyproject, /name = "package_name"/); + assert.match(pyproject, /"fastapi"/); + assert.match(pyproject, /"uvicorn\[standard\]"/); + assert.match(pyproject, /package_name = "package_name\.__main__:main"/); + assert.match(main, /FastAPI\(\)/); + assert.match(main, /@app\.get\("\/"\)/); + assert.match(entry, /from package_name\.main import app/); + assert.match(entry, /uvicorn\.run\(app,/); + assert.match(testFile, /TestClient/); + assert.match(testFile, /from package_name\.main import app/); + assert.match(devRequirements, /^pytest$/m); + assert.match(devRequirements, /^httpx$/m); + assert.match(instructions, /uvicorn \.main:app --reload/); + assert.strictEqual(await fsExtra.pathExists(path.join(TEMPLATE_PATH, 'package_name', '__init__.py')), true); + }); + + test('accepts PEP 508-style names and rejects unsafe ones', () => { + for (const validName of ['a', 'my_api', 'MyAPI', 'my-api', 'a.b', 'package_name']) { + assert.strictEqual(validateFastApiProjectName(validName), null, validName); + } + assert.strictEqual(typeof validateFastApiProjectName(''), 'string'); + assert.strictEqual(typeof validateFastApiProjectName('1'), 'string'); + assert.strictEqual(typeof validateFastApiProjectName('my api'), 'string'); + assert.strictEqual(typeof validateFastApiProjectName('my/api'), 'string'); + assert.strictEqual(typeof validateFastApiProjectName('-api'), 'string'); + assert.strictEqual(typeof validateFastApiProjectName('api-'), 'string'); + assert.match(validateFastApiProjectName('_') ?? '', /Single-character project names/); + }); + + test('creator is offered as FastAPI and supports quick create', () => { + const creator = new NewFastApiProject({} as EnvironmentManagers, {} as PythonProjectManager); + + assert.strictEqual(creator.name, FAST_API_CREATOR_NAME); + assert.strictEqual(creator.displayName, 'FastAPI'); + assert.strictEqual(creator.supportsQuickCreate, true); + assert.match(creator.description ?? '', /FastAPI/); + }); + + test('quick create scaffolds the project, environment, launch config, and copilot instructions', async () => { + const { instructions } = useRealTemplateCopy(); + const { envManagers, defaultCreate, setEnvironment } = createManagers(); + const add = sinon.stub().resolves(); + const creator = new NewFastApiProject(envManagers, { add } as unknown as PythonProjectManager); + + const created = asProject( + await creator.create({ + quickCreate: true, + name: 'my_api', + rootUri: Uri.file(tmpDir), + }), + ); + + const projectFolder = path.join(tmpDir, 'my_api_project'); + assert.strictEqual(created.name, 'my_api'); + assert.strictEqual(created.uri.fsPath, projectFolder); + assert.strictEqual(add.calledOnce, true); + assert.strictEqual(add.firstCall.args[0].uri.fsPath, projectFolder); + + const files = await collectFiles(projectFolder); + for (const file of files) { + const contents = await fsExtra.readFile(file, 'utf8'); + assert.strictEqual(contents.includes('package_name'), false, file); + } + const main = await fsExtra.readFile(path.join(projectFolder, 'my_api', 'main.py'), 'utf8'); + const entry = await fsExtra.readFile(path.join(projectFolder, 'my_api', '__main__.py'), 'utf8'); + const pyproject = await fsExtra.readFile(path.join(projectFolder, 'pyproject.toml'), 'utf8'); + assert.match(main, /FastAPI\(\)/); + assert.match(entry, /from my_api\.main import app/); + assert.match(pyproject, /name = "my_api"/); + assert.match(pyproject, /my_api = "my_api\.__main__:main"/); + assert.strictEqual(await fsExtra.pathExists(path.join(projectFolder, 'tests', 'test_my_api.py')), true); + + sinon.assert.calledOnce(defaultCreate); + assert.strictEqual(defaultCreate.firstCall.args[1].quickCreate, true); + sinon.assert.calledOnce(setEnvironment); + + const launchJson = JSON.parse(await fsExtra.readFile(path.join(tmpDir, '.vscode', 'launch.json'), 'utf8')); + assert.strictEqual(launchJson.configurations[0].name, 'Python: FastAPI (my_api)'); + assert.strictEqual(launchJson.configurations[0].module, 'uvicorn'); + assert.deepStrictEqual(launchJson.configurations[0].args, ['my_api.main:app']); + + sinon.assert.calledOnce(instructions); + assert.strictEqual(path.basename(instructions.firstCall.args[1]), 'fastapi-copilot-instructions.md'); + assert.deepStrictEqual(instructions.firstCall.args[2], [ + { searchValue: '', replaceValue: 'my_api' }, + ]); + }); + + test('quick create requires a name and rejects an invalid name before writing files', async () => { + const add = sinon.stub().resolves(); + const creator = new NewFastApiProject({} as EnvironmentManagers, { add } as unknown as PythonProjectManager); + + await assert.rejects( + () => creator.create({ quickCreate: true, name: '', rootUri: Uri.file(tmpDir) }), + /FastAPI project name is required in quickCreate mode/, + ); + await assert.rejects( + () => creator.create({ quickCreate: true, name: 'bad name', rootUri: Uri.file(tmpDir) }), + /Invalid project name/, + ); + assert.strictEqual(add.called, false); + assert.strictEqual(await fsExtra.pathExists(path.join(tmpDir, 'bad name_project')), false); + }); + + test('interactive creation prompts for a name and virtual environment', async () => { + const { instructions } = useRealTemplateCopy(); + const { envManagers, defaultCreate } = createManagers(); + const add = sinon.stub().resolves(); + const creator = new NewFastApiProject(envManagers, { add } as unknown as PythonProjectManager); + const prompt = sinon.stub(windowApis, 'showInputBoxWithButtons').callsFake(async (options) => { + assert.strictEqual(await options?.validateInput?.('my api'), validateFastApiProjectName('my api')); + assert.strictEqual(await options?.validateInput?.('my_api'), null); + return 'my_api'; + }); + sinon.stub(windowApis, 'showQuickPickWithButtons').resolves({ label: l10n.t('Yes') }); + + const created = asProject(await creator.create()); + + assert.strictEqual(prompt.calledOnce, true); + assert.strictEqual(created.name, 'my_api'); + sinon.assert.calledOnce(defaultCreate); + sinon.assert.calledOnce(instructions); + assert.strictEqual(await fsExtra.pathExists(path.join(tmpDir, 'my_api_project', 'my_api', 'main.py')), true); + }); + + test('interactive creation skips copilot instructions when Copilot is not installed', async () => { + const { instructions } = useRealTemplateCopy(); + const { envManagers } = createManagers(); + const creator = new NewFastApiProject(envManagers, { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('my_api'); + sinon.stub(windowApis, 'showQuickPickWithButtons').resolves({ label: l10n.t('Yes') }); + sinon.stub(creationHelpers, 'isCopilotInstalled').returns(false); + + const created = asProject(await creator.create()); + + assert.strictEqual(created.name, 'my_api'); + sinon.assert.notCalled(instructions); + assert.strictEqual(await fsExtra.pathExists(path.join(tmpDir, '.vscode', 'launch.json')), true); + }); + + test('declining a virtual environment keeps the project when an environment already exists', async () => { + useRealTemplateCopy(); + const { envManagers, defaultCreate, getEnvironment, setEnvironment } = createManagers(); + const creator = new NewFastApiProject(envManagers, { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('my_api'); + sinon.stub(windowApis, 'showQuickPickWithButtons').resolves({ label: l10n.t('No') }); + sinon.stub(creationHelpers, 'isCopilotInstalled').returns(false); + + const created = asProject(await creator.create()); + + assert.strictEqual(created.name, 'my_api'); + sinon.assert.notCalled(defaultCreate); + sinon.assert.calledOnce(getEnvironment); + sinon.assert.calledOnce(setEnvironment); + }); + + test('returns undefined when the name prompt or environment prompt is cancelled', async () => { + const add = sinon.stub().resolves(); + const creator = new NewFastApiProject({} as EnvironmentManagers, { add } as unknown as PythonProjectManager); + const prompt = sinon.stub(windowApis, 'showInputBoxWithButtons').resolves(undefined); + + assert.strictEqual(await creator.create(), undefined); + + prompt.resolves('my_api'); + sinon.stub(windowApis, 'showQuickPickWithButtons').resolves(undefined); + assert.strictEqual(await creator.create(), undefined); + assert.strictEqual(add.called, false); + }); + + test('back from the name prompt reopens template selection', async () => { + const creator = new NewFastApiProject( + {} as EnvironmentManagers, + { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager, + ); + sinon.stub(windowApis, 'showInputBoxWithButtons').rejects(QuickInputButtons.Back); + const execute = stubExecuteCommand(); + + assert.strictEqual(await creator.create(), undefined); + sinon.assert.calledOnceWithExactly(execute, 'python-envs.createNewProjectFromTemplate'); + }); + + test('aborts when the template, workspace, or destination is unavailable', async () => { + const add = sinon.stub().resolves(); + const { envManagers } = createManagers(); + const creator = new NewFastApiProject(envManagers, { add } as unknown as PythonProjectManager); + + assert.strictEqual(await creator.create({ name: 'my_api', rootUri: Uri.file(tmpDir) }), undefined); + sinon.assert.calledWith(showError, l10n.t('Template folder does not exist, aborting creation.')); + + showError.resetHistory(); + useRealTemplateCopy(); + workspaceFolders = undefined; + assert.strictEqual(await creator.create({ name: 'my_api' } as PythonProjectCreatorOptions), undefined); + sinon.assert.calledWith(showError, l10n.t('No workspace folder is open or provided, aborting creation.')); + + workspaceFolders = [{ index: 0, name: 'test-workspace', uri: Uri.file(tmpDir) }]; + await fsExtra.ensureDir(path.join(tmpDir, 'my_api_project')); + showError.resetHistory(); + assert.strictEqual(await creator.create({ name: 'my_api', rootUri: Uri.file(tmpDir) }), undefined); + sinon.assert.calledWith( + showError, + l10n.t( + 'A project folder by that name already exists, aborting creation. Please retry with a unique project name given your workspace.', + ), + ); + assert.strictEqual(add.called, false); + }); + + test('reports an invalid name supplied outside quick create', async () => { + const creator = new NewFastApiProject( + {} as EnvironmentManagers, + { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager, + ); + + assert.strictEqual(await creator.create({ name: 'bad name', rootUri: Uri.file(tmpDir) }), undefined); + sinon.assert.calledWith(showError, validateFastApiProjectName('bad name')); + }); + + test('uses a quick-create environment manager when the default manager cannot quick-create', async () => { + useRealTemplateCopy(); + const { envManagers, defaultCreate, venvCreate } = createManagers({ + supportsQuickCreate: false, + supportsCreate: true, + includeVenvFallback: true, + }); + const creator = new NewFastApiProject(envManagers, { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager); + + const created = asProject( + await creator.create({ + quickCreate: true, + name: 'my_api', + rootUri: Uri.file(tmpDir), + }), + ); + + assert.strictEqual(created.name, 'my_api'); + sinon.assert.notCalled(defaultCreate); + sinon.assert.calledOnce(venvCreate); + assert.strictEqual(venvCreate.firstCall.args[1].quickCreate, true); + }); + + test('offers custom environment creation when no manager can create one', async () => { + useRealTemplateCopy(); + const environment = createEnvironment(); + const { envManagers, getEnvironment, setEnvironment } = createManagers({ + supportsQuickCreate: false, + supportsCreate: false, + environment, + }); + const creator = new NewFastApiProject(envManagers, { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager); + showError.resolves(l10n.t('Create custom environment')); + const execute = stubExecuteCommand(); + + const created = asProject( + await creator.create({ + quickCreate: true, + name: 'my_api', + rootUri: Uri.file(tmpDir), + }), + ); + + assert.strictEqual(created.uri.fsPath, path.join(tmpDir, 'my_api_project')); + sinon.assert.calledWith( + showError, + l10n.t( + 'A virtual environment could not be created for the new FastAPI project "{0}" because your default environment manager does not support this operation and no alternative was available.', + 'my_api', + ), + l10n.t('Create custom environment'), + ); + assert.strictEqual(execute.firstCall.args[0], 'python-envs.createAny'); + assert.strictEqual(execute.firstCall.args[1].selectEnvironment, true); + assert.strictEqual(execute.firstCall.args[1].uri.fsPath, path.join(tmpDir, 'my_api_project')); + sinon.assert.calledOnce(getEnvironment); + sinon.assert.calledOnce(setEnvironment); + }); + + test('returns undefined when the created project cannot be matched to an environment', async () => { + useRealTemplateCopy(); + const { envManagers } = createManagers({ environment: undefined }); + const creator = new NewFastApiProject(envManagers, { + add: sinon.stub().resolves(), + } as unknown as PythonProjectManager); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('my_api'); + sinon.stub(windowApis, 'showQuickPickWithButtons').resolves({ label: l10n.t('No') }); + + assert.strictEqual(await creator.create(), undefined); + sinon.assert.calledWith( + showError, + l10n.t('Project created but unable to be correlated to correct Python environment.'), + ); + }); +});