Skip to content
This repository was archived by the owner on Oct 23, 2025. It is now read-only.

Commit fe1c916

Browse files
authored
create project with 723 style script (#428)
fixes microsoft/vscode-python-environments#393
1 parent 8b6a3b9 commit fe1c916

File tree

5 files changed

+171
-39
lines changed

5 files changed

+171
-39
lines changed

src/features/creators/creationHelpers.ts

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -96,61 +96,65 @@ export async function quickCreateNewVenv(envManagers: EnvironmentManagers, destF
9696
}
9797

9898
/**
99-
* Recursively replaces all occurrences of a string in file and folder names, as well as file contents, within a directory tree.
100-
* @param dir - The root directory to start the replacement from.
99+
* Replaces all occurrences of a string in file and folder names, as well as file contents, within a directory tree or a single file.
100+
* @param targetPath - The root directory or file path to start the replacement from.
101101
* @param searchValue - The string to search for in names and contents.
102102
* @param replaceValue - The string to replace with.
103103
* @returns {Promise<void>} Resolves when all replacements are complete.
104104
*/
105-
export async function replaceInFilesAndNames(dir: string, searchValue: string, replaceValue: string) {
106-
const entries = await fs.readdir(dir, { withFileTypes: true });
107-
for (const entry of entries) {
108-
let entryName = entry.name;
109-
let fullPath = path.join(dir, entryName);
110-
let newFullPath = fullPath;
111-
// If the file or folder name contains searchValue, rename it
112-
if (entryName.includes(searchValue)) {
113-
const newName = entryName.replace(new RegExp(searchValue, 'g'), replaceValue);
114-
newFullPath = path.join(dir, newName);
115-
await fs.rename(fullPath, newFullPath);
116-
entryName = newName;
117-
}
118-
if (entry.isDirectory()) {
119-
await replaceInFilesAndNames(newFullPath, searchValue, replaceValue);
120-
} else {
121-
let content = await fs.readFile(newFullPath, 'utf8');
122-
if (content.includes(searchValue)) {
123-
content = content.replace(new RegExp(searchValue, 'g'), replaceValue);
124-
await fs.writeFile(newFullPath, content, 'utf8');
105+
export async function replaceInFilesAndNames(targetPath: string, searchValue: string, replaceValue: string) {
106+
const stat = await fs.stat(targetPath);
107+
108+
if (stat.isDirectory()) {
109+
const entries = await fs.readdir(targetPath, { withFileTypes: true });
110+
for (const entry of entries) {
111+
let entryName = entry.name;
112+
let fullPath = path.join(targetPath, entryName);
113+
let newFullPath = fullPath;
114+
// If the file or folder name contains searchValue, rename it
115+
if (entryName.includes(searchValue)) {
116+
const newName = entryName.replace(new RegExp(searchValue, 'g'), replaceValue);
117+
newFullPath = path.join(targetPath, newName);
118+
await fs.rename(fullPath, newFullPath);
119+
entryName = newName;
125120
}
121+
await replaceInFilesAndNames(newFullPath, searchValue, replaceValue);
122+
}
123+
} else if (stat.isFile()) {
124+
let content = await fs.readFile(targetPath, 'utf8');
125+
if (content.includes(searchValue)) {
126+
content = content.replace(new RegExp(searchValue, 'g'), replaceValue);
127+
await fs.writeFile(targetPath, content, 'utf8');
126128
}
127129
}
128130
}
129131

130132
/**
131133
* Ensures the .github/copilot-instructions.md file exists at the given root, creating or appending as needed.
134+
* Performs multiple find-and-replace operations as specified by the replacements array.
132135
* @param destinationRootPath - The root directory where the .github folder should exist.
133-
* @param instructionsText - The text to write or append to the copilot-instructions.md file.
136+
* @param instructionsFilePath - The path to the instructions template file.
137+
* @param replacements - An array of tuples [{ text_to_find_and_replace, text_to_replace_it_with }]
134138
*/
135139
export async function manageCopilotInstructionsFile(
136140
destinationRootPath: string,
137-
packageName: string,
138141
instructionsFilePath: string,
142+
replacements: Array<{ searchValue: string; replaceValue: string }>,
139143
) {
140-
const instructionsText = `\n \n` + (await fs.readFile(instructionsFilePath, 'utf-8'));
144+
let instructionsText = `\n\n` + (await fs.readFile(instructionsFilePath, 'utf-8'));
145+
for (const { searchValue: text_to_find_and_replace, replaceValue: text_to_replace_it_with } of replacements) {
146+
instructionsText = instructionsText.replace(new RegExp(text_to_find_and_replace, 'g'), text_to_replace_it_with);
147+
}
141148
const githubFolderPath = path.join(destinationRootPath, '.github');
142149
const customInstructionsPath = path.join(githubFolderPath, 'copilot-instructions.md');
143150
if (!(await fs.pathExists(githubFolderPath))) {
144-
// make the .github folder if it doesn't exist
145151
await fs.mkdir(githubFolderPath);
146152
}
147153
const customInstructions = await fs.pathExists(customInstructionsPath);
148154
if (customInstructions) {
149-
// Append to the existing file
150-
await fs.appendFile(customInstructionsPath, instructionsText.replace(/<package_name>/g, packageName));
155+
await fs.appendFile(customInstructionsPath, instructionsText);
151156
} else {
152-
// Create the file if it doesn't exist
153-
await fs.writeFile(customInstructionsPath, instructionsText.replace(/<package_name>/g, packageName));
157+
await fs.writeFile(customInstructionsPath, instructionsText);
154158
}
155159
}
156160

src/features/creators/newPackageProject.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ export class NewPackageProject implements PythonProjectCreator {
140140
'copilot-instructions-text',
141141
'package-copilot-instructions.md',
142142
);
143-
await manageCopilotInstructionsFile(destRoot, packageName, packageInstructionsPath);
143+
await manageCopilotInstructionsFile(destRoot, packageInstructionsPath, [
144+
{ searchValue: '<package_name>', replaceValue: packageName },
145+
]);
144146
}
145147

146148
// update launch.json file with config for the package
Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,118 @@
1-
import { MarkdownString, window } from 'vscode';
1+
import * as fs from 'fs-extra';
2+
import * as path from 'path';
3+
import { commands, l10n, MarkdownString, QuickInputButtons, Uri, window, workspace } from 'vscode';
24
import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../../api';
5+
import { NEW_PROJECT_TEMPLATES_FOLDER } from '../../common/constants';
6+
import { showInputBoxWithButtons } from '../../common/window.apis';
7+
import { isCopilotInstalled, manageCopilotInstructionsFile, replaceInFilesAndNames } from './creationHelpers';
38

49
export class NewScriptProject implements PythonProjectCreator {
5-
public readonly name = 'newScript';
6-
public readonly displayName = 'Project';
7-
public readonly description = 'Create a new Python project';
8-
public readonly tooltip = new MarkdownString('Create a new Python project');
10+
public readonly name = l10n.t('newScript');
11+
public readonly displayName = l10n.t('Script');
12+
public readonly description = l10n.t('Creates a new script folder in your current workspace with PEP 723 support');
13+
public readonly tooltip = new MarkdownString(l10n.t('Create a new Python script'));
914

1015
constructor() {}
1116

12-
async create(_options?: PythonProjectCreatorOptions): Promise<PythonProject | undefined> {
13-
// show notification that the script creation was selected than return undefined
14-
window.showInformationMessage('Creating a new Python project...');
17+
async create(options?: PythonProjectCreatorOptions): Promise<PythonProject | Uri | undefined> {
18+
// quick create (needs name, will always create venv and copilot instructions)
19+
// not quick create
20+
// ask for script file name
21+
// ask if they want venv
22+
let scriptFileName = options?.name;
23+
let createCopilotInstructions: boolean | undefined;
24+
if (options?.quickCreate === true) {
25+
// If quickCreate is true, we should not prompt for any input
26+
if (!scriptFileName) {
27+
throw new Error('Script file name is required in quickCreate mode.');
28+
}
29+
createCopilotInstructions = true;
30+
} else {
31+
//Prompt as quickCreate is false
32+
if (!scriptFileName) {
33+
try {
34+
scriptFileName = await showInputBoxWithButtons({
35+
prompt: l10n.t('What is the name of the script? (e.g. my_script.py)'),
36+
ignoreFocusOut: true,
37+
showBackButton: true,
38+
validateInput: (value) => {
39+
// Ensure the filename ends with .py and follows valid naming conventions
40+
if (!value.endsWith('.py')) {
41+
return l10n.t('Script name must end with ".py".');
42+
}
43+
const baseName = value.replace(/\.py$/, '');
44+
// following PyPI (PEP 508) rules for package names
45+
if (!/^([a-z_]|[a-z0-9_][a-z0-9._-]*[a-z0-9_])$/i.test(baseName)) {
46+
return l10n.t(
47+
'Invalid script name. Use only letters, numbers, underscores, hyphens, or periods. Must start and end with a letter or number.',
48+
);
49+
}
50+
if (/^[-._0-9]$/i.test(baseName)) {
51+
return l10n.t('Single-character script names cannot be a number, hyphen, or period.');
52+
}
53+
return null;
54+
},
55+
});
56+
} catch (ex) {
57+
if (ex === QuickInputButtons.Back) {
58+
await commands.executeCommand('python-envs.createNewProjectFromTemplate');
59+
}
60+
}
61+
if (!scriptFileName) {
62+
return undefined;
63+
}
64+
if (isCopilotInstalled()) {
65+
createCopilotInstructions = true;
66+
}
67+
}
68+
69+
// 1. Copy template folder
70+
const newScriptTemplateFile = path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'new723ScriptTemplate', 'script.py');
71+
if (!(await fs.pathExists(newScriptTemplateFile))) {
72+
window.showErrorMessage(l10n.t('Template file does not exist, aborting creation.'));
73+
return undefined;
74+
}
75+
76+
// Check if the destination folder is provided, otherwise use the first workspace folder
77+
let destRoot = options?.rootUri.fsPath;
78+
if (!destRoot) {
79+
const workspaceFolders = workspace.workspaceFolders;
80+
if (!workspaceFolders || workspaceFolders.length === 0) {
81+
window.showErrorMessage(l10n.t('No workspace folder is open or provided, aborting creation.'));
82+
return undefined;
83+
}
84+
destRoot = workspaceFolders[0].uri.fsPath;
85+
}
86+
87+
// Check if the destination folder already exists
88+
const scriptDestination = path.join(destRoot, scriptFileName);
89+
if (await fs.pathExists(scriptDestination)) {
90+
window.showErrorMessage(
91+
l10n.t(
92+
'A script file by that name already exists, aborting creation. Please retry with a unique script name given your workspace.',
93+
),
94+
);
95+
return undefined;
96+
}
97+
await fs.copy(newScriptTemplateFile, scriptDestination);
98+
99+
// 2. Replace 'script_name' in the file using a helper (just script name remove .py)
100+
await replaceInFilesAndNames(scriptDestination, 'script_name', scriptFileName.replace(/\.py$/, ''));
101+
102+
// 3. add custom github copilot instructions
103+
if (createCopilotInstructions) {
104+
const packageInstructionsPath = path.join(
105+
NEW_PROJECT_TEMPLATES_FOLDER,
106+
'copilot-instructions-text',
107+
'script-copilot-instructions.md',
108+
);
109+
await manageCopilotInstructionsFile(destRoot, packageInstructionsPath, [
110+
{ searchValue: '<script_name>', replaceValue: scriptFileName },
111+
]);
112+
}
113+
114+
return Uri.file(scriptDestination);
115+
}
15116
return undefined;
16117
}
17118
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Copilot Instructions for <script_name>
2+
3+
- The script `<script_name>` is a Python python project within the workspace.
4+
- It has inline script metadata (as proposed by PEP 723) that defines the script name, required python version, and dependencies.
5+
- If imports which require a specific Python version or dependencies are added, keep the inline script metadata up to date.
6+
- You need to call the `Get Python Environment Information` tool on the `<script_name>` path to get the Python executable details.
7+
- Substitute the Python executable you get from the `Get Python Environment Information` tool anywhere you see `<python>` in these instructions.
8+
- Run command for `<script_name>`: `<python> <script_name>`
9+
- Script can be easily debugged from the Integrated Terminal when activated with the command `debugpy <script_name>` after the necessary environment is activated.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# /// script_name
2+
# requires-python = ">=X.XX" TODO: Update this to the minimum Python version you want to support
3+
# dependencies = [
4+
# TODO: Add any dependencies your script requires
5+
# ]
6+
# ///
7+
8+
# TODO: Update the main function to your needs or remove it.
9+
10+
11+
def main() -> None:
12+
print("Start coding in Python today!")
13+
14+
15+
if __name__ == "__main__":
16+
main()

0 commit comments

Comments
 (0)