diff --git a/.gitignore b/.gitignore index 2a6babf..1028765 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ node_modules .vscode-test/ *.vsix src/test/examples/*/.tox + +# Python venvs +.venv diff --git a/package.json b/package.json index 0abf3ff..9dc15f2 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,27 @@ "Other" ], "activationEvents": [ + "onCommand:workbench.action.tasks.runTask", "onCommand:python-tox.select", "onCommand:python-tox.selectMultiple", "onCommand:python-tox.openDocs" ], "main": "./out/extension.js", "contributes": { + "taskDefinitions": [ + { + "type": "tox", + "required": [ + "testenv" + ], + "properties": { + "testenv": { + "type": "string", + "description": "The testenv to execute" + } + } + } + ], "commands": [ { "command": "python-tox.select", @@ -83,4 +98,4 @@ "typescript": "^4.6.3", "vsce": "^2.7.0" } -} +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 5444525..c1ee9b2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,10 @@ import * as util from 'util'; import * as path from 'path'; import * as os from 'os'; +import { ToxTaskProvider } from './toxTaskProvider'; + +let toxTaskProvider: vscode.Disposable | undefined; + const exec = util.promisify(child_process.exec); function findProjectDir() { @@ -26,7 +30,7 @@ function findProjectDir() { } async function getToxEnvs(projDir: string) { - const { stdout } = await exec('tox -a', {cwd: projDir}); + const { stdout } = await exec('tox -a', { cwd: projDir }); return stdout.trim().split(os.EOL); } @@ -40,7 +44,7 @@ async function safeGetToxEnvs(projDir: string) { } function runTox(envs: string[], projDir: string) { - const term = vscode.window.createTerminal({"cwd": projDir, "name": "tox"}); + const term = vscode.window.createTerminal({ "cwd": projDir, "name": "tox" }); const envArg = envs.join(","); term.show(true); // preserve focus @@ -65,7 +69,7 @@ async function selectCommand() { if (!envs) { return; } - const selected = await vscode.window.showQuickPick(envs, {placeHolder: "tox environment"}); + const selected = await vscode.window.showQuickPick(envs, { placeHolder: "tox environment" }); if (!selected) { return; } @@ -78,7 +82,7 @@ async function selectMultipleCommand() { if (!envs) { return; } - const selected = await vscode.window.showQuickPick(envs, {placeHolder: "tox environments", canPickMany: true}); + const selected = await vscode.window.showQuickPick(envs, { placeHolder: "tox environments", canPickMany: true }); if (!selected) { return; } @@ -90,6 +94,10 @@ async function openDocumentationCommand() { } export function activate(context: vscode.ExtensionContext) { + const workspaceTox = findProjectDir(); + if (workspaceTox) { + toxTaskProvider = vscode.tasks.registerTaskProvider(ToxTaskProvider.toxType, new ToxTaskProvider(workspaceTox)); + } context.subscriptions.push( vscode.commands.registerCommand('python-tox.select', selectCommand), vscode.commands.registerCommand('python-tox.selectMultiple', selectMultipleCommand), @@ -97,7 +105,13 @@ export function activate(context: vscode.ExtensionContext) { ); } -export function deactivate() {} +export function deactivate() { + + if (toxTaskProvider) { + toxTaskProvider.dispose(); + } + +} // For testing, before we move this to a utils.ts export const _private = { diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index fe6f2a9..116aaa9 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -1,4 +1,4 @@ -import { strict as assert } from 'assert'; +import { doesNotMatch, strict as assert } from 'assert'; // You can import and use all API from the 'vscode' module // as well as import your extension to test it @@ -6,16 +6,10 @@ import * as vscode from 'vscode'; import * as extension from '../../extension'; import * as path from 'path'; import * as fs from 'fs'; -import * as util from 'util'; - -function getExampleDir(name: string) { - const dir = path.join(__dirname, '..', '..', '..', 'src', 'test', 'examples', name); - assert.ok(fs.existsSync(dir)); - return dir; -} +import * as utils from './utils'; function getExampleFileUri(name: string, file: string) { - const dir = getExampleDir(name); + const dir = utils.getExampleDir(name); const filePath = path.join(dir, file); assert.ok(fs.existsSync(filePath)); return vscode.Uri.file(filePath); @@ -44,19 +38,19 @@ suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); test('getting tox environments', async () => { - const dir = getExampleDir("simple"); + const dir = utils.getExampleDir("simple"); const envs = await extension._private.getToxEnvs(dir); assert.deepEqual(envs, ["one", "two"]); }); test('make sure we have all tox environments', async () => { - const dir = getExampleDir("allenvs"); + const dir = utils.getExampleDir("allenvs"); const envs = await extension._private.getToxEnvs(dir); assert.deepEqual(envs, ["one", "two", "three"]); }); test('running tox', async () => { - const dir = getExampleDir("end2end"); + const dir = utils.getExampleDir("end2end"); const tmpdir = path.join(dir, ".tox", "tmp"); const marker = path.join(tmpdir, "tox-did-run"); diff --git a/src/test/suite/toxTaskProvider.test.ts b/src/test/suite/toxTaskProvider.test.ts new file mode 100644 index 0000000..8d32633 --- /dev/null +++ b/src/test/suite/toxTaskProvider.test.ts @@ -0,0 +1,31 @@ +import { doesNotMatch, strict as assert } from 'assert'; + +// You can import and use all API from the 'vscode' module +// as well as import your extension to test it +import * as vscode from 'vscode'; +import * as tasks from '../../toxTaskProvider'; +import * as utils from './utils'; + +suite('ToxTaskProvider Test Suite', () => { + + vscode.window.showInformationMessage('Start all tests.'); + + test('getting tox tasks', async() => { + const dir = utils.getExampleDir("allenvs"); + + const allEnvsWorkspaceFolder = { + uri: vscode.Uri.file(dir), + name: "AllEnvs", + index: 0, + }; + + vscode.workspace.updateWorkspaceFolders(0, null, allEnvsWorkspaceFolder); + + const toxTaskProvider = new tasks.ToxTaskProvider(dir); + const toxTasks = await toxTaskProvider.provideTasks(); + assert.equal(toxTasks?.length, 3); + assert.equal(toxTasks[0].name, "one"); + assert.equal(toxTasks[1].name, "two"); + assert.equal(toxTasks[2].name, "three"); + }); +}); diff --git a/src/test/suite/utils.ts b/src/test/suite/utils.ts new file mode 100644 index 0000000..797cfad --- /dev/null +++ b/src/test/suite/utils.ts @@ -0,0 +1,9 @@ +import { doesNotMatch, strict as assert } from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function getExampleDir(name: string) { + const dir = path.join(__dirname, '..', '..', '..', 'src', 'test', 'examples', name); + assert.ok(fs.existsSync(dir)); + return dir; +} \ No newline at end of file diff --git a/src/toxTaskProvider.ts b/src/toxTaskProvider.ts new file mode 100644 index 0000000..a60caae --- /dev/null +++ b/src/toxTaskProvider.ts @@ -0,0 +1,149 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as cp from 'child_process'; +import * as vscode from 'vscode'; + +export class ToxTaskProvider implements vscode.TaskProvider { + static readonly toxType = 'tox'; + static readonly toxIni = 'tox.ini'; + private toxPromise: Thenable | undefined = undefined; + + constructor(workspaceRoot: string) { + const pattern = path.join(workspaceRoot, ToxTaskProvider.toxIni); + const fileWatcher = vscode.workspace.createFileSystemWatcher(pattern); + fileWatcher.onDidChange(() => this.toxPromise = undefined); + fileWatcher.onDidCreate(() => this.toxPromise = undefined); + fileWatcher.onDidDelete(() => this.toxPromise = undefined); + } + + public provideTasks(): Thenable | undefined { + if (!this.toxPromise) { + this.toxPromise = getToxTestenvs(); + } + return this.toxPromise; + } + + public resolveTask(_task: vscode.Task): vscode.Task | undefined { + const testenv = _task.definition.testenv; + if (testenv) { + const definition: ToxTaskDefinition = _task.definition; + return new vscode.Task(definition, _task.scope ?? vscode.TaskScope.Workspace, definition.testenv, ToxTaskProvider.toxType, new vscode.ShellExecution(`tox -e ${definition.testenv}`)); + } + return undefined; + } +} + +function exists(file: string): Promise { + return new Promise((resolve, _reject) => { + fs.exists(file, (value) => { + resolve(value); + }); + }); +} + +function exec(command: string, options: cp.ExecOptions): Promise<{ stdout: string; stderr: string }> { + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + cp.exec(command, options, (error, stdout, stderr) => { + if (error) { + reject({ error, stdout, stderr }); + } + resolve({ stdout, stderr }); + }); + }); +} + +let _channel: vscode.OutputChannel; +function getOutputChannel(): vscode.OutputChannel { + if (!_channel) { + _channel = vscode.window.createOutputChannel('Tox Auto Detection'); + } + return _channel; +} + +interface ToxTaskDefinition extends vscode.TaskDefinition { + /** + * The environment name + */ + testenv: string; +} + +const buildNames: string[] = ['build', 'compile', 'watch']; +function isBuildTask(name: string): boolean { + for (const buildName of buildNames) { + if (name.indexOf(buildName) !== -1) { + return true; + } + } + return false; +} + +const testNames: string[] = ['test']; +function isTestTask(name: string): boolean { + for (const testName of testNames) { + if (name.indexOf(testName) !== -1) { + return true; + } + } + return false; +} + +async function getToxTestenvs(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + const result: vscode.Task[] = []; + + if (!workspaceFolders || workspaceFolders.length === 0) { + return result; + } + for (const workspaceFolder of workspaceFolders) { + const folderString = workspaceFolder.uri.fsPath; + if (!folderString) { + continue; + } + const toxFile = path.join(folderString, ToxTaskProvider.toxIni); + if (!await exists(toxFile)) { + continue; + } + + const commandLine = 'tox -a'; + try { + const { stdout, stderr } = await exec(commandLine, { cwd: folderString }); + if (stderr && stderr.length > 0) { + getOutputChannel().appendLine(stderr); + getOutputChannel().show(true); + } + if (stdout) { + const lines = stdout.split(/\r{0,1}\n/); + for (const line of lines) { + if (line.length === 0) { + continue; + } + const toxTestenv = line; + const kind: ToxTaskDefinition = { + type: ToxTaskProvider.toxType, + testenv: toxTestenv + }; + + const task = new vscode.Task(kind, workspaceFolder, toxTestenv, ToxTaskProvider.toxType, new vscode.ShellExecution(`tox -e ${toxTestenv}`)); + result.push(task); + const lowerCaseLine = line.toLowerCase(); + if (isBuildTask(lowerCaseLine)) { + task.group = vscode.TaskGroup.Build; + } else if (isTestTask(lowerCaseLine)) { + task.group = vscode.TaskGroup.Test; + } + } + } + } catch (err: any) { + const channel = getOutputChannel(); + if (err.stderr) { + channel.appendLine(err.stderr); + } + if (err.stdout) { + channel.appendLine(err.stdout); + } + channel.appendLine('Auto detecting tox testenvs failed.'); + channel.show(true); + } + } + return result; +} \ No newline at end of file