diff --git a/ChangeLog.md b/ChangeLog.md index a927244..ffa810b 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/README.md b/README.md index a31a1de..1939b7a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 5392f9f..5b33904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "write-your-python-program", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "write-your-python-program", - "version": "2.3.0", + "version": "2.4.0", "license": "See license in LICENSE", "dependencies": { "elkjs": "^0.12.0", @@ -18,7 +18,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", @@ -29,7 +29,7 @@ "typescript": "^5.4.2" }, "engines": { - "vscode": "^1.94.2" + "vscode": "^1.104.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -752,9 +752,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.93.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.93.0.tgz", - "integrity": "sha512-kUK6jAHSR5zY8ps42xuW89NLcBpw1kOabah7yv38J8MyiYuOHxLQBi0e7zeXbQgVefDy/mZZetqEFC+Fl5eIEQ==", + "version": "1.104.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.104.0.tgz", + "integrity": "sha512-0KwoU2rZ2ecsTGFxo4K1+f+AErRsYW0fsp6A0zufzGuhyczc2IoKqYqcwXidKXmy2u8YB2GsYsOtiI9Izx3Tig==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index a20f609..1e0c19e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publisher": "StefanWehr", "icon": "icon.png", "engines": { - "vscode": "^1.94.2" + "vscode": "^1.104.0" }, "keywords": [ "Python", @@ -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", diff --git a/python/code/wypp/cmdlineArgs.py b/python/code/wypp/cmdlineArgs.py index 2d4e4de..7db92a9 100644 --- a/python/code/wypp/cmdlineArgs.py +++ b/python/code/wypp/cmdlineArgs.py @@ -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: diff --git a/python/code/wypp/pidFile.py b/python/code/wypp/pidFile.py new file mode 100644 index 0000000..6259743 --- /dev/null +++ b/python/code/wypp/pidFile.py @@ -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 diff --git a/python/code/wypp/runner.py b/python/code/wypp/runner.py index 4664c11..ff3c9fd 100644 --- a/python/code/wypp/runner.py +++ b/python/code/wypp/runner.py @@ -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 @@ -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: diff --git a/python/tests/test_pidFile.py b/python/tests/test_pidFile.py new file mode 100644 index 0000000..57a07c3 --- /dev/null +++ b/python/tests/test_pidFile.py @@ -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)) diff --git a/src/extension.ts b/src/extension.ts index 5721aa3..a1220db 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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'; @@ -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 { + // 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(); @@ -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'; @@ -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) { @@ -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); @@ -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(keyDisableAI) !== true) { + await tryUpdate(keyDisableAI, true, chatCfg); + } + if (errors.length > 0) { let msg: string; if (errors.every(e => e === noWorkspaceOpen)) { @@ -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; } @@ -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)}; diff --git a/src/killPreviousRun.ts b/src/killPreviousRun.ts new file mode 100644 index 0000000..7e59442 --- /dev/null +++ b/src/killPreviousRun.ts @@ -0,0 +1,135 @@ +// Terminates a program started by the RUN button that is still running, together +// with all processes it started. The python runner writes its process ID (and on +// Unix its process group) to a PID file and keeps the file open until it exits +// (see python/code/wypp/pidFile.py). + +import { execFile } from 'child_process'; +import * as fs from 'fs'; + +const isWindows = process.platform === 'win32'; + +interface PidFileContent { + pid: number, + pgid: number | null +} + +function run(cmd: string, args: string[]): Promise<{ ok: boolean, stdout: string }> { + return new Promise((resolve) => { + execFile(cmd, args, { windowsHide: true }, (err, stdout) => { + resolve({ ok: !err, stdout: stdout ?? '' }); + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM: the process exists but belongs to someone else + return (e as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function tryUnlink(file: string): NodeJS.ErrnoException | undefined { + try { + fs.unlinkSync(file); + return undefined; + } catch (e) { + return e as NodeJS.ErrnoException; + } +} + +function readPidFile(pidFile: string): PidFileContent | undefined { + let content: string; + try { + content = fs.readFileSync(pidFile, 'utf-8'); + } catch (e) { + return undefined; + } + try { + const json = JSON.parse(content); + if (typeof json.pid === 'number') { + return { pid: json.pid, pgid: typeof json.pgid === 'number' ? json.pgid : null }; + } + } catch (e) { + // fall through + } + return undefined; +} + +async function killOnWindows(pidFile: string, info: PidFileContent, log: (s: string) => void) { + // The runner keeps the PID file open, and an open file cannot be deleted on Windows. + // If deleting succeeds, the runner is gone. + const err = tryUnlink(pidFile); + if (!err || err.code === 'ENOENT') { + log('PID file was stale, no program to terminate'); + return; + } + log(`Terminating process ${info.pid} and its children`); + // /T: also kill all child processes, /F: force + const res = await run('taskkill', ['/PID', String(info.pid), '/T', '/F']); + if (!res.ok) { + log(`taskkill failed for process ${info.pid}`); + } + // The handle of the PID file is released shortly after the process has died. + for (let i = 0; i < 20; i++) { + const e = tryUnlink(pidFile); + if (!e || e.code === 'ENOENT') { + return; + } + await sleep(50); + } +} + +async function killOnUnix(pidFile: string, info: PidFileContent, log: (s: string) => void) { + // Make sure the PID still belongs to our runner and has not been reused by another process. + // The command line of the runner contains the path of the PID file. + const res = await run('ps', ['-ww', '-o', 'command=', '-p', String(info.pid)]); + const cmdLine = res.stdout.trim(); + if (!res.ok || !cmdLine.includes('runYourProgram.py') || !cmdLine.includes(pidFile)) { + log('PID file was stale, no program to terminate'); + tryUnlink(pidFile); + return; + } + // A shell with job control starts each command in its own process group, so killing + // the group also kills all processes started by the program. We only kill the group if + // the runner is its leader, to make sure we never kill the shell itself. + const target = info.pgid === info.pid ? -info.pid : info.pid; + const what = target < 0 ? `process group ${info.pid}` : `process ${info.pid}`; + log(`Terminating ${what}`); + try { + process.kill(target, 'SIGTERM'); + } catch (e) { + log(`Sending SIGTERM to ${what} failed: ${e}`); + } + for (let i = 0; i < 20 && isAlive(info.pid); i++) { + await sleep(50); + } + if (isAlive(info.pid)) { + log(`Process ${info.pid} still alive, sending SIGKILL to ${what}`); + try { + process.kill(target, 'SIGKILL'); + } catch (e) { + log(`Sending SIGKILL to ${what} failed: ${e}`); + } + } + tryUnlink(pidFile); +} + +export async function killPreviousRun(pidFile: string, log: (s: string) => void): Promise { + const info = readPidFile(pidFile); + if (!info) { + tryUnlink(pidFile); + return; + } + if (isWindows) { + await killOnWindows(pidFile, info, log); + } else { + await killOnUnix(pidFile, info, log); + } +} diff --git a/src/test/unit/killPreviousRun.test.ts b/src/test/unit/killPreviousRun.test.ts new file mode 100644 index 0000000..3b3d95d --- /dev/null +++ b/src/test/unit/killPreviousRun.test.ts @@ -0,0 +1,145 @@ +import * as assert from 'assert'; +import { ChildProcess, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { killPreviousRun } from '../../killPreviousRun'; + +const repoRoot = path.join(__dirname, '..', '..', '..'); +const runProg = path.join(repoRoot, 'python', 'code', 'wypp', 'runYourProgram.py'); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return false; + } +} + +async function waitFor(cond: () => boolean, what: string, timeoutMs = 10000) { + const end = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > end) { + throw new Error(`Timeout waiting for ${what}`); + } + await sleep(50); + } +} + +function exited(proc: ChildProcess): Promise { + return new Promise((resolve) => { + if (proc.exitCode !== null || proc.signalCode !== null) { + resolve(); + } else { + proc.on('exit', () => resolve()); + } + }); +} + +suite('killPreviousRun', function () { + // The tests start python processes; killing processes works differently on Windows. + if (process.platform === 'win32') { + return; + } + this.timeout(20000); + + let dir: string; + let pidFile: string; + const procs: ChildProcess[] = []; + const log = (_s: string) => {}; + + setup(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wypp-kill-test-')); + pidFile = path.join(dir, 'wypp.pid'); + }); + + teardown(() => { + for (const p of procs) { + try { process.kill(-p.pid!, 'SIGKILL'); } catch (e) { /* already dead */ } + } + procs.length = 0; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + // Starts the runner like the RUN button does. detached: the runner gets its own + // process group, as it does when started by a shell with job control. + // The student program starts a child process and then runs forever. + async function startRunner(extraCode = ''): Promise<{ runner: ChildProcess, childPid: number }> { + const childPidFile = path.join(dir, 'child.pid'); + const prog = path.join(dir, 'prog.py'); + fs.writeFileSync(prog, [ + 'import subprocess, time', + extraCode, + `p = subprocess.Popen(['sleep', '60'])`, + `open(${JSON.stringify(childPidFile)}, 'w').write(str(p.pid))`, + 'while True:', + ' time.sleep(0.1)', + '' + ].join('\n')); + const runner = spawn('python3', + [runProg, '--quiet', '--no-clear', '--interactive', '--pid-file', pidFile, prog], + { detached: true, stdio: 'ignore' }); + procs.push(runner); + await waitFor(() => fs.existsSync(childPidFile) && fs.readFileSync(childPidFile, 'utf-8') !== '', + 'child process to start'); + const childPid = parseInt(fs.readFileSync(childPidFile, 'utf-8')); + return { runner, childPid }; + } + + test('does nothing if there is no PID file', async () => { + await killPreviousRun(pidFile, log); + assert.ok(!fs.existsSync(pidFile)); + }); + + test('kills the runner and the processes it started', async () => { + const { runner, childPid } = await startRunner(); + assert.ok(fs.existsSync(pidFile)); + assert.ok(isAlive(childPid)); + await killPreviousRun(pidFile, log); + await exited(runner); + await waitFor(() => !isAlive(childPid), 'child process to terminate'); + assert.ok(!fs.existsSync(pidFile)); + }); + + test('uses SIGKILL if the runner ignores SIGTERM', async () => { + const { runner, childPid } = await startRunner( + 'import signal; signal.signal(signal.SIGTERM, signal.SIG_IGN)'); + await killPreviousRun(pidFile, log); + await exited(runner); + assert.strictEqual(runner.signalCode, 'SIGKILL'); + await waitFor(() => !isAlive(childPid), 'child process to terminate'); + }); + + test('removes a PID file of a process that has already exited', async () => { + const { runner } = await startRunner(); + const content = fs.readFileSync(pidFile, 'utf-8'); + process.kill(-runner.pid!, 'SIGKILL'); + await exited(runner); + fs.writeFileSync(pidFile, content); // SIGKILL leaves the PID file behind + await killPreviousRun(pidFile, log); + assert.ok(!fs.existsSync(pidFile)); + }); + + test('does not kill a process that is not the runner', async () => { + // e.g. the PID has been reused by another process + const other = spawn('sleep', ['60'], { detached: true, stdio: 'ignore' }); + procs.push(other); + fs.writeFileSync(pidFile, JSON.stringify({ pid: other.pid, pgid: other.pid })); + await killPreviousRun(pidFile, log); + await sleep(200); + assert.ok(isAlive(other.pid!)); + assert.ok(!fs.existsSync(pidFile)); + }); + + test('ignores a garbled PID file', async () => { + fs.writeFileSync(pidFile, 'garbage'); + await killPreviousRun(pidFile, log); + assert.ok(!fs.existsSync(pidFile)); + }); +}); diff --git a/test_version.py b/test_version.py new file mode 100644 index 0000000..17bbb16 --- /dev/null +++ b/test_version.py @@ -0,0 +1,37 @@ +import json +import os +import tempfile +import unittest +import wypp.version as version + +# package.json lives in the root directory of the repository +BASE_DIR = os.path.join(os.path.dirname(__file__), '..', '..') + +def writePackageJson(d: str, v: str): + with open(os.path.join(d, 'package.json'), 'w', encoding='utf-8') as f: + json.dump({'name': 'write-your-python-program', 'version': v}, f) + +class TestVersion(unittest.TestCase): + + def test_readVersionFromRepo(self): + with open(os.path.join(BASE_DIR, 'package.json'), encoding='utf-8') as f: + expected = json.load(f)['version'] + if os.path.exists(os.path.join(BASE_DIR, '.git')): + expected = expected + '-git' + self.assertEqual(version.readVersion(), expected) + + def test_readVersionWithoutGit(self): + with tempfile.TemporaryDirectory() as d: + writePackageJson(d, '1.2.3') + self.assertEqual(version.readVersion(d), '1.2.3') + + def test_readVersionWithGit(self): + with tempfile.TemporaryDirectory() as d: + writePackageJson(d, '1.2.3') + os.mkdir(os.path.join(d, '.git')) + self.assertEqual(version.readVersion(d), '1.2.3-git') + + def test_readVersionWithoutPackageJson(self): + with tempfile.TemporaryDirectory() as d: + os.mkdir(os.path.join(d, '.git')) + self.assertIsNone(version.readVersion(d))