diff --git a/qt-python/package.json b/qt-python/package.json index cfc29369..3ae88308 100644 --- a/qt-python/package.json +++ b/qt-python/package.json @@ -40,6 +40,21 @@ "title": "Qt Python Configuration", "properties": {} }, + "commands": [ + { + "command": "qt-python.installPySide6", + "title": "%qt-python.command.installPySide6.title%", + "category": "Qt-Python" + } + ], + "menus": { + "commandPalette": [ + { + "command": "qt-python.installPySide6", + "when": "workspaceFolderCount > 0" + } + ] + }, "taskDefinitions": [ { "type": "pyside", diff --git a/qt-python/package.nls.json b/qt-python/package.nls.json index 0967ef42..de7b0751 100644 --- a/qt-python/package.nls.json +++ b/qt-python/package.nls.json @@ -1 +1,3 @@ -{} +{ + "qt-python.command.installPySide6.title": "Install PySide6" +} diff --git a/qt-python/src/builder.ts b/qt-python/src/builder.ts new file mode 100644 index 00000000..aaf60474 --- /dev/null +++ b/qt-python/src/builder.ts @@ -0,0 +1,82 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only + +import * as path from 'path'; +import * as childProcess from 'child_process'; + +import { IsWindows } from 'qt-lib'; +import { PySideEnv } from './env'; + +export interface PySideCommandBuildOptions { + useVenv?: boolean; + cwd?: string; +} + +export class PySideCommandBuilder { + private readonly _shellPath: string; + private readonly _shellArgs: string[]; + private readonly _venvActivationCommand: string; + + constructor( + private readonly _env: PySideEnv, + private readonly _options?: PySideCommandBuildOptions + ) { + this._shellPath = resolveShellPath(); + this._shellArgs = [IsWindows ? '/c' : '-c']; + this._venvActivationCommand = resolveVenvActivationCommand(this._env); + } + + get shellPath(): string { + return this._shellPath; + } + + get shellArgs(): string[] { + return this._shellArgs; + } + + get venvActivationCommand(): string { + return this._venvActivationCommand; + } + + public build(command: string) { + const all: string[] = [command]; + + if (this._options?.cwd) { + all.unshift(`cd ${enclosePath(this._options.cwd)}`); + } + + if (this._options?.useVenv && this._venvActivationCommand) { + all.unshift(this._venvActivationCommand); + } + + return all.join(' && '); + } +} + +// helpers +function resolveShellPath(): string { + if (IsWindows) { + return process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe'; + } + + const result = childProcess.spawnSync('command -v bash', { shell: true }); + const found = result.stdout.toString().trim(); + return result.status === 0 && found ? found : '/bin/bash'; +} + +function resolveVenvActivationCommand(env: PySideEnv): string { + const bin = env.venvBinPath; + if (!bin) { + return ''; + } + + const script = enclosePath( + path.join(bin, IsWindows ? 'activate.bat' : 'activate') + ); + + return IsWindows ? script : `source ${script}`; +} + +function enclosePath(s: string) { + return IsWindows ? `"${s}"` : `'${s}'`; +} diff --git a/qt-python/src/constants.ts b/qt-python/src/constants.ts index f0e79297..3486d1a8 100644 --- a/qt-python/src/constants.ts +++ b/qt-python/src/constants.ts @@ -7,6 +7,11 @@ export const EXTENSION_ID = 'qt-python'; export const MS_PYTHON_ID = 'ms-python.python'; export const LOG_NAME = 'qt-python'; +export const COMMAND_PREFIX = 'qt-python'; +export const COMMAND_INSTALL_PYSIDE = 'installPySide6'; // contributes > commands +export const COMMAND_PYTHON_CREATE_ENV = 'python.createEnvironment'; +export const COMMAND_PYTHON_SELECT_PYTHON = 'python.setInterpreter'; + export const TASK_TYPE = 'pyside'; // contributes > taskDefinitions export const TASK_SOURCE = 'PySide'; @@ -21,3 +26,4 @@ export const TOML_KEY_PROJECT_NAME = 'project.name'; export const TOML_KEY_PROJECT_FILES = 'tool.pyside6-project.files'; export const PYSIDE_PROJECT_TOOL = 'pyside6-project'; +export const MAINT_WHEEL_DIR_NAME = 'QtForPython'; diff --git a/qt-python/src/env.ts b/qt-python/src/env.ts index a574235a..f64c5e58 100644 --- a/qt-python/src/env.ts +++ b/qt-python/src/env.ts @@ -14,10 +14,22 @@ export class PySideEnv { this._pyEnv = pyenv; } + public isVenv(): boolean { + return this._pyEnv?.environment?.type === 'VirtualEnvironment'; + } + + get venvName(): string | undefined { + return this._pyEnv?.environment?.name; + } + get venvBinPath(): string { const root = this._pyEnv?.executable.sysPrefix ?? ''; return root ? utils.toForwardSlash(path.join(root, consts.VENV_BIN_DIR)) : ''; } + + get interpreterPath(): string | undefined { + return this._pyEnv?.executable.uri?.fsPath; + } } diff --git a/qt-python/src/extension.ts b/qt-python/src/extension.ts index 2755f557..47de4843 100644 --- a/qt-python/src/extension.ts +++ b/qt-python/src/extension.ts @@ -18,6 +18,7 @@ import { PySideTaskProvider } from './task'; import { PySideDebugConfigProvider } from './debug'; import { PySideProjectManager } from './project-manager'; import * as consts from '@/constants'; +import { onInstallPySide6Command } from './installer'; const logger = createLogger('extension'); @@ -34,6 +35,8 @@ export async function activate(context: vscode.ExtensionContext) { await initDependency(); await initCoreApi(); await initPythonSupport(context); + initCommands(context); + logger.info(`Activated: ${consts.EXTENSION_ID}`); telemetry.sendEvent('activated'); } catch (e) { @@ -74,6 +77,22 @@ async function initPythonSupport(context: vscode.ExtensionContext) { ); } +function initCommands(context: vscode.ExtensionContext) { + function register(c: string, callback: (...args: unknown[]) => unknown) { + return vscode.commands.registerCommand( + `${consts.COMMAND_PREFIX}.${c}`, + async () => { + telemetry.sendAction(c); + await callback(); + } + ); + } + + context.subscriptions.push( + register(consts.COMMAND_INSTALL_PYSIDE, onInstallPySide6Command) + ); +} + const onPyApiEnvChanged = async (e: PyApiEnvChanged) => { const folder = e.resource; if (folder) { diff --git a/qt-python/src/installer.ts b/qt-python/src/installer.ts new file mode 100644 index 00000000..84f9ea6f --- /dev/null +++ b/qt-python/src/installer.ts @@ -0,0 +1,304 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { + isError, + CoreKey, + createLogger, + QtInsRootConfigName, + compareVersions +} from 'qt-lib'; +import { PySideEnv } from './env'; +import { PySideProject } from './project'; +import { PySideCommandRunner } from './runner'; +import { coreApi, projectManager } from './extension'; +import { normalizeDriveLetter } from './utils'; +import * as texts from './texts'; +import * as consts from './constants'; + +const logger = createLogger('installer'); + +interface TargetProjectItem extends vscode.QuickPickItem { + project: PySideProject; +} + +interface InstallSourceItem extends vscode.QuickPickItem { + source?: 'oss' | 'local' | 'download'; + env?: PySideEnv; + localPath?: string; +} + +interface CheckResult { + status: 'noVenv' | 'noPySide' | 'alreadyInstalled'; + folder: vscode.WorkspaceFolder; + env?: PySideEnv; + pysideVersion?: string; +} + +export async function onInstallPySide6Command() { + const selected = await selectTargetProject(); + if (!selected) { + return; + } + + info(selected.folder, 'Check installation status'); + const result = await checkInstallation(selected); + + info( + selected.folder, + 'Check installation status: ', + `result = ${result.status}, `, + `version = ${result.pysideVersion}` + ); + + void showMessageOrInstall(result); +} + +async function selectTargetProject() { + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length <= 1) { + return folders[0] && projectManager.getProject(folders[0]); + } + + const items = folders + .map((folder) => { + const project = projectManager.getProject(folder); + if (!project) { + return undefined; + } + + return { + label: folder.name, + description: folder.uri.fsPath, + project + } as TargetProjectItem; + }) + .filter((item) => item !== undefined); + + const placeHolder = texts.install.placeHolder.selectFolder; + const selected = await vscode.window.showQuickPick(items, { placeHolder }); + return selected?.project; +} + +async function checkInstallation(project: PySideProject): Promise { + const env = project.env; + const folder = project.folder; + if (!env || !env.isVenv()) { + return { status: 'noVenv', folder }; + } + + const pysideVersion = await fetchPySide6Version(env); + if (!pysideVersion) { + return { status: 'noPySide', folder, env }; + } + + return { status: 'alreadyInstalled', folder, env, pysideVersion }; +} + +async function showMessageOrInstall(result: CheckResult) { + if (result.status === 'alreadyInstalled') { + void vscode.window.showInformationMessage( + texts.install.popup.alreadyInstalled( + result.folder.name, + result.pysideVersion ?? '', + result.env?.venvName ?? '' + ) + ); + return; + } + + if (result.status === 'noVenv') { + const msg = texts.install.popup.noVenv(result.folder.name); + const btnCreate = texts.install.popup.buttonCreateEnv; + const btnSelect = texts.install.popup.buttonSelectEnv; + + void vscode.window + .showWarningMessage(msg, btnCreate, btnSelect) + .then((value) => { + const exec = vscode.commands.executeCommand; + if (value === btnCreate) { + void exec(consts.COMMAND_PYTHON_CREATE_ENV); + } else if (value === btnSelect) { + void exec(consts.COMMAND_PYTHON_SELECT_PYTHON); + } + }); + + return; + } + + if (!result.env) { + logger.error('Environment is invalid'); + return; + } + + await tryInstallPySide(result.folder, result.env); +} + +async function tryInstallPySide( + folder: vscode.WorkspaceFolder, + env: PySideEnv +) { + const source = await selectInstallSource(env); + if (!source) { + return; + } + + if (source.source === 'download') { + const url = vscode.Uri.parse('https://account.qt.io'); + void vscode.env.openExternal(url); + return; + } + + const logIndented = (line: string) => { + info(folder, ' ', line); + }; + + const options = { + title: texts.install.popup.installing, + location: vscode.ProgressLocation.Notification, + cancellable: false + }; + + await vscode.window.withProgress(options, async () => { + const runner = new PySideCommandRunner(env); + runner.onStdout(logIndented); + runner.onStderr(logIndented); + + if (source.source === 'oss') { + await runner.run('pip install PySide6', { useVenv: true }); + } else if (source.source === 'local' && source.localPath) { + await runner.run('pip install -r requirements.txt', { + useVenv: true, + cwd: source.localPath + }); + } else { + return; + } + + const pyside6Version = await fetchPySide6Version(env); + if (pyside6Version) { + void vscode.window.showInformationMessage( + texts.install.popup.installed( + folder.name, + pyside6Version, + env.venvName ?? '' + ) + ); + } + }); +} + +async function selectInstallSource(env: PySideEnv) { + const items: InstallSourceItem[] = [ + { + source: 'oss', + label: texts.install.sourcePicker.labelOss, + env + } + ]; + + if (coreApi) { + const insRootKey = QtInsRootConfigName; + const insRoot = + coreApi.getValue(CoreKey.GLOBAL_WORKSPACE, insRootKey) ?? ''; + + const localPackages = await getLocalPackageInfo(insRoot, env); + if (localPackages.length !== 0) { + items.push({ + kind: vscode.QuickPickItemKind.Separator, + label: texts.install.sourcePicker.annotationForLocal + }); + items.push(...localPackages); + } + } + + items.push({ + kind: vscode.QuickPickItemKind.Separator, + label: '' + }); + + items.push({ + source: 'download', + label: texts.install.sourcePicker.labelDownload + ' $(globe)' + }); + + const placeHolder = texts.install.placeHolder.selectVersion; + return vscode.window.showQuickPick(items, { placeHolder }); +} + +async function getLocalPackageInfo(insRoot: string, env: PySideEnv) { + const packagesRoot = path.join(insRoot, consts.MAINT_WHEEL_DIR_NAME); + + try { + const entries = await fs.promises.readdir(packagesRoot, { + withFileTypes: true + }); + + const pickerItems = entries + .filter((entry) => { + if (entry.isDirectory()) { + const r = path.join(packagesRoot, entry.name, 'requirements.txt'); + return fs.existsSync(r); + } + + return false; + }) + .sort((a: fs.Dirent, b: fs.Dirent) => { + return -1 * compareVersions(a.name, b.name); + }) + .map((e) => { + const dir = normalizeDriveLetter(path.join(e.parentPath, e.name)); + return { + label: e.name, + description: dir, + env, + source: 'local', + localPath: dir + } as InstallSourceItem; + }); + + return pickerItems; + } catch (err) { + return []; + } +} + +// helpers +const info = (folder: vscode.WorkspaceFolder, ...message: string[]) => { + logger.info(`(${folder.name}) `, ...message); +}; + +async function fetchPySide6Version(env: PySideEnv) { + const logIndented = (line: string) => { + logger.info(' ', line); + }; + + try { + // expected output from 'pip show ' + // + // Name: PySide6_Essentials + // Version: 6.7.0+commercial + // Summary: Python bindings for the Qt cross-platform ... + + const runner = new PySideCommandRunner(env); + runner.onStdout(logIndented); + runner.onStderr(logIndented); + + const output = await runner.run(`pip show PySide6`, { useVenv: true }); + + for (const line of output) { + const [key, value] = line.split(':'); + if (key?.toLowerCase() === 'version' && value) { + return value.trim(); + } + } + } catch (e) { + logger.error(isError(e) ? e.message : String(e)); + } + + return undefined; +} diff --git a/qt-python/src/project.ts b/qt-python/src/project.ts index b4dff7c3..5fedb2c4 100644 --- a/qt-python/src/project.ts +++ b/qt-python/src/project.ts @@ -9,6 +9,7 @@ import { PySideProjectInfo } from './types'; type Folder = vscode.WorkspaceFolder; type Context = vscode.ExtensionContext; + const logger = createLogger('project'); export class PySideProject implements Project { diff --git a/qt-python/src/runner.ts b/qt-python/src/runner.ts new file mode 100644 index 00000000..2d25e7ee --- /dev/null +++ b/qt-python/src/runner.ts @@ -0,0 +1,90 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only + +import * as childProcess from 'child_process'; + +import { createLogger } from 'qt-lib'; +import { PySideEnv } from './env'; +import { PySideCommandBuilder, PySideCommandBuildOptions } from './builder'; + +const logger = createLogger('runner'); + +export class PySideCommandRunner { + private _onStdout: ((line: string) => void) | undefined; + private _onStderr: ((line: string) => void) | undefined; + + constructor(private readonly _env: PySideEnv) {} + + public onStdout(f: ((line: string) => void) | undefined) { + this._onStdout = f; + } + + public onStderr(f: ((line: string) => void) | undefined) { + this._onStderr = f; + } + + public async run(command: string, options?: PySideCommandBuildOptions) { + const builder = new PySideCommandBuilder(this._env, options); + const commandLine = builder.build(command); + + logger.info('Running command'); + logger.info(`- shell: ${builder.shellPath}`); + logger.info(`- command: ${commandLine}`); + logger.info( + '- venv activation: ' + + `${options?.useVenv ?? false}, ` + + builder.venvActivationCommand + ); + + const proc = childProcess.spawn(commandLine, { shell: builder.shellPath }); + const outPromise = streamToLines(proc.stdout, this._onStdout); + const errPromise = streamToLines(proc.stderr, this._onStderr); + + await new Promise((resolve, reject) => { + proc.on('error', reject); + proc.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`Process exited with code ${code}`)); + }); + }); + + const out = await outPromise; + const err = await errPromise; + void err; + + return out; + } +} + +// helpers +type Stream = NodeJS.ReadableStream; +type Callback = ((line: string) => void) | undefined; + +async function streamToLines(stream: Stream, callback: Callback) { + let leftover = ''; + const lines: string[] = []; + + for await (const chunk of stream) { + const text = leftover + chunk.toString(); + const parts = text.split('\n'); + leftover = parts.pop() ?? ''; + + for (const line of parts) { + const trimmed = line.trim(); + lines.push(trimmed); + callback?.(trimmed); + } + } + + if (leftover.trim()) { + const trimmed = leftover.trim(); + lines.push(trimmed); + callback?.(trimmed); + } + + return lines; +} diff --git a/qt-python/src/task.ts b/qt-python/src/task.ts index 9a908e64..23fbd30b 100644 --- a/qt-python/src/task.ts +++ b/qt-python/src/task.ts @@ -4,11 +4,11 @@ import _ from 'lodash'; import * as vscode from 'vscode'; -import { IsWindows, createLogger } from 'qt-lib'; +import { createLogger } from 'qt-lib'; import { TaskId, type ProjectToolAction } from './types'; -import { PySideEnv } from './env'; import { PySideProject } from './project'; import { projectManager } from './extension'; +import { PySideCommandBuilder } from './builder'; import * as consts from './constants'; const logger = createLogger('task'); @@ -74,8 +74,14 @@ function createTask(project: PySideProject, taskId: TaskId) { return undefined; } - const cwd = folder.uri.fsPath; - const cmd = createCmdline(taskId, env); + const builder = new PySideCommandBuilder(env, { useVenv: true }); + const commandLine = builder.build(createProjectToolCommand(taskId)); + const shellOptions = { + cwd: folder.uri.fsPath, + executable: builder.shellPath, + shellArgs: builder.shellArgs + }; + const def = { type: consts.TASK_TYPE, action // contributes > taskDefinitions @@ -83,7 +89,7 @@ function createTask(project: PySideProject, taskId: TaskId) { const task = new vscode.Task(def, folder, taskName, consts.TASK_SOURCE); task.detail = `${consts.PYSIDE_PROJECT_TOOL} ${action}`; - task.execution = new vscode.ShellExecution(cmd, { cwd }); + task.execution = new vscode.ShellExecution(commandLine, shellOptions); task.presentationOptions = { clear: true }; const group = findTaskGroup(taskId); @@ -94,17 +100,9 @@ function createTask(project: PySideProject, taskId: TaskId) { return task; } -function createCmdline(taskId: TaskId, env: PySideEnv) { +function createProjectToolCommand(taskId: TaskId) { const action = findProjectToolAction(taskId); - const pysideCmd = `${consts.PYSIDE_PROJECT_TOOL} ${action ?? ''}`; - const binPath = env.venvBinPath; - const shellExec = IsWindows - ? (process.env.ComSpec ?? 'C:/Windows/System32/cmd.exe') + ' /c' - : '/usr/bin/env bash -c'; - - return IsWindows - ? `${shellExec} '"${binPath}/activate.bat" && ${pysideCmd}'` - : `${shellExec} 'source "${binPath}/activate" && ${pysideCmd}'`; + return `${consts.PYSIDE_PROJECT_TOOL} ${action ?? ''}`; } function findTaskName(id: TaskId): string | undefined { diff --git a/qt-python/src/texts.ts b/qt-python/src/texts.ts new file mode 100644 index 00000000..bc7014ec --- /dev/null +++ b/qt-python/src/texts.ts @@ -0,0 +1,30 @@ +// Copyright (C) 2025 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only + +export const install = { + popup: { + installing: 'Installing PySide6...', + installed: (folder: string, pysideVersion: string, venv: string) => + `PySide ${pysideVersion} is installed for '${folder}'. ` + + `(venv: ${venv})`, + alreadyInstalled: (folder: string, pysideVersion: string, venv: string) => + `PySide ${pysideVersion} is already installed for '${folder}'. ` + + `(venv: ${venv})`, + noVenv: (folder: string) => + `No virtual environment found for '${folder}'. ` + + 'Please create or select a virtual environment first.', + buttonCreateEnv: 'Create Environment', + buttonSelectEnv: 'Select Interpreter' + }, + placeHolder: { + selectFolder: 'Select a Folder', + selectVersion: 'Select PySide Version' + }, + sourcePicker: { + labelOss: 'Latest PySide6 from PyPI', + labelDownload: + 'Visit https://account.qt.io/ ' + + 'to download and install the wheels manually', + annotationForLocal: 'from Qt installation root' + } +}; diff --git a/qt-python/src/utils.ts b/qt-python/src/utils.ts index dc42e24c..fb2e81d5 100644 --- a/qt-python/src/utils.ts +++ b/qt-python/src/utils.ts @@ -1,6 +1,21 @@ // Copyright (C) 2025 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only +import { IsWindows } from 'qt-lib'; + export function toForwardSlash(path: string): string { return path.replace(/\\/g, '/'); } + +export function normalizeDriveLetter(p: string): string { + if (!IsWindows || p.length < 2) { + return p; + } + + const drive = p[0]; + if (p[1] === ':' && drive && drive >= 'a' && drive <= 'z') { + return drive.toUpperCase() + p.slice(1); + } + + return p; +}