Skip to content

feature: add commands for creating modules and packages#1840

Open
NCBM wants to merge 9 commits into
DetachHead:mainfrom
NCBM:feat-module-package
Open

feature: add commands for creating modules and packages#1840
NCBM wants to merge 9 commits into
DetachHead:mainfrom
NCBM:feat-module-package

Conversation

@NCBM

@NCBM NCBM commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Changes:

  • Introducing langserver commands:
    • basedpyright.explorer.createNewModule: create a new module from current active file uri or given file uri with name validation
      • given file uri only available in vscode extension with folders in explorer menu
      • validation contains illegal file name (reject), name with dots (currently reject), non-identifier (warn), unicode (warn)
    • basedpyright.explorer.createNewPackage: create a new package (including __init__.py) from current active file uri or given file uri with name validation
      • similar with basedpyright.explorer.createNewModule
  • Context menu items in explorer/context (passing uri to langserver):
    • basedpyright.explorer.createNewModule
    • basedpyright.explorer.createNewPackage
    • only available when a folder selected

LLM-generated code included with proper review

Comment on lines +11 to +62
export function hasForbiddenFileNameChars(name: string): boolean {
for (const ch of name) {
const code = ch.charCodeAt(0);
if (code < 0x20 || code === 0x7f) {
return true;
}
if (
ch === '<' ||
ch === '>' ||
ch === ':' ||
ch === '"' ||
ch === '/' ||
ch === '\\' ||
ch === '|' ||
ch === '?' ||
ch === '*'
) {
return true;
}
}
return false;
}

function hasNonAsciiChars(name: string): boolean {
for (const ch of name) {
if (ch.charCodeAt(0) > 127) {
return true;
}
}
return false;
}

export function validatePythonName(name: string): NameValidation {
if (!name || hasForbiddenFileNameChars(name)) {
return 'forbidden';
}
// Pure-ASCII Python identifier
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return 'ok';
}
// Dot: always conflicts with Python's package-path resolution.
// A file named `foo.bar.py` can never be imported — `import foo.bar`
// looks for `foo/bar.py`, not `foo.bar.py`.
if (name.includes('.')) {
return 'dot';
}
// Non-ASCII: could be valid PEP 3131 identifier, but warn about portability
if (hasNonAsciiChars(name)) {
return 'unicode';
}
return 'nonIdentifier';
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sounds like something the pyright codebase would already have a function for. i found this:

private _isLegalModulePartName(name: string): boolean {
// PEP8 indicates that all module names should be lowercase
// with underscores. It doesn't talk about non-ASCII
// characters, but it appears that's the convention.
return !!name.match(/[a-z_]+/);
}

it should probably be moved to symbolNameUtils.ts and used by both modules

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two commands should be split up into separate modules, like the other commands

