From dc370bee834d9f2b9fc53391b3a2012487be51d1 Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Wed, 29 Oct 2025 10:46:51 +0100 Subject: [PATCH 1/6] improve interactive startup This fixes a scrolling bug that caused some lines to be hidden from the user --- python/code/wypp/ansi.py | 2 +- python/code/wypp/interactive.py | 12 ------------ python/code/wypp/runner.py | 10 +++++----- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/python/code/wypp/ansi.py b/python/code/wypp/ansi.py index a919d0c0..b2608371 100644 --- a/python/code/wypp/ansi.py +++ b/python/code/wypp/ansi.py @@ -23,7 +23,7 @@ def color(s, color): return color + s + RESET def green(s): - return color(s, GREEN) + return color(s, GREEN + BOLD) def red(s): return color(s, RED + BOLD) diff --git a/python/code/wypp/interactive.py b/python/code/wypp/interactive.py index 46fd3bfb..21123668 100644 --- a/python/code/wypp/interactive.py +++ b/python/code/wypp/interactive.py @@ -8,18 +8,6 @@ from constants import * from myLogging import * from exceptionHandler import handleCurrentException -import i18n - -def prepareInteractive(reset=True): - print() - if reset: - if os.name == 'nt': - # clear the terminal - os.system('cls') - else: - # On linux & mac use ANSI Sequence for this - print('\033[2J\033[H') - HISTORY_SIZE = 1000 diff --git a/python/code/wypp/runner.py b/python/code/wypp/runner.py index 7e85fe3b..535b721b 100644 --- a/python/code/wypp/runner.py +++ b/python/code/wypp/runner.py @@ -33,7 +33,7 @@ def pythonVersionOk(v): import runCode import exceptionHandler import cmdlineArgs - +import ansi def printWelcomeString(file, version, doTypecheck): cwd = os.getcwd() + "/" @@ -44,8 +44,10 @@ def printWelcomeString(file, version, doTypecheck): tycheck = '' if not doTypecheck: tycheck = ', no typechecking' - printStderr(i18n.tr('=== WELCOME to ') + '"Write Your Python Program" ' + - '(%sPython %s, %s%s) ===' % (versionStr, pythonVersion, file, tycheck)) + msg = i18n.tr('=== WELCOME to ') + '"Write Your Python Program" ' + \ + '(%sPython %s, %s%s) ===' % (versionStr, pythonVersion, file, tycheck) + fullMsg = (10 * '\n') + ansi.green(msg) + '\n' + printStderr(fullMsg) def main(globals, argList=None): (args, restArgs) = cmdlineArgs.parseCmdlineArgs(argList) @@ -70,8 +72,6 @@ def main(globals, argList=None): isInteractive = args.interactive version = versionMod.readVersion() - if isInteractive: - interactive.prepareInteractive(reset=not args.noClear) if fileToRun is None: return From 41a7bf4516b7ca97a1c5e9fcb9ad557daa0b079a Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Wed, 29 Oct 2025 10:47:03 +0100 Subject: [PATCH 2/6] reusue terminal if possible This is much faster than always creating a new terminal --- src/extension.ts | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index f06ec855..6dbaba05 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -46,20 +46,31 @@ function showButtons() { async function startTerminal( existing: vscode.Terminal | undefined, name: string, cmd: string ): Promise { + let terminal: vscode.Terminal | undefined; if (existing) { - existing.dispose(); + // 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 don't care. + if (!existing.exitStatus) { + terminal = existing; + terminal.sendText("import sys; sys.exit(0)"); + } else { + existing.dispose(); + } } - const terminalOptions: vscode.TerminalOptions = {name: name}; - if (isWindows) { - // We don't know which shell will be used by default. - // If PowerShell is the default, we need to prefix the command with "& ". - // Otherwise, the prefix is not allowed and results in a syntax error. - // -> Just force cmd.exe. - terminalOptions.shellPath = "cmd.exe"; + if (!terminal) { + const terminalOptions: vscode.TerminalOptions = {name: name}; + if (isWindows) { + // We don't know which shell will be used by default. + // If PowerShell is the default, we need to prefix the command with "& ". + // Otherwise, the prefix is not allowed and results in a syntax error. + // -> Just force cmd.exe. + terminalOptions.shellPath = "cmd.exe"; + } + terminal = vscode.window.createTerminal(terminalOptions); + // Sometimes the terminal takes some time to start up before it can start accepting input. + await new Promise((resolve) => setTimeout(resolve, 100)); } - const terminal = vscode.window.createTerminal(terminalOptions); - // Sometimes the terminal takes some time to start up before it can start accepting input. - await new Promise((resolve) => setTimeout(resolve, 100)); terminal.show(false); // focus the terminal terminal.sendText(cmd); return terminal; @@ -425,6 +436,16 @@ export async function activate(context: vscode.ExtensionContext) { disposables.push(outChannel); const terminals: { [name: string]: TerminalContext } = {}; + vscode.window.onDidCloseTerminal((t) => { + // Loop through terminals and delete the entry if it matches the closed terminal + for (const [key, termContext] of Object.entries(terminals)) { + if (termContext.terminal === t) { + delete terminals[key]; + console.log(`Terminal closed and removed from map: ${t.name} (key: ${key})`); + break; + } + } + }); installButton("Write Your Python Program", undefined); From a4ce643f77fbbb529f02e3766fd3b95388d0a00d Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Tue, 4 Nov 2025 18:20:08 +0100 Subject: [PATCH 3/6] do not access annotations too early --- python/code/wypp/records.py | 14 +------ .../file-test-data/basics/recursive2_ok.err | 0 .../file-test-data/basics/recursive2_ok.out | 1 + python/file-test-data/basics/recursive2_ok.py | 38 +++++++++++++++++++ python/file-test-data/basics/recursive_ok.err | 0 python/file-test-data/basics/recursive_ok.out | 1 + python/file-test-data/basics/recursive_ok.py | 37 ++++++++++++++++++ .../basics/recursive_old_fail.err | 6 +++ .../basics/recursive_old_fail.out | 0 .../basics/recursive_old_fail.py | 37 ++++++++++++++++++ python/fileTests.py | 36 ++++++++++++++---- python/fileTestsLib.py | 20 +++++++++- 12 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 python/file-test-data/basics/recursive2_ok.err create mode 100644 python/file-test-data/basics/recursive2_ok.out create mode 100644 python/file-test-data/basics/recursive2_ok.py create mode 100644 python/file-test-data/basics/recursive_ok.err create mode 100644 python/file-test-data/basics/recursive_ok.out create mode 100644 python/file-test-data/basics/recursive_ok.py create mode 100644 python/file-test-data/basics/recursive_old_fail.err create mode 100644 python/file-test-data/basics/recursive_old_fail.out create mode 100644 python/file-test-data/basics/recursive_old_fail.py diff --git a/python/code/wypp/records.py b/python/code/wypp/records.py index 21eb3122..c0cbfcb1 100644 --- a/python/code/wypp/records.py +++ b/python/code/wypp/records.py @@ -17,13 +17,6 @@ def init(enableTypeChecking=True): global _typeCheckingEnabled _typeCheckingEnabled = enableTypeChecking -def _collectDataClassAttributes(cls): - result = dict() - for c in cls.mro(): - if hasattr(c, '__kind') and c.__kind == 'record' and hasattr(c, '__annotations__'): - result = c.__annotations__ | result - return result - def _checkRecordAttr(cls: typing.Any, ns: myTypeguard.Namespaces, name: str, @@ -48,11 +41,8 @@ def _patchDataClass(cls, mutable: bool, ns: myTypeguard.Namespaces): fieldNames = [f.name for f in dataclasses.fields(cls)] setattr(cls, EQ_ATTRS_ATTR, fieldNames) - if hasattr(cls, '__annotations__'): - # add annotions for type checked constructor. - cls.__kind = 'record' - cls.__init__.__annotations__ = _collectDataClassAttributes(cls) - cls.__init__ = typecheck.wrapTypecheckRecordConstructor(cls, ns) + cls.__kind = 'record' + cls.__init__ = typecheck.wrapTypecheckRecordConstructor(cls, ns) if mutable: # prevent new fields being added diff --git a/python/file-test-data/basics/recursive2_ok.err b/python/file-test-data/basics/recursive2_ok.err new file mode 100644 index 00000000..e69de29b diff --git a/python/file-test-data/basics/recursive2_ok.out b/python/file-test-data/basics/recursive2_ok.out new file mode 100644 index 00000000..fb7f6489 --- /dev/null +++ b/python/file-test-data/basics/recursive2_ok.out @@ -0,0 +1 @@ +Kinzig diff --git a/python/file-test-data/basics/recursive2_ok.py b/python/file-test-data/basics/recursive2_ok.py new file mode 100644 index 00000000..08db2e08 --- /dev/null +++ b/python/file-test-data/basics/recursive2_ok.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from wypp import * + +# Ein Flussabschnitt ist entweder +# - ein Bach mit Namen und Quelle, oder +# - ein Zusammenfluss eines Haupt- und Nebenflussabschnitts an einem bestimmten Ort. + +@record +class Creek: + origin: str + name: str + +@record +class Confluence: + location: str + mainStem: RiverSection + tributary: RiverSection + +type RiverSection = Union[Creek, Confluence] + +kinzig1 = Creek('Loßburg', 'Kinzig') +gutach = Creek('Schönwald', 'Gutach') +kinzig2 = Confluence('Hausach', kinzig1, gutach) +schutter1 = Creek('Schweighausen', 'Schutter') +heidengraben = Creek('Lahr', 'Heidengraben') +schutter2 = Confluence('Lahr', schutter1, heidengraben) +kinzig3 = Confluence('Kehl', kinzig2, schutter2) + +# Name eines Flussabschnitts bestimmen +# Eingabe: den Flussabschnitt (Typ: RiverSection) +# Ergebnis: der Name (Typ: str) +def riverName(r: RiverSection) -> str: + if isinstance(r, Creek): + return r.name + elif isinstance(r, Confluence): + return riverName(r.mainStem) + +print(riverName(kinzig3)) diff --git a/python/file-test-data/basics/recursive_ok.err b/python/file-test-data/basics/recursive_ok.err new file mode 100644 index 00000000..e69de29b diff --git a/python/file-test-data/basics/recursive_ok.out b/python/file-test-data/basics/recursive_ok.out new file mode 100644 index 00000000..fb7f6489 --- /dev/null +++ b/python/file-test-data/basics/recursive_ok.out @@ -0,0 +1 @@ +Kinzig diff --git a/python/file-test-data/basics/recursive_ok.py b/python/file-test-data/basics/recursive_ok.py new file mode 100644 index 00000000..ff17ae55 --- /dev/null +++ b/python/file-test-data/basics/recursive_ok.py @@ -0,0 +1,37 @@ +from wypp import * + +# Ein Flussabschnitt ist entweder +# - ein Bach mit Namen und Quelle, oder +# - ein Zusammenfluss eines Haupt- und Nebenflussabschnitts an einem bestimmten Ort. + +@record +class Creek: + origin: str + name: str + +@record +class Confluence: + location: str + mainStem: RiverSection + tributary: RiverSection + +type RiverSection = Union[Creek, Confluence] + +kinzig1 = Creek('Loßburg', 'Kinzig') +gutach = Creek('Schönwald', 'Gutach') +kinzig2 = Confluence('Hausach', kinzig1, gutach) +schutter1 = Creek('Schweighausen', 'Schutter') +heidengraben = Creek('Lahr', 'Heidengraben') +schutter2 = Confluence('Lahr', schutter1, heidengraben) +kinzig3 = Confluence('Kehl', kinzig2, schutter2) + +# Name eines Flussabschnitts bestimmen +# Eingabe: den Flussabschnitt (Typ: RiverSection) +# Ergebnis: der Name (Typ: str) +def riverName(r: RiverSection) -> str: + if isinstance(r, Creek): + return r.name + elif isinstance(r, Confluence): + return riverName(r.mainStem) + +print(riverName(kinzig3)) diff --git a/python/file-test-data/basics/recursive_old_fail.err b/python/file-test-data/basics/recursive_old_fail.err new file mode 100644 index 00000000..d335585d --- /dev/null +++ b/python/file-test-data/basics/recursive_old_fail.err @@ -0,0 +1,6 @@ +Traceback (most recent call last): + File "file-test-data/basics/recursive_old_fail.py", line 13, in + class Confluence: + File "file-test-data/basics/recursive_old_fail.py", line 15, in Confluence + mainStem: RiverSection +NameError: name 'RiverSection' is not defined diff --git a/python/file-test-data/basics/recursive_old_fail.out b/python/file-test-data/basics/recursive_old_fail.out new file mode 100644 index 00000000..e69de29b diff --git a/python/file-test-data/basics/recursive_old_fail.py b/python/file-test-data/basics/recursive_old_fail.py new file mode 100644 index 00000000..ff17ae55 --- /dev/null +++ b/python/file-test-data/basics/recursive_old_fail.py @@ -0,0 +1,37 @@ +from wypp import * + +# Ein Flussabschnitt ist entweder +# - ein Bach mit Namen und Quelle, oder +# - ein Zusammenfluss eines Haupt- und Nebenflussabschnitts an einem bestimmten Ort. + +@record +class Creek: + origin: str + name: str + +@record +class Confluence: + location: str + mainStem: RiverSection + tributary: RiverSection + +type RiverSection = Union[Creek, Confluence] + +kinzig1 = Creek('Loßburg', 'Kinzig') +gutach = Creek('Schönwald', 'Gutach') +kinzig2 = Confluence('Hausach', kinzig1, gutach) +schutter1 = Creek('Schweighausen', 'Schutter') +heidengraben = Creek('Lahr', 'Heidengraben') +schutter2 = Confluence('Lahr', schutter1, heidengraben) +kinzig3 = Confluence('Kehl', kinzig2, schutter2) + +# Name eines Flussabschnitts bestimmen +# Eingabe: den Flussabschnitt (Typ: RiverSection) +# Ergebnis: der Name (Typ: str) +def riverName(r: RiverSection) -> str: + if isinstance(r, Creek): + return r.name + elif isinstance(r, Confluence): + return riverName(r.mainStem) + +print(riverName(kinzig3)) diff --git a/python/fileTests.py b/python/fileTests.py index 32f7b345..2c778bb0 100644 --- a/python/fileTests.py +++ b/python/fileTests.py @@ -1,17 +1,39 @@ from pathlib import Path from fileTestsLib import * +import sys + +def pythonMinVersion(major: int, minor: int) -> bool: + return sys.version_info >= (major, minor) + +def pythonMaxVersion(major: int, minor: int) -> bool: + return sys.version_info <= (major, minor) directories = [Path("file-test-data/basics"), Path("file-test-data/extras")] +special = { + 'file-test-data/basics/recursive_ok.py': pythonMinVersion(3, 14), + 'file-test-data/basics/recursive_old_fail.py': pythonMaxVersion(3, 13) + } + #directories = [Path("file-test-data/basics")] #directories = [Path("file-test-data/extras")] -for d in directories: - for file in d.iterdir(): - if file.is_file(): - name = file.as_posix() - if name.endswith('.py'): - check(name) +def main(): + for d in directories: + for file in d.iterdir(): + if file.is_file(): + name = file.as_posix() + if name.endswith('.py'): + if name in special: + if special[name]: + check(name) + else: + check(name) + + globalCtx.results.finish() -globalCtx.results.finish() +try: + main() +except KeyboardInterrupt: + pass diff --git a/python/fileTestsLib.py b/python/fileTestsLib.py index a889445e..8de2d169 100644 --- a/python/fileTestsLib.py +++ b/python/fileTestsLib.py @@ -10,6 +10,7 @@ import shutil import json import re +import fnmatch GLOBAL_CHECK_OUTPUTS = True @@ -24,6 +25,7 @@ class TestOpts: keepGoing: bool record: Optional[str] lang: Optional[str] + patterns: list[str] def parseArgs() -> TestOpts: parser = argparse.ArgumentParser( @@ -41,7 +43,9 @@ def parseArgs() -> TestOpts: type=str, help='Record the expected output for the given file.') parser.add_argument('--lang', dest='lang', type=str, help='Display error messages in this language (either en or de, only for recording).') - + parser.add_argument('patterns', metavar='PATTERN', + help='Glob patterns, a test is executed only if it matches at least one pattern', + nargs='*') # Parse the arguments args = parser.parse_args() @@ -53,7 +57,8 @@ def parseArgs() -> TestOpts: only=args.only, keepGoing=args.keepGoing, record=args.record, - lang=args.lang + lang=args.lang, + patterns=args.patterns, ) defaultLang = 'de' @@ -82,6 +87,8 @@ def finish(self): print(f"Passed: {len(self.passed)}") print(f"Skipped: {len(self.skipped)}") print(f"Failed: {len(self.failed)}") + print() + print('Python version: ' + sys.version) if self.failed: print() print("Failed tests:") @@ -136,6 +143,9 @@ def shouldSkip(testFile: str, ctx: TestContext, minVersion: Optional[tuple[int, """ global _started opts = ctx.opts + if opts.patterns: + if not matchesAnyPattern(testFile, opts.patterns): + return True if opts.startAt: if _started: return False @@ -368,6 +378,12 @@ def checkNoConfig(testFile: str, if not ctx.opts.keepGoing: ctx.results.finish() +def matchesAnyPattern(testFile: str, patterns: list[str]) -> bool: + for p in patterns: + if fnmatch.fnmatch(testFile, p) or p in testFile: + return True + return False + def check(testFile: str, exitCode: int = 1, minVersion: Optional[tuple[int, int]] = None, From ce55bfa586d478b7ffd1cdec42fd2c314fe2d99b Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Fri, 7 Nov 2025 13:56:00 +0100 Subject: [PATCH 4/6] add vscode-test folder --- vscode-test/.vscode/settings.json | 10 ++++++++++ vscode-test/type-test.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 vscode-test/.vscode/settings.json create mode 100644 vscode-test/type-test.py diff --git a/vscode-test/.vscode/settings.json b/vscode-test/.vscode/settings.json new file mode 100644 index 00000000..8ba5a24c --- /dev/null +++ b/vscode-test/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "python.analysis.diagnosticSeverityOverrides": { + "reportWildcardImportFromLibrary": "none" + }, + "python.analysis.extraPaths": [ + "../python/code", + "/Users/swehr/devel/write-your-python-program/python/code" + ], + "python.analysis.typeCheckingMode": "off" +} diff --git a/vscode-test/type-test.py b/vscode-test/type-test.py new file mode 100644 index 00000000..83800177 --- /dev/null +++ b/vscode-test/type-test.py @@ -0,0 +1,15 @@ +from wypp import * + +type OnOff = Literal['on', 'off'] + +def test(x: OnOff): + pass + +test('blub') + +@record +class Point: + x: int + y: int + +p = Point() From 42349a68b596f40539b9426c231506a658ff88dc Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Fri, 7 Nov 2025 16:01:46 +0100 Subject: [PATCH 5/6] fix interactive and readline handling --- python/code/wypp/interactive.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/code/wypp/interactive.py b/python/code/wypp/interactive.py index 21123668..0d7c2670 100644 --- a/python/code/wypp/interactive.py +++ b/python/code/wypp/interactive.py @@ -36,11 +36,12 @@ def enterInteractive(userDefs: dict, checkTypes: bool, loadingFailed: bool): historyFile = getHistoryFilePath() try: import readline + except: + readline = None + if readline: readline.parse_and_bind('tab: complete') if historyFile and os.path.exists(historyFile): readline.read_history_file(historyFile) - except: - pass try: consoleClass(locals=userDefs).interact(banner="", exitmsg='') finally: From 45e0142fdaf9f1631e9830b2ca9fdf3568875d8c Mon Sep 17 00:00:00 2001 From: Stefan Wehr Date: Fri, 7 Nov 2025 16:07:37 +0100 Subject: [PATCH 6/6] some log messages --- src/extension.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 6dbaba05..0f69243e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -44,19 +44,23 @@ function showButtons() { } async function startTerminal( - existing: vscode.Terminal | undefined, name: string, cmd: string + existing: vscode.Terminal | undefined, name: string, cmd: string, outChannel: vscode.OutputChannel ): Promise { 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 don't care. + // 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(); } + } else { + outChannel.appendLine("No terminal exists yet, creating new terminal"); } if (!terminal) { const terminalOptions: vscode.TerminalOptions = {name: name}; @@ -434,6 +438,7 @@ export async function activate(context: vscode.ExtensionContext) { const outChannel = vscode.window.createOutputChannel("Write Your Python Program"); disposables.push(outChannel); + outChannel.appendLine('Write Your Python Program extension activated.'); const terminals: { [name: string]: TerminalContext } = {}; vscode.window.onDidCloseTerminal((t) => { @@ -497,7 +502,8 @@ export async function activate(context: vscode.ExtensionContext) { disableOpt + langOpt + " --interactive " + " --change-directory " + - fileToCommandArgument(file) + fileToCommandArgument(file), + outChannel ); terminals[cmdId] = {terminal: cmdTerm, directory: path.dirname(file)}; if (pyCmd.kind === "warning") {