Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Write Your Python Program - CHANGELOG

* unreleased
* RUN button: a program that is still running (and all processes it started)
is terminated before the new run starts. Restarting now also works with
pending input in the interpreter, a program in an endless loop or a program
waiting for `input()`
* RUN button: disables AI features (GitHub Copilot) in the workspace settings
(`chat.disableAIFeatures`)
* Requires VS Code 1.104 or newer
* 2.4.0 (2026-09-28)
* Greatly improved visualizatioin
* Python 3.14: forward references in records and functions work without
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The ideas of this environment are based on the great ideas from
## Quick start

* Step 1. Install **Python 3.12.x** or **Python 3.13.x** or **Python 3.14.x**.
* Step 2. Install Visual Studio Code, at least version Version: 1.94.2
* Step 2. Install Visual Studio Code, at least version 1.104
* Step 3. Install the **write-your-python-program** extension for Visual Studio Code.
* Step 4. Open a folder in Visual Studio Code.
* Step 5. Open or create a Python file inside this folder. The "RUN" button in the taskbar at the bottom will
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"publisher": "StefanWehr",
"icon": "icon.png",
"engines": {
"vscode": "^1.94.2"
"vscode": "^1.104.0"
},
"keywords": [
"Python",
Expand Down Expand Up @@ -104,7 +104,7 @@
"@types/mocha": "^8.2.3",
"@types/node": "^18.19.130",
"@types/tmp": "^0.2.6",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.104.0",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.30.0",
"@vscode/test-electron": "^1.6.1",
Expand Down
2 changes: 2 additions & 0 deletions python/code/wypp/cmdlineArgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ def parseCmdlineArgs(argList):
help='Do not check type annotations')
parser.add_argument('--repl', action='extend', type=str, nargs='+', default=[], dest='repls',
help='Run repl tests in the file given')
parser.add_argument('--pid-file', dest='pidFile', type=str,
help='Write the process ID to this file while running')
parser.add_argument('file', metavar='FILE',
help='The file to run', nargs='?')
if argList is None:
Expand Down
36 changes: 36 additions & 0 deletions python/code/wypp/pidFile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import atexit
import json
import os

# The PID file allows the VS Code extension to terminate a still-running program
# (and all processes it started) before it starts a new one in the same terminal.
# The file stays open while the program runs: on Windows, an open file cannot be
# deleted, so a failing delete tells the extension that the program is still alive.

_openPidFile = None

def writePidFile(path: str):
global _openPidFile
if _openPidFile is not None:
raise Exception('pid file already created')
pgid = os.getpgid(0) if hasattr(os, 'getpgid') else None
f = open(path, 'w', encoding='utf-8')
json.dump({'pid': os.getpid(), 'pgid': pgid}, f)
f.flush()
_openPidFile = f
atexit.register(removePidFile)

def removePidFile():
global _openPidFile
f = _openPidFile
if f is None:
return
_openPidFile = None
try:
f.close()
except OSError:
pass
try:
os.remove(f.name)
except OSError:
pass
3 changes: 3 additions & 0 deletions python/code/wypp/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def pythonVersionOk(v):
from . import interactive
from .myLogging import *
from . import paths
from . import pidFile
from . import replTester
from . import runCode
from . import version as versionMod
Expand All @@ -47,6 +48,8 @@ def printWelcomeString(file, version, doTypecheck):

def main(globals, argList=None):
(args, restArgs) = cmdlineArgs.parseCmdlineArgs(argList)
if args.pidFile:
pidFile.writePidFile(args.pidFile)
if args.verbose:
enableVerbose()
if args.debug:
Expand Down
87 changes: 87 additions & 0 deletions python/tests/test_pidFile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import json
import os
import sys
import tempfile
import unittest
from unittest import mock

import pidFile

class TestPidFile(unittest.TestCase):

def setUp(self):
self.tmpDir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmpDir.name, 'wypp.pid')
# writePidFile registers removePidFile with atexit; don't do this in the test process
patcher = mock.patch('pidFile.atexit.register')
self.atexitRegister = patcher.start()
self.addCleanup(patcher.stop)

def tearDown(self):
pidFile.removePidFile()
self.tmpDir.cleanup()

def readPidFile(self):
with open(self.path, encoding='utf-8') as f:
return json.load(f)

def test_writeWritesPidAndPgid(self):
pidFile.writePidFile(self.path)
expectedPgid = os.getpgid(0) if hasattr(os, 'getpgid') else None
self.assertEqual(self.readPidFile(), {'pid': os.getpid(), 'pgid': expectedPgid})

def test_fileStaysOpenAfterWrite(self):
pidFile.writePidFile(self.path)
self.assertIsNotNone(pidFile._openPidFile)
self.assertFalse(pidFile._openPidFile.closed)

def test_writeRegistersRemoveWithAtexit(self):
pidFile.writePidFile(self.path)
self.atexitRegister.assert_called_once_with(pidFile.removePidFile)

def test_writeTwiceRaises(self):
pidFile.writePidFile(self.path)
with self.assertRaises(Exception):
pidFile.writePidFile(os.path.join(self.tmpDir.name, 'other.pid'))

def test_removeClosesAndDeletes(self):
pidFile.writePidFile(self.path)
f = pidFile._openPidFile
pidFile.removePidFile()
self.assertTrue(f.closed)
self.assertFalse(os.path.exists(self.path))
self.assertIsNone(pidFile._openPidFile)

def test_writeAgainAfterRemove(self):
pidFile.writePidFile(self.path)
pidFile.removePidFile()
pidFile.writePidFile(self.path)
self.assertEqual(self.readPidFile()['pid'], os.getpid())