Comment on lines +72 to +86
async function resolveWorkspace(
ls: LanguageServerInterface,
primaryUri: Uri | undefined,
fallbackUri: Uri | undefined
) {
for (const uri of [primaryUri, fallbackUri]) {
if (uri) {
const workspace = await ls.getWorkspaceForFile(uri);
if (workspace) {
return workspace;
}
}
}
return undefined;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just resolve the workspace the same way as other commands? eg.

if (params.arguments && params.arguments.length >= 1) {
const docUri = Uri.parse(params.arguments[0] as string, this._ls.serviceProvider);
const otherArgs = params.arguments.slice(1);
const workspace = await this._ls.getWorkspaceForFile(docUri);

export class CreateModuleCommand implements ServerCommand {
constructor(private _ls: LanguageServerInterface) {}

async execute(cmdParams: ExecuteCommandParams, _token: CancellationToken): Promise<any> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid using any

export class CreatePackageCommand implements ServerCommand {
constructor(private _ls: LanguageServerInterface) {}

async execute(cmdParams: ExecuteCommandParams, _token: CancellationToken): Promise<any> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Comment on lines +183 to +215
const nameCheck = validatePythonName(packageName);
if (nameCheck === 'forbidden') {
this._ls.window.showErrorMessage(Service.invalidPythonNameForbidden().format({ name: packageName }));
return;
}
if (nameCheck === 'dot') {
this._ls.window.showErrorMessage(Service.invalidPythonNameDot().format({ name: packageName }));
return;
}
if (nameCheck === 'nonIdentifier') {
this._ls.window.showWarningMessage(Service.invalidPythonNameNonIdentifier().format({ name: packageName }));
}
if (nameCheck === 'unicode') {
this._ls.window.showWarningMessage(Service.invalidPythonNameUnicode().format({ name: packageName }));
}

const workspaceUri = tryParseUri(args[0] as string | undefined, this._ls);
const workspace = await resolveWorkspace(this._ls, workspaceUri, targetDir);
if (!workspace) {
this._ls.window.showErrorMessage(Service.noWorkspaceFound());
return;
}

const fs = workspace.service.fs;

if (!fs.existsSync(targetDir)) {
this._ls.window.showErrorMessage(Service.dirNotExist().format({ path: targetDir.toUserVisibleString() }));
return;
}
// If the target is a file (e.g. from command palette), use its parent directory
if (!fs.statSync(targetDir).isDirectory()) {
targetDir = targetDir.getDirectory();
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicated code

Comment on lines +14 to +15
// other test files should also be fixed.
setLocaleOverride('en-us');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// other test files should also be fixed.
setLocaleOverride('en-us');
// TODO: other test files should also be fixed.
setLocaleOverride('en-us');

Comment on lines +22 to +119
class MockWindow implements WindowInterface {
private _text = '';
messages: CapturedMessage[] = [];

get statusBarText(): string {
return this._text;
}
set statusBarText(value: string) {
this._text = value;
}

showErrorMessage(message: string): void;
showErrorMessage(message: string, ...actions: MessageAction[]): Promise<MessageAction | undefined>;
showErrorMessage(message: string, ..._actions: MessageAction[]): void | Promise<MessageAction | undefined> {
this.messages.push({ type: 'error', text: message });
return undefined;
}
showWarningMessage(message: string): void;
showWarningMessage(message: string, ...actions: MessageAction[]): Promise<MessageAction | undefined>;
showWarningMessage(message: string, ..._actions: MessageAction[]): void | Promise<MessageAction | undefined> {
this.messages.push({ type: 'warning', text: message });
return undefined;
}
showInformationMessage(message: string): void;
showInformationMessage(message: string, ...actions: MessageAction[]): Promise<MessageAction | undefined>;
showInformationMessage(
message: string,
..._actions: MessageAction[]
): void | Promise<MessageAction | undefined> {
this.messages.push({ type: 'info', text: message });
return undefined;
}
}

const _mockCancellationToken: CancellationToken = {
isCancellationRequested: false,
onCancellationRequested: () => ({ dispose: () => {} }),
};

function createMockLanguageServerInterface(testFS: TestFileSystem, window: MockWindow): LanguageServerInterface {
const sp = createServiceProvider(testFS);

const mockWorkspace: Workspace = {
workspaceName: 'test',
rootUri: Uri.file('/project', sp),
kinds: [WellKnownWorkspaceKinds.Test],
service: {
fs: testFS,
} as unknown as Workspace['service'],
disableLanguageServices: false,
disableTaggedHints: false,
disableOrganizeImports: false,
disableWorkspaceSymbol: false,
isInitialized: createInitStatus(),
searchPathsToWatch: [],
useTypingExtensions: false,
fileEnumerationTimeoutInSec: 10,
autoFormatStrings: true,
baselineMode: 'auto',
};

return {
serviceProvider: sp,
console: new NullConsole(),
window,
supportAdvancedEdits: false,
createBackgroundAnalysis: () => undefined,
reanalyze: () => {},
restart: () => {},
getWorkspaces: async () => [mockWorkspace],
getSettings: async () => ({
watchForSourceChanges: true,
watchForLibraryChanges: true,
watchForConfigChanges: true,
openFilesOnly: true,
useLibraryCodeForTypes: true,
disableLanguageServices: false,
disableTaggedHints: false,
disableOrganizeImports: false,
typeCheckingMode: 'off',
diagnosticSeverityOverrides: {},
logLevel: LogLevel.Info,
autoImportCompletions: true,
functionSignatureDisplay: SignatureDisplayType.formatted,
inlayHints: {
callArgumentNames: true,
callArgumentNamesMatching: false,
functionReturnTypes: true,
variableTypes: true,
genericTypes: false,
},
useTypingExtensions: false,
}),
getWorkspaceForFile: async () => mockWorkspace,
convertUriToLspUriString: (_fs, uri) => uri.toString(),
documentsWithDiagnostics: {},
};
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd = new CreateModuleCommand(ls);
});

// --- golden path ---

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of these comments you can use a nested describe

const explorerMenuCreateCommands = [Commands.createNewModule, Commands.createNewPackage];
explorerMenuCreateCommands.forEach((commandName) => {
context.subscriptions.push(registerRefactorCommand(client, commandName, true));
});

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see 7177e59, we should avoid registering commands in the vscode extension if possible because they dont work in other clients

@NCBM NCBM Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we indeed have client logic (asking extra inputs). is there fine replacements?

@NCBM

NCBM commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

maybe I can implement this feature in another way (like a different editor extension out of here). so feel free to close this if introducing this breaks availability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants