Skip to content
Open
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,37 @@ This lightweight VS Code extension supercharges your Metaflow dev workflow:

The extension automatically:

* Validates that the active file contains a Metaflow `FlowSpec` class before running
* Detects `Parameter(...)` definitions and prompts you for values before execution
* Pre-fills default values and validates input types (int, float, bool, JSON)
* Detects the current function name (`def` or `async def`)
* Saves the file before running
* Executes the command in the file’s directory
* Reuses a shared terminal session

## Smart Parameter Prompts

When you run a flow (Ctrl + Alt + R), the extension parses your Python file for `Parameter(...)` definitions and shows an input prompt for each one. Default values are pre-filled, required parameters are enforced, and type validation is applied automatically.

The final command is built as `python flow.py run --param1=value1 --param2=value2` and sent to the terminal.

### Current Limitations

* Parameter detection uses static AST parsing — it does not import or execute your code.
* Dynamic parameters (created in loops or conditionals), renamed imports (e.g. `P = Parameter`), and deploy-time callable defaults are not detected.
* `IncludeFile` parameters are not yet supported.
* The extension uses `python` as the interpreter; custom virtualenv paths are not yet supported (see [#3](https://github.com/outerbounds/metaflow-dev-vscode/issues/3)).

You can always fall back to running flows manually via the terminal if the prompt does not detect your parameters.

## Running Tests

```bash
npm test
```

This runs all tests using Node's built-in test runner. Tests cover flow detection, parameter extraction, and command construction.

## Installation

1. **Clone or copy** this repository to a local folder
Expand Down
87 changes: 87 additions & 0 deletions buildRunCommand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/** Metaflow/Click long-option style: letters, digits, underscore, hyphen (not whitespace or '='). */
const CLI_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;

/**
* Single-quote for bash/zsh: prevents all expansion.
* Only ' needs escaping, done by ending the string, adding an escaped
* literal quote, and reopening: it's → 'it'\''s'
*/
function quoteForBash(value) {
return "'" + value.replace(/'/g, "'\\''" ) + "'";
}

/**
* Single-quote for PowerShell: prevents all expansion.
* Only ' needs escaping, done by doubling: it's → 'it''s'
*/
function quoteForPowershell(value) {
return "'" + value.replace(/'/g, "''" ) + "'";
}

/**
* Double-quote for cmd.exe: the only option since cmd has no single-quote
* string semantics. Escape " by doubling and % by doubling.
*/
function quoteForCmd(value) {
return '"' + value.replace(/"/g, '""').replace(/%/g, '%%') + '"';
}

/**
* Quote a string for safe use in a terminal command.
* Uses single quotes for bash/PowerShell (prevents all variable expansion)
* and double quotes for cmd.exe.
*
* @param {string} value - The raw string to quote.
* @param {'bash'|'powershell'|'cmd'} shell - Target shell type.
*/
function quoteForTerminal(value, shell) {
if (shell === 'powershell') return quoteForPowershell(value);
if (shell === 'cmd') return quoteForCmd(value);
return quoteForBash(value);
}

/**
* Detect shell type from a shell executable path.
* @param {string} shellPath - e.g. '/bin/bash' or 'C:\\...\\powershell.exe'
* @returns {'bash'|'powershell'|'cmd'}
*/
function detectShellType(shellPath) {
const lower = (shellPath || '').toLowerCase();
if (lower.includes('powershell') || lower.includes('pwsh')) return 'powershell';
if (lower.endsWith('cmd.exe') || lower.endsWith('cmd')) return 'cmd';
return 'bash';
}

/**
* Build a `python <file> run --flag=value ...` command string.
*
* @param {string} filePath - Absolute path to the flow file.
* @param {Array<{name: string, value: string}>} flagArgs - Parameter name/value pairs.
* @param {'bash'|'powershell'|'cmd'} shell - Target shell type.
* @returns {string} The shell command to execute.
*/
function buildRunCommand(filePath, flagArgs, shell) {
const parts = ['python', quoteForTerminal(filePath, shell), 'run'];

for (const arg of flagArgs) {
if (!CLI_NAME_RE.test(arg.name)) {
throw new Error(`Invalid parameter name: ${arg.name}`);
}
if (/[\r\n]/.test(arg.value)) {
throw new Error(`Parameter value for --${arg.name} contains newline characters.`);
}
parts.push(`--${arg.name}=${quoteForTerminal(arg.value, shell)}`);
}

return parts.join(' ');
}

module.exports = {
buildRunCommand,
quoteForTerminal,
quoteForBash,
quoteForPowershell,
quoteForCmd,
detectShellType,
CLI_NAME_RE,
};
36 changes: 36 additions & 0 deletions examples/hello_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
Example Metaflow flow for manual testing of the VS Code extension.

To test parameter prompts:
1. Open this file in VS Code with the extension installed
2. Press Ctrl+Alt+R to run the flow
3. You should see input prompts for 'greeting' and 'count'
pre-filled with their defaults
4. The final terminal command should look like:
python hello_flow.py run --greeting='...' --count='...'

To test spin:
1. Place your cursor inside the 'start' or 'end' method
2. Press Ctrl+Alt+S
"""

from metaflow import FlowSpec, Parameter, step


class HelloFlow(FlowSpec):
greeting = Parameter('greeting', default='hello world', type=str, help='Message to print')
count = Parameter('count', default=3, type=int, help='How many times to repeat')

@step
def start(self):
for i in range(self.count):
print(f"{i + 1}: {self.greeting}")
self.next(self.end)

@step
def end(self):
print("Done!")


if __name__ == '__main__':
HelloFlow()
109 changes: 75 additions & 34 deletions extension.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,100 @@
const vscode = require('vscode');
const path = require('path');
const { validateEditorForFlowCommands, reportFlowInspectFailure } = require('./flowDetection');
const { inspectFlowFile, promptForParameters } = require('./parameterPrompt');
const { buildRunCommand, quoteForTerminal, detectShellType } = require('./buildRunCommand');

let sharedTerminal = null;

/**
* Core function to detect the current Python function name and run a script.
*/
async function runPythonCommand(scriptName) {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
function getShellType() {
return detectShellType(vscode.env.shell);
}

const doc = editor.document;
await doc.save();
function getOrCreateTerminal() {
if (!sharedTerminal || sharedTerminal.exitStatus !== undefined) {
sharedTerminal = vscode.window.createTerminal('Metaflow Runner');
}
return sharedTerminal;
}

const cursorLine = editor.selection.active.line;
function sendToTerminal(fileDir, command) {
const shell = getShellType();
const terminal = getOrCreateTerminal();
terminal.show();
terminal.sendText(`cd ${quoteForTerminal(fileDir, shell)}`);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

On Windows when the integrated terminal shell is cmd.exe, cd will not switch drives (e.g. C: -> D:) unless /d is used. This can cause the subsequent python ... command to run in the wrong directory for flows located on a different drive. Consider emitting cd /d <dir> for shell === 'cmd' (or separately sending a <drive>: command before cd).

Suggested change
terminal.sendText(`cd ${quoteForTerminal(fileDir, shell)}`);
const cdCommand =
shell === 'cmd'
? `cd /d ${quoteForTerminal(fileDir, shell)}`
: `cd ${quoteForTerminal(fileDir, shell)}`;
terminal.sendText(cdCommand);

Copilot uses AI. Check for mistakes.
terminal.sendText(command);
}

// Find the nearest "def"/"async def" above cursor
let funcName = null;
for (let i = cursorLine; i >= 0; i--) {
/**
* Find the nearest enclosing Python function name above the cursor.
*/
function findEnclosingFunction(doc, line) {
for (let i = line; i >= 0; i--) {
const lineText = doc.lineAt(i).text.trim();
const match = lineText.match(/^(?:async\s+def|def)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/);
if (match) {
funcName = match[1];
break;
}
if (match) return match[1];
}
return null;
}

/**
* @param {import('vscode').TextDocument} doc
* @param {{ parameters: Array<Record<string, unknown>> }} inspectResult
*/
async function runFlow(doc, inspectResult) {
const filePath = doc.fileName;
const fileDir = path.dirname(filePath);
const shell = getShellType();

const params = inspectResult.parameters || [];
let flagArgs = [];

if (params.length > 0) {
const values = await promptForParameters(params);
if (values === null) return;
flagArgs = values;
}

try {
const command = buildRunCommand(filePath, flagArgs, shell);
sendToTerminal(fileDir, command);
} catch (err) {
console.error('Failed to build or run Metaflow command:', err);
vscode.window.showErrorMessage(
err instanceof Error ? err.message : 'Failed to run Metaflow flow.'
);
}
}

async function spinStep(doc, editor) {
const funcName = findEnclosingFunction(doc, editor.selection.active.line);
if (!funcName) {
vscode.window.showErrorMessage("No enclosing Python function found.");
vscode.window.showErrorMessage('No enclosing Python function found.');
return;
}

const filePath = doc.fileName;
const fileDir = path.dirname(filePath);
const shell = getShellType();
const command = `python ${quoteForTerminal(filePath, shell)} spin ${funcName}`;
sendToTerminal(fileDir, command);
}

let command = '';
if (scriptName == 'spin_func')
command = `python ${filePath} spin ${funcName}`;
else
command = `python ${filePath} run`;
async function runPythonCommand(scriptName) {
const editor = vscode.window.activeTextEditor;
const doc = validateEditorForFlowCommands(editor);
if (!doc) return;

// Reuse or create a single shared terminal
if (!sharedTerminal || sharedTerminal.exitStatus !== undefined) {
sharedTerminal = vscode.window.createTerminal('Metaflow Runner');
}
await doc.save();

sharedTerminal.show();
sharedTerminal.sendText(`cd "${fileDir}"`);
sharedTerminal.sendText(command);
const inspect = await inspectFlowFile(doc.fileName);
if (reportFlowInspectFailure(inspect)) return;

/*
vscode.window.showInformationMessage(
`${scriptName.toUpperCase()}: ${funcName} from ${path.basename(filePath)}`
);
*/
if (scriptName === 'spin_func') {
await spinStep(doc, editor);
} else {
await runFlow(doc, inspect);
}
}

function activate(context) {
Expand Down
64 changes: 64 additions & 0 deletions flowDetection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Pure predicate: whether AST inspection allows run/spin.
* @param {{ syntaxOk: boolean, hasFlowSpec: boolean }} inspectResult
*/
function flowInspectAllowsRun(inspectResult) {
return !!(inspectResult && inspectResult.syntaxOk && inspectResult.hasFlowSpec);
}

/**
* Validates the active editor: must exist and be a Python document.
* Shows an error message and returns null when validation fails.
* @returns {import('vscode').TextDocument | null}
*/
function validateEditorForFlowCommands(editor) {
const vscode = require('vscode');

if (!editor) {
vscode.window.showErrorMessage('No active editor.');
return null;
}

const doc = editor.document;
if (doc.languageId !== 'python') {
vscode.window.showErrorMessage('Active file is not a Python file.');
return null;
}

return doc;
}

/**
* Shows errors when inspect result blocks run/spin.
* @returns {boolean} true if run/spin should abort (failure reported), false if OK to continue.
* @param {{ syntaxOk: boolean, hasFlowSpec: boolean, error?: string | null }} inspectResult
*/
function reportFlowInspectFailure(inspectResult) {
const vscode = require('vscode');

if (flowInspectAllowsRun(inspectResult)) {
return false;
}

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

reportFlowInspectFailure can throw when called with null/undefined because it unconditionally reads inspectResult.syntaxOk/hasFlowSpec after flowInspectAllowsRun returns false. Since flowInspectAllowsRun explicitly accepts falsy inputs (and tests cover null), add an early guard like “if (!inspectResult) { showErrorMessage(...); return true; }” to keep the helper total-order safe.

Suggested change
if (!inspectResult) {
vscode.window.showErrorMessage(
'Flow inspection failed.'
);
return true;
}

Copilot uses AI. Check for mistakes.
if (!inspectResult.syntaxOk) {
vscode.window.showErrorMessage(
inspectResult.error || 'This file has a Python syntax error.'
);
return true;
}

if (!inspectResult.hasFlowSpec) {
vscode.window.showErrorMessage(
'No FlowSpec class found. This file does not appear to be a Metaflow flow.'
);
return true;
}

return true;
}

module.exports = {
flowInspectAllowsRun,
validateEditorForFlowCommands,
reportFlowInspectFailure,
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"url": "https://github.com/outerbounds/metaflow-dev-vscode"
},
"main": "./extension.js",
"scripts": {
"test": "node --test test/*.test.js"
},
"contributes": {
"commands": [
{
Expand Down
Loading
Loading