def test_removeWithoutWriteIsNoop(self):
pidFile.removePidFile()
self.assertIsNone(pidFile._openPidFile)

def test_removeTwiceIsHarmless(self):
pidFile.writePidFile(self.path)
pidFile.removePidFile()
pidFile.removePidFile()
self.assertFalse(os.path.exists(self.path))

def test_removeAfterFileDeletedByOthers(self):
# e.g. the extension deleted a PID file it considered stale (possible on Unix)
pidFile.writePidFile(self.path)
f = pidFile._openPidFile
os.remove(self.path)
pidFile.removePidFile()
self.assertTrue(f.closed)
self.assertIsNone(pidFile._openPidFile)

@unittest.skipUnless(sys.platform == 'win32', 'Windows only')
def test_openFileCannotBeDeletedOnWindows(self):
# The extension relies on this to detect that the program is still running.
pidFile.writePidFile(self.path)
with self.assertRaises(PermissionError):
os.remove(self.path)
pidFile.removePidFile()
self.assertFalse(os.path.exists(self.path))
34 changes: 26 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import * as vscode from 'vscode';
import * as process from 'process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Uri } from 'vscode';

import { getProgFlowVizCallback } from './programflow-visualization/main';
import { initTraceCache } from './programflow-visualization/trace_cache';
import { killPreviousRun } from './killPreviousRun';

const extensionId = 'write-your-python-program';
const python3ConfigKey = 'python3Cmd';
Expand Down Expand Up @@ -43,18 +45,23 @@ function showButtons() {
});
}

// The python runner writes its process ID to this file (option --pid-file)
function pidFileFor(cmdId: string): string {
return path.join(os.tmpdir(), `wypp-${process.pid}-${cmdId}.pid`);
}

async function startTerminal(
existing: vscode.Terminal | undefined, name: string, cmd: string, outChannel: vscode.OutputChannel
existing: vscode.Terminal | undefined, name: string, cmd: string, pidFile: string,
outChannel: vscode.OutputChannel
): Promise<vscode.Terminal> {
// Terminate a program from a previous run that is still running (including all
// processes it started). This also allows us to re-use the existing terminal.
await killPreviousRun(pidFile, (s) => outChannel.appendLine(s));
let terminal: vscode.Terminal | undefined;
if (existing) {
// We try to re-use the existing terminal. But we need to terminate a potentially
// running python process first. If there is no python process, the shell
// complains about the "import sys; sys.exit(0)" command, but we do not care.
if (!existing.exitStatus) {
outChannel.appendLine("Reusing existing terminal");
terminal = existing;
terminal.sendText("import sys; sys.exit(0)");
} else {
outChannel.appendLine("Disposing existing terminal because it's already terminated");
existing.dispose();
Expand Down Expand Up @@ -251,7 +258,7 @@ async function fixPylanceConfig(
folder?: vscode.WorkspaceFolder
) {
// disable warnings about wildcard imports, set pylance's extraPaths to wypp
// turn typechecking off
// turn typechecking off, disable AI features
// This is a quite distructive change, so we do it on first hit of the run button
// not on-load of the plugin
const subDir = 'python/code';
Expand All @@ -267,7 +274,7 @@ async function fixPylanceConfig(
const noWorkspaceOpen = '__NO_WORKSPACE_OPEN__';

// helper to update config and collect errors; treat "no folder open" specially
async function tryUpdate(key: string, value: any) {
async function tryUpdate(key: string, value: any, config: vscode.WorkspaceConfiguration = cfg) {
// If target is workspace-wide and there is no workspace open at all
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
Expand All @@ -277,7 +284,7 @@ async function fixPylanceConfig(
}

try {
await cfg.update(key, value, target);
await config.update(key, value, target);
} catch (e) {
// If the error message explicitly mentions the workspace/folder, capture it, but prefer the proactive checks above
const msg = e instanceof Error ? e.message : String(e);
Expand Down Expand Up @@ -319,6 +326,13 @@ async function fixPylanceConfig(
await tryUpdate(keyMode, 'off');
}

// disable AI features (GitHub Copilot chat, inline suggestions, ...)
const chatCfg = vscode.workspace.getConfiguration('chat', folder?.uri);
const keyDisableAI = 'disableAIFeatures';
if (chatCfg.get<boolean>(keyDisableAI) !== true) {
await tryUpdate(keyDisableAI, true, chatCfg);
}

if (errors.length > 0) {
let msg: string;
if (errors.every(e => e === noWorkspaceOpen)) {
Expand Down Expand Up @@ -498,6 +512,7 @@ export async function activate(context: vscode.ExtensionContext) {
for (const [key, termContext] of Object.entries(terminals)) {
if (termContext.terminal === t) {
delete terminals[key];
fs.rm(pidFileFor(key), { force: true }, () => {});
console.log(`Terminal closed and removed from map: ${t.name} (key: ${key})`);
break;
}
Expand Down Expand Up @@ -548,14 +563,17 @@ export async function activate(context: vscode.ExtensionContext) {
const disableOpt = disableTypechecking(context) ? " --no-typechecking" : "";
if (pyCmd.kind !== "error") {
const pythonCmd = commandListToArgument(pyCmd.cmd);
const pidFile = pidFileFor(cmdId);
const cmdTerm = await startTerminal(
terminals[cmdId]?.terminal,
"WYPP - RUN",
pythonCmd + " " + fileToCommandArgument(runProg) + verboseOpt +
disableOpt + langOpt +
" --interactive " +
" --pid-file " + fileToCommandArgument(pidFile) +
" --change-directory " +
fileToCommandArgument(file),
pidFile,
outChannel
);
terminals[cmdId] = {terminal: cmdTerm, directory: path.dirname(file)};
Expand Down
Loading
Loading