diff --git a/.gitignore b/.gitignore index 26264f4..260bf78 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,4 @@ out/ *.vsix python/VERSION +.claude diff --git a/.vscode/launch.json b/.vscode/launch.json index ab270d4..96c5615 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,13 +5,6 @@ { "version": "0.2.0", "configurations": [ - { - "name": "Python: Current File", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" - }, { "name": "Run Extension", "type": "extensionHost", @@ -36,6 +29,13 @@ "${workspaceFolder}/out/test/**/*.js" ], "preLaunchTask": "${defaultBuildTask}" + }, + { + "name": "Python: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" } ] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..79d95a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +Write Your Python Program (WYPP): a beginner-friendly Python environment. Two parts ship together: + +- **VS Code extension** (TypeScript, `src/`), which adds a RUN button and a program-flow visualization. +- **`wypp` Python package** (`python/code/wypp/`), the teaching language: records, dynamic type checking of annotations, `check(...)` tests and localized (German/English) error messages. It is also published to PyPI. + +## Layout + +- `src/extension.ts`: extension entry point. It runs `python/code/wypp/runYourProgram.py` in a terminal, with `python/code` on the path. +- `src/programflow-visualization/`: Python-Tutor-like visualization. See its `README.md` for the architecture. + - `backend/` spawns `pytrace-generator/main.py` to produce traces. + - `graph-model.ts` and `reachability.ts` hold pure logic (unit-tested). + - `web/` is the webview UI (ELK layout), bundled by `scripts/build-web.mjs` with esbuild. +- `python/code/wypp/`: the runtime (`runner.py`, `typecheck.py`, `records.py`, `errors.py`, `i18n.py`, ...). +- `python/code/typeguard/`: vendored copy of typeguard. Always import it through `wypp/myTypeguard.py`, never directly. +- `pytrace-generator/`: standalone tracer for the visualization, with its own tests. +- `elk-task/`, `visualization-plan.md`: design notes for ongoing visualization work. + +## Commands + +TypeScript (run from the repo root): + +```sh +npm install +npm run build # tsc + web bundle -> out/ +npm test # compile, typecheck web, eslint, mocha unit tests (out/test/unit) +npm run watch:web # rebuild the webview on change (serve out/programflow-visualization/web) +``` + +Python (run from `python/`; needs Python 3.12–3.14): + +```sh +./allTestsForPyVersion # unit + integration + file tests +./allTestsForPyVersion --unit tests/test_record.py +python3 fileTests.py --only file-test-data/basics/foo.py +python3 fileTests.py --record file-test-data/basics/foo.py [--lang en] +./run somefile.py # run a file with wypp +python3 ../pytrace-generator/test/runTests.py +``` + +## Definition of done + +A change is done when all of the following apply: + +1. The tests for every part you touched pass: + - TypeScript (`src/`): `npm test`. + - Python runtime (`python/code/`): `./allTestsForPyVersion` in `python/`. + - Tracer (`pytrace-generator/`): `python3 pytrace-generator/test/runTests.py`. +2. New behavior has a test: a unit test, or a file test in `python/file-test-data/`. A bug fix comes with a test that reproduces the bug. +3. Changed error output has been re-recorded with `--record` in both German and English (`--lang en`), and you have reviewed the diff of the recorded files. +4. Every new user-facing message is wrapped in `tr()` from `i18n.py`, with a German translation added there. +5. You have tried visualization UI changes in a browser (`npm run watch:web`) or in the extension. +6. `ChangeLog.md` has an entry for every user-visible change. `README.md` is updated if documented behavior changed. +7. No stray debug output, scratch files or build artifacts (`out/`, `*.vsix`, `dist/`) are committed. + +## Conventions + +- **File tests** live in `python/file-test-data/`. Each `foo.py` has expected `foo.out`/`foo.err` files (German) and optional `foo.out_en`/`foo.err_en` files (English). If a change alters error output, re-record with `--record` and review the diff. +- A file test named `*_ok.py` is expected to exit with code 0. Every other file test is expected to exit with code 1. +- A file test that relies on forward references contains the line `# from __future__ import annotations`, commented out. Python 3.14 doesn't need the import. On Python < 3.14, `fileTestsLib.py` runs a temporary copy with the line uncommented. +- CI runs `npm test` and the Python tests on 3.12, 3.13 and 3.14. +- The version lives in `package.json`. The Python package reads it from there (see `python/setup.py` and `wypp/version.py`). Update `ChangeLog.md` when you release. +- `mkdist` packages the `.vsix` (with `vsce`) and the Python distribution. The `*.vsix` files in the root are build artifacts. + +## Version control + +- Do not create commits unless explicitly ask for. + diff --git a/ChangeLog.md b/ChangeLog.md index da68e76..94cfbed 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,9 @@ # Write Your Python Program - CHANGELOG +* unreleased + * Python 3.14: forward references in records and functions work without + `from __future__ import annotations` + * 2.3.0 (2026-04-03) * Use relative imports in the whole code base * 2.2.2 (2026-04-03) diff --git a/python/code/wypp/records.py b/python/code/wypp/records.py index 827d81a..3e0e50b 100644 --- a/python/code/wypp/records.py +++ b/python/code/wypp/records.py @@ -50,8 +50,9 @@ def _patchDataClass(cls, mutable: bool, ns: myTypeguard.Namespaces): fields = set(fieldNames) info = location.RecordConstructorInfo(cls) locs = {} + annotations = utils.getAnnotations(cls) for name in fields: - if not name in cls.__annotations__: + if not name in annotations: raise errors.WyppTypeError.noTypeAnnotationForRecordAttribute(name, cls.__name__) else: locs[name] = info.getParamSourceLocation(name) diff --git a/python/code/wypp/typecheck.py b/python/code/wypp/typecheck.py index eb141bc..24a5d54 100644 --- a/python/code/wypp/typecheck.py +++ b/python/code/wypp/typecheck.py @@ -240,7 +240,7 @@ def wrapTypecheck(cfg: dict | CheckCfg, outerInfo: Optional[location.CallableInf else: checkCfg = CheckCfg.fromDict(cfg) def _wrap(f: Callable[P, T]) -> Callable[P, T]: - sig = inspect.signature(f) + sig = utils.getSignature(f) if isEmptySignature(sig): return f if outerInfo is None: diff --git a/python/code/wypp/utils.py b/python/code/wypp/utils.py index 43c50d0..de7bff9 100644 --- a/python/code/wypp/utils.py +++ b/python/code/wypp/utils.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +import inspect import os import sys from typing import * @@ -20,6 +21,24 @@ def _call_with_next_frame_removed( ) -> T: return f(*args, **kwargs) +# Starting with python 3.14, annotations are evaluated lazily (PEP 649). Evaluating them +# too early (e.g. when decorating a record whose fields refer to a type defined later) +# raises a NameError. We therefore fetch annotations in FORWARDREF format: names not yet defined +# become ForwardRef objects, which are resolved when the check is performed. +def getSignature(f: Callable) -> inspect.Signature: + if sys.version_info >= (3, 14): + import annotationlib + return inspect.signature(f, annotation_format=annotationlib.Format.FORWARDREF) + else: + return inspect.signature(f) + +def getAnnotations(x: Any) -> dict[str, Any]: + if sys.version_info >= (3, 14): + import annotationlib + return annotationlib.get_annotations(x, format=annotationlib.Format.FORWARDREF) + else: + return getattr(x, '__annotations__', {}) + def getEnv(name, conv, default): s = os.getenv(name) if s is None: diff --git a/python/file-test-data/basics/forwardRefs.err b/python/file-test-data/basics/forwardRefs.err index f517c11..b242d5b 100644 --- a/python/file-test-data/basics/forwardRefs.err +++ b/python/file-test-data/basics/forwardRefs.err @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/basics/forwardRefs.py", line 12, in + File "file-test-data/basics/forwardRefs.py", line 13, in a.foo(a) WyppTypeError: <__wypp__.A object at 0x00> @@ -8,10 +8,10 @@ Der Aufruf der Methode `foo` der Klasse `A` erwartet einen Wert vom Typ `B` als Aber der übergebene Wert hat den Typ `A`. ## Datei file-test-data/basics/forwardRefs.py -## Fehlerhafter Aufruf in Zeile 12: +## Fehlerhafter Aufruf in Zeile 13: a.foo(a) -## Typ deklariert in Zeile 4: +## Typ deklariert in Zeile 5: def foo(self, b: B): \ No newline at end of file diff --git a/python/file-test-data/basics/forwardRefs.err_en b/python/file-test-data/basics/forwardRefs.err_en index 20d22f2..77d5b9a 100644 --- a/python/file-test-data/basics/forwardRefs.err_en +++ b/python/file-test-data/basics/forwardRefs.err_en @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/basics/forwardRefs.py", line 12, in + File "file-test-data/basics/forwardRefs.py", line 13, in a.foo(a) WyppTypeError: <__wypp__.A object at 0x00> @@ -8,10 +8,10 @@ The call of method `foo` of class `A` expects value of type `B` as 1st argument. But the value given has type `A`. ## File file-test-data/basics/forwardRefs.py -## Problematic call in line 12: +## Problematic call in line 13: a.foo(a) -## Type declared in line 4: +## Type declared in line 5: def foo(self, b: B): \ No newline at end of file diff --git a/python/file-test-data/basics/forwardRefs.py b/python/file-test-data/basics/forwardRefs.py index cd11bd0..4ff0c4e 100644 --- a/python/file-test-data/basics/forwardRefs.py +++ b/python/file-test-data/basics/forwardRefs.py @@ -1,4 +1,5 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 class A: def foo(self, b: B): diff --git a/python/file-test-data/basics/forwardRefs_ok.py b/python/file-test-data/basics/forwardRefs_ok.py index c3f7fc8..8abefef 100644 --- a/python/file-test-data/basics/forwardRefs_ok.py +++ b/python/file-test-data/basics/forwardRefs_ok.py @@ -1,4 +1,5 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 class A: def foo(self, b: B): diff --git a/python/file-test-data/basics/recursive2_ok.py b/python/file-test-data/basics/recursive2_ok.py index 08db2e0..19e5487 100644 --- a/python/file-test-data/basics/recursive2_ok.py +++ b/python/file-test-data/basics/recursive2_ok.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # Ein Flussabschnitt ist entweder diff --git a/python/file-test-data/extras/invalidType.err b/python/file-test-data/extras/invalidType.err index 739a7f8..cd05b1c 100644 --- a/python/file-test-data/extras/invalidType.err +++ b/python/file-test-data/extras/invalidType.err @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/extras/invalidType.py", line 9, in + File "file-test-data/extras/invalidType.py", line 11, in foo() WyppTypeError: ungültiger Typ `Union(list(int), list[float])` @@ -7,6 +7,6 @@ WyppTypeError: ungültiger Typ `Union(list(int), list[float])` Wolltest du `Union[list(int), list[float]]` schreiben? ## Datei file-test-data/extras/invalidType.py -## Typ deklariert in Zeile 6: +## Typ deklariert in Zeile 8: def foo() -> Union(list(int), list[float]): \ No newline at end of file diff --git a/python/file-test-data/extras/invalidType.py b/python/file-test-data/extras/invalidType.py index 7759517..9b55af0 100644 --- a/python/file-test-data/extras/invalidType.py +++ b/python/file-test-data/extras/invalidType.py @@ -1,8 +1,10 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # See https://github.com/skogsbaer/write-your-python-program/issues/61 -# Tests 'return' +# Tests 'return' def foo() -> Union(list(int), list[float]): pass diff --git a/python/file-test-data/extras/invalidType2.err b/python/file-test-data/extras/invalidType2.err index abf4785..20d81dc 100644 --- a/python/file-test-data/extras/invalidType2.err +++ b/python/file-test-data/extras/invalidType2.err @@ -1,4 +1,4 @@ Traceback (most recent call last): - File "file-test-data/extras/invalidType2.py", line 5, in + File "file-test-data/extras/invalidType2.py", line 7, in T = Union(list(int), list[float]) TypeError: 'type' object is not iterable \ No newline at end of file diff --git a/python/file-test-data/extras/invalidType2.py b/python/file-test-data/extras/invalidType2.py index 1757ecb..b4a1784 100644 --- a/python/file-test-data/extras/invalidType2.py +++ b/python/file-test-data/extras/invalidType2.py @@ -1,10 +1,12 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # See https://github.com/skogsbaer/write-your-python-program/issues/61 T = Union(list(int), list[float]) -# Tests 'return' +# Tests 'return' def foo() -> T: pass diff --git a/python/file-test-data/extras/invalidType3.err b/python/file-test-data/extras/invalidType3.err index 8a772f4..3755696 100644 --- a/python/file-test-data/extras/invalidType3.err +++ b/python/file-test-data/extras/invalidType3.err @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/extras/invalidType3.py", line 9, in + File "file-test-data/extras/invalidType3.py", line 11, in foo() WyppTypeError: ungültiger Typ `Optional(list(int), list[float])` @@ -7,6 +7,6 @@ WyppTypeError: ungültiger Typ `Optional(list(int), list[float])` Wolltest du `Optional[list(int), list[float]]` schreiben? ## Datei file-test-data/extras/invalidType3.py -## Typ deklariert in Zeile 6: +## Typ deklariert in Zeile 8: def foo() -> Optional(list(int), list[float]): \ No newline at end of file diff --git a/python/file-test-data/extras/invalidType3.py b/python/file-test-data/extras/invalidType3.py index cf8418b..e7fe6f0 100644 --- a/python/file-test-data/extras/invalidType3.py +++ b/python/file-test-data/extras/invalidType3.py @@ -1,8 +1,10 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # See https://github.com/skogsbaer/write-your-python-program/issues/61 -# Tests 'return' +# Tests 'return' def foo() -> Optional(list(int), list[float]): pass diff --git a/python/file-test-data/extras/invalidType4.err b/python/file-test-data/extras/invalidType4.err index b261d29..6c4b169 100644 --- a/python/file-test-data/extras/invalidType4.err +++ b/python/file-test-data/extras/invalidType4.err @@ -1,10 +1,10 @@ Traceback (most recent call last): - File "file-test-data/extras/invalidType4.py", line 9, in + File "file-test-data/extras/invalidType4.py", line 11, in foo() WyppTypeError: ungültiger Typ `Optional[list[int], list[float]]` ## Datei file-test-data/extras/invalidType4.py -## Typ deklariert in Zeile 6: +## Typ deklariert in Zeile 8: def foo() -> Optional[list[int], list[float]]: \ No newline at end of file diff --git a/python/file-test-data/extras/invalidType4.py b/python/file-test-data/extras/invalidType4.py index 36f511d..396f96d 100644 --- a/python/file-test-data/extras/invalidType4.py +++ b/python/file-test-data/extras/invalidType4.py @@ -1,8 +1,10 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # See https://github.com/skogsbaer/write-your-python-program/issues/61 -# Tests 'return' +# Tests 'return' def foo() -> Optional[list[int], list[float]]: pass diff --git a/python/file-test-data/extras/testABCMeta.err b/python/file-test-data/extras/testABCMeta.err index 4ffca11..958fc73 100644 --- a/python/file-test-data/extras/testABCMeta.err +++ b/python/file-test-data/extras/testABCMeta.err @@ -1,4 +1,4 @@ Traceback (most recent call last): - File "file-test-data/extras/testABCMeta.py", line 28, in + File "file-test-data/extras/testABCMeta.py", line 30, in Circle(Point(0, 0), 1) TypeError: Can't instantiate abstract class Circle without an implementation for abstract method 'area' \ No newline at end of file diff --git a/python/file-test-data/extras/testABCMeta.py b/python/file-test-data/extras/testABCMeta.py index 31508f5..3417cf9 100644 --- a/python/file-test-data/extras/testABCMeta.py +++ b/python/file-test-data/extras/testABCMeta.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + # See: https://github.com/skogsbaer/write-your-python-program/issues/74 from wypp import * @@ -25,4 +27,4 @@ def move(self, x: float, y: float)->None: self.x = self.x + x self.y = self.y + y -Circle(Point(0, 0), 1) \ No newline at end of file +Circle(Point(0, 0), 1) diff --git a/python/file-test-data/extras/testClassHierarchy_ok.py b/python/file-test-data/extras/testClassHierarchy_ok.py index 3e2964a..38aefd9 100644 --- a/python/file-test-data/extras/testClassHierarchy_ok.py +++ b/python/file-test-data/extras/testClassHierarchy_ok.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * class FileSystemEntry: diff --git a/python/file-test-data/extras/testClassRecursion_ok.py b/python/file-test-data/extras/testClassRecursion_ok.py index 3da4a82..62e3e85 100644 --- a/python/file-test-data/extras/testClassRecursion_ok.py +++ b/python/file-test-data/extras/testClassRecursion_ok.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * class C: diff --git a/python/file-test-data/extras/testHintParentheses1.err b/python/file-test-data/extras/testHintParentheses1.err index 7cf3140..f58b7e7 100644 --- a/python/file-test-data/extras/testHintParentheses1.err +++ b/python/file-test-data/extras/testHintParentheses1.err @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/extras/testHintParentheses1.py", line 8, in + File "file-test-data/extras/testHintParentheses1.py", line 10, in check(foo([1,2,3]), 3) WyppTypeError: ungültiger Typ `list(int)` @@ -7,6 +7,6 @@ WyppTypeError: ungültiger Typ `list(int)` Wolltest du `list[int]` schreiben? ## Datei file-test-data/extras/testHintParentheses1.py -## Typ deklariert in Zeile 5: +## Typ deklariert in Zeile 7: def foo(l: list(int)) -> int: \ No newline at end of file diff --git a/python/file-test-data/extras/testHintParentheses1.py b/python/file-test-data/extras/testHintParentheses1.py index 6efd9b2..e6fb4f6 100644 --- a/python/file-test-data/extras/testHintParentheses1.py +++ b/python/file-test-data/extras/testHintParentheses1.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # See https://github.com/skogsbaer/write-your-python-program/issues/61 diff --git a/python/file-test-data/extras/testHintParentheses3.err b/python/file-test-data/extras/testHintParentheses3.err index e5d29b1..ab94a81 100644 --- a/python/file-test-data/extras/testHintParentheses3.err +++ b/python/file-test-data/extras/testHintParentheses3.err @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/extras/testHintParentheses3.py", line 9, in + File "file-test-data/extras/testHintParentheses3.py", line 11, in foo() WyppTypeError: ungültiger Typ `Union(list, str)` @@ -7,6 +7,6 @@ WyppTypeError: ungültiger Typ `Union(list, str)` Wolltest du `Union[list, str]` schreiben? ## Datei file-test-data/extras/testHintParentheses3.py -## Typ deklariert in Zeile 6: +## Typ deklariert in Zeile 8: def foo() -> Union(list, str): \ No newline at end of file diff --git a/python/file-test-data/extras/testHintParentheses3.py b/python/file-test-data/extras/testHintParentheses3.py index d93e038..1cee3c7 100644 --- a/python/file-test-data/extras/testHintParentheses3.py +++ b/python/file-test-data/extras/testHintParentheses3.py @@ -1,8 +1,10 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * # See https://github.com/skogsbaer/write-your-python-program/issues/61 -# Tests 'return' +# Tests 'return' def foo() -> Union(list, str): pass diff --git a/python/file-test-data/extras/testIterator3_ok.py b/python/file-test-data/extras/testIterator3_ok.py index cc6ddc2..d838903 100644 --- a/python/file-test-data/extras/testIterator3_ok.py +++ b/python/file-test-data/extras/testIterator3_ok.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from typing import Iterable def myRange(n: int) -> Iterator[int]: diff --git a/python/file-test-data/extras/testRecordSetTypeForwardRef.err b/python/file-test-data/extras/testRecordSetTypeForwardRef.err index ec9c21b..929f2ad 100644 --- a/python/file-test-data/extras/testRecordSetTypeForwardRef.err +++ b/python/file-test-data/extras/testRecordSetTypeForwardRef.err @@ -1,7 +1,7 @@ Traceback (most recent call last): - File "file-test-data/extras/testRecordSetTypeForwardRef.py", line 15, in + File "file-test-data/extras/testRecordSetTypeForwardRef.py", line 17, in m() - File "file-test-data/extras/testRecordSetTypeForwardRef.py", line 13, in m + File "file-test-data/extras/testRecordSetTypeForwardRef.py", line 15, in m r.x = "hello" WyppTypeError: "hello" @@ -10,10 +10,10 @@ Attribut `x` des Records `Record` deklariert als Typ `A`. Das Attribut kann nicht auf einen Wert vom Typ `str` gesetzt werden. ## Datei file-test-data/extras/testRecordSetTypeForwardRef.py -## Fehlerhafte Zuweisung in Zeile 13: +## Fehlerhafte Zuweisung in Zeile 15: r.x = "hello" -## Typ deklariert in Zeile 6: +## Typ deklariert in Zeile 8: x: A \ No newline at end of file diff --git a/python/file-test-data/extras/testRecordSetTypeForwardRef.py b/python/file-test-data/extras/testRecordSetTypeForwardRef.py index ba2dae0..164a58b 100644 --- a/python/file-test-data/extras/testRecordSetTypeForwardRef.py +++ b/python/file-test-data/extras/testRecordSetTypeForwardRef.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * @record(mutable=True) diff --git a/python/file-test-data/extras/testTypeKeyword_ok.py b/python/file-test-data/extras/testTypeKeyword_ok.py index d98ceb0..6022153 100644 --- a/python/file-test-data/extras/testTypeKeyword_ok.py +++ b/python/file-test-data/extras/testTypeKeyword_ok.py @@ -1,5 +1,7 @@ # WYPP_TEST_CONFIG: {"typecheck": "both"} -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * type T = Union[str, int] diff --git a/python/file-test-data/extras/testTypesProtos5_ok.py b/python/file-test-data/extras/testTypesProtos5_ok.py index 3465904..051b718 100644 --- a/python/file-test-data/extras/testTypesProtos5_ok.py +++ b/python/file-test-data/extras/testTypesProtos5_ok.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from abc import ABC, abstractmethod from wypp import * diff --git a/python/file-test-data/extras/testTypesProtos6.err b/python/file-test-data/extras/testTypesProtos6.err index 39f4328..b064562 100644 --- a/python/file-test-data/extras/testTypesProtos6.err +++ b/python/file-test-data/extras/testTypesProtos6.err @@ -1,13 +1,13 @@ Traceback (most recent call last): - File "file-test-data/extras/testTypesProtos6.py", line 57, in + File "file-test-data/extras/testTypesProtos6.py", line 59, in print(computeTotalSize(root)) - File "file-test-data/extras/testTypesProtos6.py", line 50, in computeTotalSize + File "file-test-data/extras/testTypesProtos6.py", line 52, in computeTotalSize fs.accept(visitor) - File "file-test-data/extras/testTypesProtos6.py", line 19, in accept + File "file-test-data/extras/testTypesProtos6.py", line 21, in accept visitor.visitDirectory(self) - File "file-test-data/extras/testTypesProtos6.py", line 41, in visitDirectory + File "file-test-data/extras/testTypesProtos6.py", line 43, in visitDirectory c.accept(self) - File "file-test-data/extras/testTypesProtos6.py", line 28, in accept + File "file-test-data/extras/testTypesProtos6.py", line 30, in accept visitor.visitFile(self) WyppTypeError: <__wypp__.File object at 0x00> @@ -16,10 +16,10 @@ Der Aufruf der Methode `visitFile` der Klasse `TotalSizeVisitor` erwartet einen Aber der übergebene Wert hat den Typ `File`. ## Datei file-test-data/extras/testTypesProtos6.py -## Fehlerhafter Aufruf in Zeile 28: +## Fehlerhafter Aufruf in Zeile 30: visitor.visitFile(self) -## Typ deklariert in Zeile 42: +## Typ deklariert in Zeile 44: def visitFile(self, file: str): \ No newline at end of file diff --git a/python/file-test-data/extras/testTypesProtos6.py b/python/file-test-data/extras/testTypesProtos6.py index ac23526..4a4fb5d 100644 --- a/python/file-test-data/extras/testTypesProtos6.py +++ b/python/file-test-data/extras/testTypesProtos6.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * class FileSystemEntry: diff --git a/python/file-test-data/extras/testTypesProtos7.err b/python/file-test-data/extras/testTypesProtos7.err index 566442d..4410e5b 100644 --- a/python/file-test-data/extras/testTypesProtos7.err +++ b/python/file-test-data/extras/testTypesProtos7.err @@ -1,13 +1,13 @@ Traceback (most recent call last): - File "file-test-data/extras/testTypesProtos7.py", line 76, in + File "file-test-data/extras/testTypesProtos7.py", line 78, in print(computeTotalSize(root)) - File "file-test-data/extras/testTypesProtos7.py", line 69, in computeTotalSize + File "file-test-data/extras/testTypesProtos7.py", line 71, in computeTotalSize fs.accept(visitor) - File "file-test-data/extras/testTypesProtos7.py", line 36, in accept + File "file-test-data/extras/testTypesProtos7.py", line 38, in accept visitor.visitDirectory(self) - File "file-test-data/extras/testTypesProtos7.py", line 60, in visitDirectory + File "file-test-data/extras/testTypesProtos7.py", line 62, in visitDirectory c.accept(self) - File "file-test-data/extras/testTypesProtos7.py", line 47, in accept + File "file-test-data/extras/testTypesProtos7.py", line 49, in accept visitor.visitFile(self) WyppTypeError: File('notes.txt') @@ -16,10 +16,10 @@ Der Aufruf der Methode `visitFile` der Klasse `TotalSizeVisitor` erwartet einen Aber der übergebene Wert hat den Typ `File`. ## Datei file-test-data/extras/testTypesProtos7.py -## Fehlerhafter Aufruf in Zeile 47: +## Fehlerhafter Aufruf in Zeile 49: visitor.visitFile(self) -## Typ deklariert in Zeile 61: +## Typ deklariert in Zeile 63: def visitFile(self, f: str): \ No newline at end of file diff --git a/python/file-test-data/extras/testTypesProtos7.py b/python/file-test-data/extras/testTypesProtos7.py index c80e025..93787c2 100644 --- a/python/file-test-data/extras/testTypesProtos7.py +++ b/python/file-test-data/extras/testTypesProtos7.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from abc import ABC, abstractmethod from wypp import * diff --git a/python/file-test-data/extras/wrong-caused-by.err b/python/file-test-data/extras/wrong-caused-by.err index c3347b8..711a247 100644 --- a/python/file-test-data/extras/wrong-caused-by.err +++ b/python/file-test-data/extras/wrong-caused-by.err @@ -1,5 +1,5 @@ Traceback (most recent call last): - File "file-test-data/extras/wrong-caused-by.py", line 31, in + File "file-test-data/extras/wrong-caused-by.py", line 33, in mainStreetM.turnIntoStreet(redCarM) WyppTypeError: CarM(licensePlate='OG PY 123', color='rot') @@ -8,10 +8,10 @@ Der Aufruf der Methode `turnIntoStreet` der Klasse `StreetM` erwartet einen Wert Aber der übergebene Wert hat den Typ `CarM`. ## Datei file-test-data/extras/wrong-caused-by.py -## Fehlerhafter Aufruf in Zeile 31: +## Fehlerhafter Aufruf in Zeile 33: mainStreetM.turnIntoStreet(redCarM) -## Typ deklariert in Zeile 10: +## Typ deklariert in Zeile 12: def turnIntoStreet(self: StreetM, car: Car) -> None: \ No newline at end of file diff --git a/python/file-test-data/extras/wrong-caused-by.py b/python/file-test-data/extras/wrong-caused-by.py index 343c16f..1e08d01 100644 --- a/python/file-test-data/extras/wrong-caused-by.py +++ b/python/file-test-data/extras/wrong-caused-by.py @@ -1,4 +1,6 @@ -from __future__ import annotations +# from __future__ import annotations +# Leave the comment, it's needed for tests with python versions <= 3.13 + from wypp import * @record(mutable=True) diff --git a/python/fileTests.py b/python/fileTests.py index 5c3a1b0..c6662e7 100644 --- a/python/fileTests.py +++ b/python/fileTests.py @@ -4,10 +4,10 @@ import os def pythonMinVersion(major: int, minor: int) -> bool: - return sys.version_info >= (major, minor) + return sys.version_info[:2] >= (major, minor) def pythonMaxVersion(major: int, minor: int) -> bool: - return sys.version_info <= (major, minor) + return sys.version_info[:2] <= (major, minor) directories = [Path("file-test-data/basics"), Path("file-test-data/extras")] diff --git a/python/fileTestsLib.py b/python/fileTestsLib.py index 9f11d4c..4e2b698 100644 --- a/python/fileTestsLib.py +++ b/python/fileTestsLib.py @@ -232,6 +232,36 @@ def readAnswer(question: str, allowed: list[str]) -> str: return answer print(f'Answer must be one of {allowed}. Try again!') +# Test files that need `from __future__ import annotations` before python 3.14 contain +# this line (commented out). Since python 3.14, annotations are evaluated lazily and the +# import is not needed anymore. +FUTURE_ANNOTATIONS_COMMENTED = '# from __future__ import annotations' +FUTURE_ANNOTATIONS_MIN_VERSION = (3, 14) + +def _needsFutureAnnotations(testFile: str) -> bool: + if sys.version_info >= FUTURE_ANNOTATIONS_MIN_VERSION or not os.path.exists(testFile): + return False + return FUTURE_ANNOTATIONS_COMMENTED in readFile(testFile).splitlines() + +def _prepareTestDir(testFile: str, d: str) -> Optional[str]: + """ + For python < 3.14, copies the directory of testFile to d (preserving the relative path, + so that the output does not change) and uncomments `from __future__ import annotations`. + Returns the directory in which the test must be run, or None if testFile can be run as is. + """ + if not _needsFutureAnnotations(testFile): + return None + testDir = os.path.dirname(testFile) + shutil.copytree(testDir, os.path.join(d, testDir), + ignore=shutil.ignore_patterns('__pycache__')) + lines = readFile(testFile).splitlines(keepends=True) + lines = [l.replace(FUTURE_ANNOTATIONS_COMMENTED, FUTURE_ANNOTATIONS_COMMENTED[2:], 1) + if l.rstrip('\r\n') == FUTURE_ANNOTATIONS_COMMENTED else l + for l in lines] + with open(os.path.join(d, testFile), 'w', encoding='utf-8') as f: + f.write(''.join(lines)) + return d + def _runTest(testFile: str, exitCode: int, typecheck: bool, @@ -242,8 +272,24 @@ def _runTest(testFile: str, what: str, lang: str, ctx: TestContext) -> Literal['failed'] | None: + with tempfile.TemporaryDirectory() as d: + cwd = _prepareTestDir(testFile, d) + return _runTestIn(cwd, testFile, exitCode, typecheck, args, actualStdoutFile, + actualStderrFile, pythonPath, what, lang, ctx) + +def _runTestIn(cwd: Optional[str], + testFile: str, + exitCode: int, + typecheck: bool, + args: list[str], + actualStdoutFile: str, + actualStderrFile: str, + pythonPath: list[str], + what: str, + lang: str, + ctx: TestContext) -> Literal['failed'] | None: # Prepare the command - cmd = [sys.executable, ctx.opts.cmd, '--quiet'] + cmd = [sys.executable, os.path.abspath(ctx.opts.cmd), '--quiet'] if not typecheck: cmd.append('--no-typechecking') cmd.append(testFile) @@ -251,7 +297,8 @@ def _runTest(testFile: str, cmd.append(lang) cmd.extend(args) env = os.environ.copy() - env['PYTHONPATH'] = os.pathsep.join([os.path.join(ctx.opts.baseDir, 'code')] + pythonPath) + env['PYTHONPATH'] = os.pathsep.join([os.path.abspath(os.path.join(ctx.opts.baseDir, 'code'))] + + pythonPath) env['WYPP_UNDER_TEST'] = 'True' env['WYPP_FORCE_COLORS'] = 'True' debug(' '.join(cmd)) @@ -263,7 +310,8 @@ def _runTest(testFile: str, stdout=stdoutFile, stderr=stderrFile, text=True, - env=env + env=env, + cwd=cwd ) # Check exit code if result.returncode != exitCode: diff --git a/vscode-test/fluss.py b/vscode-test/fluss.py new file mode 100644 index 0000000..3169454 --- /dev/null +++ b/vscode-test/fluss.py @@ -0,0 +1,99 @@ +from wypp import * + +# Aus Vorlesung kopiert: +# ------------------------------------------------------------------- + +test = Literal['a', 'b'] + +# Ein Bach besteht aus +# - einem Namen +# - einem Ort der Quelle +@record +class Creek: + origin: str + name: str + +# Ein Zusammenfluss besteht aus +# - Name des Orts +# - Hauptflussabschnitt +# - Nebenflussabschnitt +@record +class Confluence: + name: str + mainStem: 'RiverSection' + tributary: 'RiverSection' + +# Ein Flussabschnitt ist entweder +# - ein Bach +# - oder ein Zusammenfluss +RiverSection = Union[Creek, Confluence] + +# Beispielflüsse +kinzig1 = Creek('Loßburg', 'Kinzig') +gutach = Creek('Schönwald', 'Gutach') +kinzig2 = Confluence('Hausach', kinzig1, gutach) + +heidengraben = Creek('Lahr', 'Heidengraben') +schutter1 = Creek('Schweighausen', 'Schutter') +schutter2 = Confluence('Lahr', schutter1, heidengraben) + +kinzig3 = Confluence('Kehl', kinzig2, schutter2) + +# ------------------------------------------------------------------- + +# a) Weitere Flüsse modellieren + +elz1 = Creek('Furtwangen', 'Elz') +glotter = Creek('Kandel', 'Glotter') +dreisam1 = Creek('Stegen', 'Dreisam') +ettenbach = Creek('Ettenheimmünster', 'Ettenbach') + +dreisam2 = Confluence('Bahlingen', dreisam1, glotter) +elz2 = Confluence('Riegel', elz1, dreisam2) +elz3 = Confluence('Kappel-Graphenhausen', elz2, ettenbach) + + +# b) Hauptabschnitte zählen + +# Zählen, aus wie vielen Hauptabschnitten ein Fluss besteht. +# Eingabe: ein Flussabschnitt +# Ausgabe: anzahl der Hauptabschnitte als int +def howManySections(section: RiverSection) -> int: + if isinstance(section, Creek): + return 1 + return 1 + howManySections(section.mainStem) + +check(howManySections(kinzig3), 3) +check(howManySections(kinzig2), 2) +check(howManySections(kinzig1), 1) +check(howManySections(schutter2), 2) +check(howManySections(schutter1), 1) +check(howManySections(elz3), 3) +check(howManySections(elz2), 2) +check(howManySections(elz1), 1) + + +# c) Prüfen, ob man flussaufwärts zu einem Ort gelangt + +# Prüft, ob man von einem Flussabschnitt flussaufwärts zu einem Ort gelangt. +# Eingabe: +# - startpunkt als RiverSection +# - zielort als String +# Ausgabe: Antwort als bool +def canSwimUpstream(start: RiverSection, goal: str) -> bool: + if isinstance(start, Creek): + return start.origin == goal + + return start.name == goal or \ + canSwimUpstream(start.mainStem, goal) or \ + canSwimUpstream(start.tributary, goal) + +check(canSwimUpstream(elz3, 'Riegel'), True) +check(canSwimUpstream(elz3, 'Ettenheimmünster'), True) +check(canSwimUpstream(elz2, 'Ettenheimmünster'), False) +check(canSwimUpstream(elz2, 'Furtwangen'), True) +check(canSwimUpstream(elz2, 'Bahlingen'), True) +check(canSwimUpstream(kinzig3, 'Lahr'), True) +check(canSwimUpstream(kinzig3, 'Schönwald'), True) +check(canSwimUpstream(kinzig2, 'Lahr'), False) +check(canSwimUpstream(kinzig3, 'Kehl'), True) # Per Definition ist der Ort des Zusammenflusses inbegriffen. diff --git a/vscode-test/test.py b/vscode-test/test.py new file mode 100644 index 0000000..e37c83f --- /dev/null +++ b/vscode-test/test.py @@ -0,0 +1,21 @@ +from wypp import * + +@record +class Item: + name: str + +item1 = Item('foo') +item2 = Item('bar') + +@record +class Items: + items: list[Item] + +invoices = [] + +for i in range(1000): + x = Item(str(i)) + y = Items([item1, item2, x]) + invoices.append(y) + +print(invoices[3]) diff --git a/vscode-test/type-test.py b/vscode-test/type-test.py index 091a1c8..948cf97 100644 --- a/vscode-test/type-test.py +++ b/vscode-test/type-test.py @@ -5,7 +5,7 @@ def test(x: OnOff): pass -test('blub') +# test('blub') @record class Point: @@ -14,3 +14,11 @@ class Point: p = Point(1, 2) print(p) + +def factorial(n: int) -> int: + if n == 0: + return 1 + else: + return n * factorial(n - 1) + +print(factorial(3))