From aada52f310e44d1e2307a64090432e7a68b909cb Mon Sep 17 00:00:00 2001 From: Dariusz Parys Date: Thu, 7 Apr 2022 11:38:32 +0200 Subject: [PATCH 1/3] feat: add tox task provider Adding a tox task provider to allow selection of testenvs through the task run interface of vscode --- .gitignore | 3 + package.json | 17 ++++- src/extension.ts | 24 +++++-- src/toxTaskProvider.ts | 149 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 src/toxTaskProvider.ts 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..8041d13 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/toxTaskProvider.ts b/src/toxTaskProvider.ts new file mode 100644 index 0000000..1f1a20f --- /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'; +import { error } from 'console'; + +export class ToxTaskProvider implements vscode.TaskProvider { + static ToxType = 'tox'; + static 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 From 93bce06639a76179ae0f284a1969d056d7922fd6 Mon Sep 17 00:00:00 2001 From: Dariusz Parys Date: Wed, 13 Apr 2022 08:04:35 +0200 Subject: [PATCH 2/3] tests: add test for tox task provider - Included a test to invoke the ToxTaskProvider class - Camel cased constants to satisfy linter --- src/extension.ts | 2 +- src/test/suite/extension.test.ts | 9 +++++++++ src/toxTaskProvider.ts | 18 +++++++++--------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 8041d13..c1ee9b2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -96,7 +96,7 @@ async function openDocumentationCommand() { export function activate(context: vscode.ExtensionContext) { const workspaceTox = findProjectDir(); if (workspaceTox) { - toxTaskProvider = vscode.tasks.registerTaskProvider(ToxTaskProvider.ToxType, new ToxTaskProvider(workspaceTox)); + toxTaskProvider = vscode.tasks.registerTaskProvider(ToxTaskProvider.toxType, new ToxTaskProvider(workspaceTox)); } context.subscriptions.push( vscode.commands.registerCommand('python-tox.select', selectCommand), diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index fe6f2a9..857afd4 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -7,6 +7,7 @@ import * as extension from '../../extension'; import * as path from 'path'; import * as fs from 'fs'; import * as util from 'util'; +import * as tasks from '../../toxTaskProvider'; function getExampleDir(name: string) { const dir = path.join(__dirname, '..', '..', '..', 'src', 'test', 'examples', name); @@ -70,4 +71,12 @@ suite('Extension Test Suite', () => { await waitForMarker(tmpdir); assert.ok(fs.existsSync(marker)); }); + + test('getting tox tasks', async() => { + const dir = getExampleDir("allenvs"); + const toxTaskProvider = new tasks.ToxTaskProvider(dir); + toxTaskProvider.provideTasks()?.then( (toxTasks) => { + assert.deepEqual(toxTasks, ["one", "two", "three"]); + }); + }); }); diff --git a/src/toxTaskProvider.ts b/src/toxTaskProvider.ts index 1f1a20f..6abe3bd 100644 --- a/src/toxTaskProvider.ts +++ b/src/toxTaskProvider.ts @@ -5,12 +5,12 @@ import * as vscode from 'vscode'; import { error } from 'console'; export class ToxTaskProvider implements vscode.TaskProvider { - static ToxType = 'tox'; - static ToxIni = 'tox.ini'; + 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 pattern = path.join(workspaceRoot, ToxTaskProvider.toxIni); const fileWatcher = vscode.workspace.createFileSystemWatcher(pattern); fileWatcher.onDidChange(() => this.toxPromise = undefined); fileWatcher.onDidCreate(() => this.toxPromise = undefined); @@ -28,7 +28,7 @@ export class ToxTaskProvider implements vscode.TaskProvider { 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 new vscode.Task(definition, _task.scope ?? vscode.TaskScope.Workspace, definition.testenv, ToxTaskProvider.toxType, new vscode.ShellExecution(`tox -e ${definition.testenv}`)); } return undefined; } @@ -99,7 +99,7 @@ async function getToxTestenvs(): Promise { if (!folderString) { continue; } - const toxFile = path.join(folderString, ToxTaskProvider.ToxIni); + const toxFile = path.join(folderString, ToxTaskProvider.toxIni); if (!await exists(toxFile)) { continue; } @@ -117,13 +117,13 @@ async function getToxTestenvs(): Promise { if (line.length === 0) { continue; } - const toxTestenv = line + const toxTestenv = line; const kind: ToxTaskDefinition = { - type: ToxTaskProvider.ToxType, + type: ToxTaskProvider.toxType, testenv: toxTestenv - } + }; - const task = new vscode.Task(kind, workspaceFolder, toxTestenv, ToxTaskProvider.ToxType, new vscode.ShellExecution(`tox -e ${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)) { From 1f8609a1bc09b9f52c1eaf9069aae8615d338d63 Mon Sep 17 00:00:00 2001 From: Dariusz Parys Date: Thu, 14 Apr 2022 18:09:01 +0200 Subject: [PATCH 3/3] refactor: add new test suite - Refactored ToxTaskProvider tests into separate test suite - Moved getExampleDir into utils.ts it is used from both test suites - Fixed test with adding a workspace creation in order to retrieve tox tasks via the task provider --- src/test/suite/extension.test.ts | 27 +++++----------------- src/test/suite/toxTaskProvider.test.ts | 31 ++++++++++++++++++++++++++ src/test/suite/utils.ts | 9 ++++++++ src/toxTaskProvider.ts | 2 +- 4 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 src/test/suite/toxTaskProvider.test.ts create mode 100644 src/test/suite/utils.ts diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 857afd4..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,17 +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'; -import * as tasks from '../../toxTaskProvider'; - -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); @@ -45,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"); @@ -71,12 +64,4 @@ suite('Extension Test Suite', () => { await waitForMarker(tmpdir); assert.ok(fs.existsSync(marker)); }); - - test('getting tox tasks', async() => { - const dir = getExampleDir("allenvs"); - const toxTaskProvider = new tasks.ToxTaskProvider(dir); - toxTaskProvider.provideTasks()?.then( (toxTasks) => { - assert.deepEqual(toxTasks, ["one", "two", "three"]); - }); - }); }); 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 index 6abe3bd..a60caae 100644 --- a/src/toxTaskProvider.ts +++ b/src/toxTaskProvider.ts @@ -2,7 +2,6 @@ import * as path from 'path'; import * as fs from 'fs'; import * as cp from 'child_process'; import * as vscode from 'vscode'; -import { error } from 'console'; export class ToxTaskProvider implements vscode.TaskProvider { static readonly toxType = 'tox'; @@ -91,6 +90,7 @@ function isTestTask(name: string): boolean { async function getToxTestenvs(): Promise { const workspaceFolders = vscode.workspace.workspaceFolders; const result: vscode.Task[] = []; + if (!workspaceFolders || workspaceFolders.length === 0) { return result; }