From dc9ee311dfea0e1c8ad97879b53ebb93566e51d8 Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Mon, 8 Dec 2025 11:27:37 +0100 Subject: [PATCH 1/5] fix bug with filenames of stdlib modules --- python/code/wypp/instrument.py | 15 +++++++++++- python/code/wypp/runCode.py | 20 ++++++++++++++- python/code/wypp/runner.py | 11 +++++++-- python/file-test-data/basics/async2_ok.py | 24 ++++++++++++++++++ python/file-test-data/basics/copy.out | 1 + python/file-test-data/basics/copy.py | 4 +++ python/file-test-data/basics/typing.err | 1 + python/fileTests.py | 9 +++++++ python/fileTestsLib.py | 30 ++++++++++++++++++----- 9 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 python/file-test-data/basics/async2_ok.py create mode 100644 python/file-test-data/basics/copy.out create mode 100644 python/file-test-data/basics/copy.py create mode 100644 python/file-test-data/basics/typing.err diff --git a/python/code/wypp/instrument.py b/python/code/wypp/instrument.py index 4015e83..2b7a8c0 100644 --- a/python/code/wypp/instrument.py +++ b/python/code/wypp/instrument.py @@ -140,12 +140,15 @@ def find_spec( target: types.ModuleType | None = None, ) -> ModuleSpec | None: + debug(f'Consulting InstrumentingFinder.find_spec for fullname={fullname}') # 1) The fullname is the name of the main module. This might be a dotted name such as x.y.z.py # so we have special logic here fp = os.path.join(self.modDir, f"{fullname}.py") if self.mainModName == fullname and os.path.isfile(fp): loader = InstrumentingLoader(fullname, fp) - return spec_from_file_location(fullname, fp, loader=loader) + spec = spec_from_file_location(fullname, fp, loader=loader) + debug(f'spec for {fullname}: {spec}') + return spec # 2) The fullname is a prefix of the main module. We want to load main modules with # dotted names such as x.y.z.py, hence we synthesize a namespace pkg # e.g. if 'x.y.z.py' exists and we're asked for 'x', return a package spec. @@ -153,6 +156,7 @@ def find_spec( spec = importlib.machinery.ModuleSpec(fullname, loader=None, is_package=True) # Namespace package marker (PEP 451) spec.submodule_search_locations = [] + debug(f'spec for {fullname}: {spec}') return spec # 3) Fallback: use the original PathFinder spec = self._origFinder.find_spec(fullname, path, target) @@ -191,10 +195,19 @@ def setupFinder(modDir: str, modName: str, extraDirs: list[str], typechecking: b # Create and install our custom finder instrumenting_finder = InstrumentingFinder(finder, modDir, modName, extraDirs) sys.meta_path.insert(0, instrumenting_finder) + debug(f'Installed instrument finder {instrumenting_finder}') + + alreadyLoaded = sys.modules.get(modName) + if alreadyLoaded: + sys.modules.pop(modName, None) + importlib.invalidate_caches() try: yield finally: + if alreadyLoaded: + sys.modules[modName] = alreadyLoaded + # Remove our custom finder when exiting the context if instrumenting_finder in sys.meta_path: sys.meta_path.remove(instrumenting_finder) diff --git a/python/code/wypp/runCode.py b/python/code/wypp/runCode.py index bee6a9e..69ad622 100644 --- a/python/code/wypp/runCode.py +++ b/python/code/wypp/runCode.py @@ -34,7 +34,7 @@ def __init__(self, mod, properlyImported): @dataclass class RunSetup: def __init__(self, pathDir: str, args: list[str]): - self.pathDir = pathDir + self.pathDir = os.path.abspath(pathDir) self.args = args self.sysPathInserted = False self.oldArgs = sys.argv @@ -64,12 +64,30 @@ def prepareLib(onlyCheckRunnable, enableTypeChecking): quiet=onlyCheckRunnable) return libDefs +def debugModule(name): + if name in sys.modules: + m = sys.modules["copy"] + print(f"Module {name} already loaded from:", getattr(m, "__file__", None)) + print("CWD:", os.getcwd()) + print("sys.path[0]:", sys.path[0]) + print("First few sys.path entries:") + for p in sys.path[:5]: + print(" ", p) + + spec = importlib.util.find_spec(name) + print("Resolved spec:", spec) + if spec: + print("Origin:", spec.origin) + print("Loader:", type(spec.loader).__name__) + def runCode(fileToRun, globals, doTypecheck=True, extraDirs=None) -> dict: if not extraDirs: extraDirs = [] modName = os.path.basename(os.path.splitext(fileToRun)[0]) with instrument.setupFinder(os.path.dirname(fileToRun), modName, extraDirs, doTypecheck): sys.dont_write_bytecode = True + if DEBUG: + debugModule(modName) res = runpy.run_module(modName, init_globals=globals, run_name='__wypp__', alter_sys=False) return res diff --git a/python/code/wypp/runner.py b/python/code/wypp/runner.py index 535b721..f42b3a4 100644 --- a/python/code/wypp/runner.py +++ b/python/code/wypp/runner.py @@ -66,9 +66,14 @@ def main(globals, argList=None): sys.exit(1) fileToRun: str = args.file + if not os.path.exists(fileToRun): + printStderr(f'File {fileToRun} does not exist') + sys.exit(1) if args.changeDir: - os.chdir(os.path.dirname(fileToRun)) + d = os.path.dirname(fileToRun) + os.chdir(d) fileToRun = os.path.basename(fileToRun) + debug(f'Changed directory to {d}, fileToRun={fileToRun}') isInteractive = args.interactive version = versionMod.readVersion() @@ -81,13 +86,15 @@ def main(globals, argList=None): libDefs = runCode.prepareLib(onlyCheckRunnable=args.checkRunnable, enableTypeChecking=args.checkTypes) - with (runCode.RunSetup(os.path.dirname(fileToRun), [fileToRun] + restArgs), + runDir = os.path.dirname(fileToRun) + with (runCode.RunSetup(runDir, [fileToRun] + restArgs), paths.projectDir(os.path.abspath(os.getcwd()))): globals['__name__'] = '__wypp__' sys.modules['__wypp__'] = sys.modules['__main__'] loadingFailed = False try: verbose(f'running code in {fileToRun}') + debug(f'sys.path: {sys.path}') globals['__file__'] = fileToRun globals = runCode.runStudentCode(fileToRun, globals, args.checkRunnable, doTypecheck=args.checkTypes, extraDirs=args.extraDirs) diff --git a/python/file-test-data/basics/async2_ok.py b/python/file-test-data/basics/async2_ok.py new file mode 100644 index 0000000..3550f36 --- /dev/null +++ b/python/file-test-data/basics/async2_ok.py @@ -0,0 +1,24 @@ +from typing import * +import types +import asyncio +from datetime import datetime + +def debug(msg): + print(msg) + +async def meaningOfLife(x: str) -> int: + debug(f'At start of meaningOfLife: {x}') + await asyncio.sleep(0.0001) + debug(f'At end of meaningOfLife: {x}') + return 42 + +async def run() -> None: + a = meaningOfLife('a') + debug('coroutine object') + n = await a + debug(n) + +def main(): + asyncio.run(run()) + +main() diff --git a/python/file-test-data/basics/copy.out b/python/file-test-data/basics/copy.out new file mode 100644 index 0000000..8d51334 --- /dev/null +++ b/python/file-test-data/basics/copy.out @@ -0,0 +1 @@ +Hello copy diff --git a/python/file-test-data/basics/copy.py b/python/file-test-data/basics/copy.py new file mode 100644 index 0000000..8433359 --- /dev/null +++ b/python/file-test-data/basics/copy.py @@ -0,0 +1,4 @@ +# WYPP_TEST_CONFIG: {"args": ["--change-directory"], "exitCode": 0} + +# This file intentionally has the same name as a file from the stdlib +print('Hello copy') diff --git a/python/file-test-data/basics/typing.err b/python/file-test-data/basics/typing.err new file mode 100644 index 0000000..4762690 --- /dev/null +++ b/python/file-test-data/basics/typing.err @@ -0,0 +1 @@ +File file-test-data/basics/typing.py does not exist diff --git a/python/fileTests.py b/python/fileTests.py index 2c778bb..5c3a1b0 100644 --- a/python/fileTests.py +++ b/python/fileTests.py @@ -1,6 +1,7 @@ from pathlib import Path from fileTestsLib import * import sys +import os def pythonMinVersion(major: int, minor: int) -> bool: return sys.version_info >= (major, minor) @@ -33,7 +34,15 @@ def main(): globalCtx.results.finish() +def extraChecks(): + # The localTyping file does not exist, running should thus fail + localTyping = 'file-test-data/basics/typing.py' + if os.path.exists(localTyping): + raise ValueError(f'File {localTyping} should not exist') + check(localTyping, exitCode=1) + try: main() + extraChecks() except KeyboardInterrupt: pass diff --git a/python/fileTestsLib.py b/python/fileTestsLib.py index 8de2d16..87e02e5 100644 --- a/python/fileTestsLib.py +++ b/python/fileTestsLib.py @@ -12,6 +12,12 @@ import re import fnmatch +DEBUG = False + +def debug(s): + if DEBUG: + print(f'[TESTDEBUG] {s}') + GLOBAL_CHECK_OUTPUTS = True GLOBAL_RECORD_ALL = False # Should be False, write actual output to all expected output files @@ -34,6 +40,7 @@ def parseArgs() -> TestOpts: ) # Define the command-line arguments + parser.add_argument("--debug", action="store_true", help="Output debug messages") parser.add_argument("--start-at", type=str, help="Start with test in FILE") parser.add_argument("--only", type=str, help="Run only the test in FILE") parser.add_argument("--continue", action="store_true", @@ -48,7 +55,9 @@ def parseArgs() -> TestOpts: nargs='*') # Parse the arguments args = parser.parse_args() - + if args.debug: + global DEBUG + DEBUG = True scriptDir = os.path.dirname(__file__) return TestOpts( cmd=f'{scriptDir}/code/wypp/runYourProgram.py', @@ -244,6 +253,7 @@ def _runTest(testFile: str, env = os.environ.copy() env['PYTHONPATH'] = os.pathsep.join([os.path.join(ctx.opts.baseDir, 'code')] + pythonPath) env['WYPP_UNDER_TEST'] = 'True' + debug(' '.join(cmd)) with open(actualStdoutFile, 'w') as stdoutFile, \ open(actualStderrFile, 'w') as stderrFile: # Run the command @@ -332,9 +342,10 @@ class WyppTestConfig: typecheck: Literal[True, False, "both"] args: list[str] pythonPath: Optional[str] + exitCode: Optional[int] @staticmethod def default() -> WyppTestConfig: - return WyppTestConfig(typecheck=True, args=[], pythonPath=None) + return WyppTestConfig(typecheck=True, args=[], pythonPath=None, exitCode=None) def readWyppTestConfig(path: str, *, max_lines: int = 5) -> WyppTestConfig: """ @@ -342,7 +353,9 @@ def readWyppTestConfig(path: str, *, max_lines: int = 5) -> WyppTestConfig: `max_lines` lines of the file at `path` and return it as a dict. Returns {} if not present. """ - validKeys = ['typecheck', 'args', 'pythonPath'] + validKeys = ['typecheck', 'args', 'pythonPath', 'exitCode'] + if not os.path.exists(path): + return WyppTestConfig.default() with open(path, "r", encoding="utf-8") as f: for lineno in range(1, max_lines + 1): line = f.readline() @@ -358,7 +371,10 @@ def readWyppTestConfig(path: str, *, max_lines: int = 5) -> WyppTestConfig: typecheck = j.get('typecheck', True) args = j.get('args', []) pythonPath = j.get('pythonPath') - return WyppTestConfig(typecheck=typecheck, args=args, pythonPath=pythonPath) + exitCode = j.get('exitCode') + cfg = WyppTestConfig(typecheck=typecheck, args=args, pythonPath=pythonPath, exitCode=exitCode) + debug(f'Config for {path}: {cfg}') + return cfg return WyppTestConfig.default() def checkNoConfig(testFile: str, @@ -370,8 +386,6 @@ def checkNoConfig(testFile: str, checkOutputs: bool = True, ctx: TestContext = globalCtx, what: str = ''): - if guessExitCode(testFile) == 0: - exitCode = 0 status = _check(testFile, exitCode, typecheck, args, pythonPath, minVersion, checkOutputs, ctx, what) ctx.results.storeTestResult(testFile, status) if status == 'failed': @@ -396,6 +410,10 @@ def check(testFile: str, pythonPath = [] if cfg.pythonPath: pythonPath = cfg.pythonPath.split(':') + if cfg.exitCode is not None: + exitCode = cfg.exitCode + elif guessExitCode(testFile) == 0: + exitCode = 0 if cfg.typecheck == 'both': checkNoConfig(testFile, exitCode, typecheck=True, args=args, pythonPath=pythonPath, minVersion=minVersion, checkOutputs=checkOutputs, From 22c45eb464af2802e69c51fbb9b60b1e0106bff0 Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Mon, 8 Dec 2025 13:46:02 +0100 Subject: [PATCH 2/5] fix test for async functions --- python/code/wypp/location.py | 11 +++++++++++ python/code/wypp/typecheck.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/python/code/wypp/location.py b/python/code/wypp/location.py index f372201..9b0a6f4 100644 --- a/python/code/wypp/location.py +++ b/python/code/wypp/location.py @@ -201,6 +201,10 @@ def getResultTypeLocation(self) -> Optional[Loc]: @abc.abstractmethod def getParamSourceLocation(self, paramName: str) -> Optional[Loc]: pass + @property + @abc.abstractmethod + def isAsync(self) -> bool: + pass class StdCallableInfo(CallableInfo): """ @@ -276,6 +280,10 @@ def getParamSourceLocation(self, paramName: str) -> Optional[Loc]: res.end_lineno, res.end_col_offset) + @property + def isAsync(self) -> bool: + node = self._findDef() + return isinstance(node, ast.AsyncFunctionDef) def classFilename(cls) -> str | None: """Best-effort path to the file that defined `cls`.""" @@ -315,6 +323,9 @@ def getParamSourceLocation(self, paramName: str) -> Optional[Loc]: return Loc(file, node.lineno, node.col_offset, node.end_lineno, node.end_col_offset) else: return None + @property + def isAsync(self) -> bool: + return False def locationOfArgument(fi: inspect.FrameInfo, idxOrName: int | str) -> Optional[Loc]: """ diff --git a/python/code/wypp/typecheck.py b/python/code/wypp/typecheck.py index bd59807..9cb90bf 100644 --- a/python/code/wypp/typecheck.py +++ b/python/code/wypp/typecheck.py @@ -126,6 +126,8 @@ def raiseArgMismatch(): def checkReturn(sig: inspect.Signature, returnFrame: Optional[inspect.FrameInfo], result: Any, info: location.CallableInfo, cfg: CheckCfg) -> None: + if info.isAsync: + return t = sig.return_annotation if isEmptyAnnotation(t): t = None From 8e68231814eeef5d740b9c4131663187050408be Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Mon, 8 Dec 2025 13:49:24 +0100 Subject: [PATCH 3/5] add missing file --- python/file-test-data/basics/async2_ok.out | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 python/file-test-data/basics/async2_ok.out diff --git a/python/file-test-data/basics/async2_ok.out b/python/file-test-data/basics/async2_ok.out new file mode 100644 index 0000000..c4cdf81 --- /dev/null +++ b/python/file-test-data/basics/async2_ok.out @@ -0,0 +1,4 @@ +coroutine object +At start of meaningOfLife: a +At end of meaningOfLife: a +42 From 661f146190bb05d85d671571345c8a34cac68f9c Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Mon, 8 Dec 2025 14:45:15 +0100 Subject: [PATCH 4/5] better debugging support --- python/code/wypp/exceptionHandler.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/code/wypp/exceptionHandler.py b/python/code/wypp/exceptionHandler.py index dc575c1..fc8221a 100644 --- a/python/code/wypp/exceptionHandler.py +++ b/python/code/wypp/exceptionHandler.py @@ -43,8 +43,9 @@ def handleCurrentException(exit=True, removeFirstTb=False, file=sys.stderr): frameList = (stacktrace.tbToFrameList(tb) if tb is not None else []) if frameList and removeFirstTb: frameList = frameList[1:] - isBug = not isWyppError and not isinstance(val, SyntaxError) and \ - len(frameList) > 0 and stacktrace.isWyppFrame(frameList[-1]) + isSyntaxError = isinstance(val, SyntaxError) + lastFrameIsWypp = len(frameList) > 0 and stacktrace.isWyppFrame(frameList[-1]) + isBug = not isWyppError and not isSyntaxError and lastFrameIsWypp stackSummary = stacktrace.limitTraceback(frameList, extra, not isBug and not isDebug()) header = False for x in stackSummary.format(): @@ -62,6 +63,7 @@ def handleCurrentException(exit=True, removeFirstTb=False, file=sys.stderr): for x in traceback.format_exception_only(etype, val): file.write(x) if isBug: + debug(f'isWyppError={isWyppError}, isSyntaxError={isSyntaxError}, lastFrameIsWypp={lastFrameIsWypp}') file.write(f'BUG: the error above is most likely a bug in WYPP!') if exit: utils.die(1) From 7e61a4ef403ed7d20a407c984043f80da9317320 Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Mon, 8 Dec 2025 14:45:28 +0100 Subject: [PATCH 5/5] install launch.json for debugging --- src/extension.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 0f69243..5721aa3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -335,6 +335,58 @@ async function fixPylanceConfig( } } +async function fixDebuggerConfig( + context: vscode.ExtensionContext, + folder?: vscode.WorkspaceFolder +) { + // Create a .vscode/launch.json for beginner debugging if one does not exist. + // Use the provided workspace folder if available, otherwise fall back to the first workspace folder. + const workspacePath = folder?.uri?.fsPath ?? (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]?.uri.fsPath); + if (!workspacePath) { + // error messages already shown in fixPylanceConfig + return; + } + + const vscodeDir = path.join(workspacePath, '.vscode'); + const launchFile = path.join(vscodeDir, 'launch.json'); + + if (fs.existsSync(launchFile)) { + // Do not overwrite existing launch.json + return; + } + + try { + if (!fs.existsSync(vscodeDir)) { + fs.mkdirSync(vscodeDir, { recursive: true }); + } + + const sep = path.delimiter; // ':' on POSIX, ';' on Windows + const d1 = context.asAbsolutePath('python/code'); + const d2 = context.asAbsolutePath('python/code/wypp'); + const envValue = `${d1}${sep}${d2}${sep}\${env:PYTHONPATH}`; + const envObj: any = {}; + envObj['PYTHONPATH'] = envValue; + + const launchJson = { + version: '0.2.0', + configurations: [ + { + name: 'WYPP Debugger', + type: 'debugpy', + request: 'launch', + program: '${file}', + console: 'integratedTerminal', + env: envObj + } + ] + }; + + fs.writeFileSync(launchFile, JSON.stringify(launchJson, null, 2), { encoding: 'utf8' }); + } catch (e) { + vscode.window.showWarningMessage('Write Your Python Program: failed to create launch.json: ' + String(e)); + } +} + class Location implements vscode.TerminalLink { constructor( public startIndex: number, @@ -476,6 +528,7 @@ export async function activate(context: vscode.ExtensionContext) { return; } await fixPylanceConfig(context); + await fixDebuggerConfig(context); await vscode.window.activeTextEditor?.document.save(); const pyCmd = getPythonCmd(pyExt); let verboseOpt = "";