Skip to content

Commit 7800a6b

Browse files
committed
support create new for a pep 723 script
1 parent d0e7390 commit 7800a6b

File tree

6 files changed

+177
-41
lines changed

6 files changed

+177
-41
lines changed

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
111111
projectCreators.registerPythonProjectCreator(new ExistingProjects(projectManager)),
112112
projectCreators.registerPythonProjectCreator(new AutoFindProjects(projectManager)),
113113
projectCreators.registerPythonProjectCreator(new NewPackageProject(envManagers, projectManager)),
114-
projectCreators.registerPythonProjectCreator(new NewScriptProject()),
114+
projectCreators.registerPythonProjectCreator(new NewScriptProject(envManagers, projectManager)),
115115
);
116116

117117
setPythonApi(envManagers, projectManager, projectCreators, terminalManager, envVarManager);

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, packageName, [
144+
{ searchValue: '<package_name>', replaceValue: packageInstructionsPath },
145+
]);
144146
}
145147

146148
// update launch.json file with config for the package
Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,122 @@
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 { EnvironmentManagers, PythonProjectManager } from '../../internal.api';
8+
import { isCopilotInstalled, manageCopilotInstructionsFile, replaceInFilesAndNames } from './creationHelpers';
39

410
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');
11+
public readonly name = l10n.t('newScript');
12+
public readonly displayName = l10n.t('Script');
13+
public readonly description = l10n.t('Creates a new script folder in your current workspace with PEP 723 support');
14+
public readonly tooltip = new MarkdownString(l10n.t('Create a new Python script'));
915

10-
constructor() {}
16+
constructor(
17+
private readonly envManagers: EnvironmentManagers,
18+
private readonly projectManager: PythonProjectManager,
19+
) {}
1120

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...');
21+
async create(options?: PythonProjectCreatorOptions): Promise<PythonProject | Uri | undefined> {
22+
// quick create (needs name, will always create venv and copilot instructions)
23+
// not quick create
24+
// ask for script file name
25+
// ask if they want venv
26+
let scriptFileName = options?.name;
27+
let createCopilotInstructions: boolean | undefined;
28+
if (options?.quickCreate === true) {
29+
// If quickCreate is true, we should not prompt for any input
30+
if (!scriptFileName) {
31+
throw new Error('Script file name is required in quickCreate mode.');
32+
}
33+
createCopilotInstructions = true;
34+
} else {
35+
//Prompt as quickCreate is false
36+
if (!scriptFileName) {
37+
try {
38+
scriptFileName = await showInputBoxWithButtons({
39+
prompt: l10n.t('What is the name of the script? (e.g. my_script.py)'),
40+
ignoreFocusOut: true,
41+
showBackButton: true,
42+
validateInput: (value) => {
43+
// Ensure the filename ends with .py and follows valid naming conventions
44+
if (!value.endsWith('.py')) {
45+
return l10n.t('Script name must end with ".py".');
46+
}
47+
const baseName = value.replace(/\.py$/, '');
48+
// following PyPI (PEP 508) rules for package names
49+
if (!/^([a-z_]|[a-z0-9_][a-z0-9._-]*[a-z0-9_])$/i.test(baseName)) {
50+
return l10n.t(
51+
'Invalid script name. Use only letters, numbers, underscores, hyphens, or periods. Must start and end with a letter or number.',
52+
);
53+
}
54+
if (/^[-._0-9]$/i.test(baseName)) {
55+
return l10n.t('Single-character script names cannot be a number, hyphen, or period.');
56+
}
57+
return null;
58+
},
59+
});
60+
} catch (ex) {
61+
if (ex === QuickInputButtons.Back) {
62+
await commands.executeCommand('python-envs.createNewProjectFromTemplate');
63+
}
64+
}
65+
if (!scriptFileName) {
66+
return undefined;
67+
}
68+
if (isCopilotInstalled()) {
69+
createCopilotInstructions = true;
70+
}
71+
}
72+
73+
// 1. Copy template folder
74+
const newScriptTemplateFile = path.join(NEW_PROJECT_TEMPLATES_FOLDER, 'new723ScriptTemplate', 'script.py');
75+
if (!(await fs.pathExists(newScriptTemplateFile))) {
76+
window.showErrorMessage(l10n.t('Template file does not exist, aborting creation.'));
77+
return undefined;
78+
}
79+
80+
// Check if the destination folder is provided, otherwise use the first workspace folder
81+
let destRoot = options?.rootUri.fsPath;
82+
if (!destRoot) {
83+
const workspaceFolders = workspace.workspaceFolders;
84+
if (!workspaceFolders || workspaceFolders.length === 0) {
85+
window.showErrorMessage(l10n.t('No workspace folder is open or provided, aborting creation.'));
86+
return undefined;
87+
}
88+
destRoot = workspaceFolders[0].uri.fsPath;
89+
}
90+
91+
// Check if the destination folder already exists
92+
const scriptDestination = path.join(destRoot, scriptFileName);
93+
if (await fs.pathExists(scriptDestination)) {
94+
window.showErrorMessage(
95+
l10n.t(
96+
'A script file by that name already exists, aborting creation. Please retry with a unique script name given your workspace.',
97+
),
98+
);
99+
return undefined;
100+
}
101+
await fs.copy(newScriptTemplateFile, scriptDestination);
102+
103+
// 2. Replace 'script_name' in the file using a helper (just script name remove .py)
104+
await replaceInFilesAndNames(scriptDestination, 'script_name', scriptFileName.replace(/\.py$/, ''));
105+
106+
// 3. add custom github copilot instructions
107+
if (createCopilotInstructions) {
108+
const packageInstructionsPath = path.join(
109+
NEW_PROJECT_TEMPLATES_FOLDER,
110+
'copilot-instructions-text',
111+
'script-copilot-instructions.md',
112+
);
113+
await manageCopilotInstructionsFile(destRoot, packageInstructionsPath, [
114+
{ searchValue: '<script_name>', replaceValue: scriptFileName },
115+
]);
116+
}
117+
118+
return Uri.file(scriptDestination);
119+
}
15120
return undefined;
16121
}
17122
}
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)