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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# The Python version Pyodide 0.26 brings (see src/pyodide-worker.js), so
# the runtime checks run the same language version as the browser.
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# npm ci also builds the library once (the "prepare" script).
- run: npm ci
- run: npm run lint
- run: npm run build:embed
# Builds the library again and checks the embed protocol against dist/.
- run: npm run test:protocol
# The runtime under CPython: the visualizer's columns, docstrings,
# condition comments and readable types, and the doctest runner.
- run: npm run test:runtime
- name: Not checked here
run: |
echo "::notice title=Not run in CI::Needs Pyodide in a browser, so it isn't checked here: the worker (run, REPL, Tests in the browser, input() bridge, gturtle) and the UI. Check these in the embed preview (npm run build:embed && npm run preview:embed)."
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ npm run build # tsc -b && vite build && copy src/style.css -> dist/ (li
npm run dev # vite build --watch, for use with npm link/yalc in a host shell
npm run lint # oxlint
npm run test:protocol # builds the library, then runs scripts/check-embed-protocol.mjs against dist/
npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests}.py
npm run build:embed # builds the iframe app + demo page into dist-embed/
npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html
```

`npm run dev:embed` currently 404s: `embed/index.html` loads `../src/embed/main.tsx`, which lies outside the Vite root, so only the build works. Use `build:embed` + `preview:embed`.

There is no unit test framework; `test:protocol` (plain `node:assert` against the built `dist/index.js`) is the only automated check. Add protocol validation cases there. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`).
There is no unit test framework. The automated checks are `test:protocol` (plain `node:assert` against the built `dist/index.js`; add protocol validation cases there) and `test:runtime` (plain Python scripts that exec `src/runtime/` under CPython 3.12, Pyodide's version; add runtime regression cases there). `.github/workflows/ci.yml` runs lint, both builds and both checks on every push and pull request. Nothing that needs Pyodide in a browser (the worker, the UI) is checked automatically. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`).

**Releases.**
- The package is consumed from git: the glpython IDE (`../glpython`) pins `glpython-editor` by tag in its `package.json`. The `prepare` script builds `dist/` on install.
Expand All @@ -42,6 +43,8 @@ There is no unit test framework; `test:protocol` (plain `node:assert` against th
- A call into a function from another file switches `_glpython_current_path` while its body runs.
- `_glpython_short_form` marks functions whose body is a single `return <expr>` (docstring allowed). Their call-enter steps carry `shortForm` for the UI's substitution mode.
- `_glpython_run_visual_repl` traces a REPL input as the pseudo-file `<repl>` against the REPL namespace. The file's functions are swapped for traced versions of their `def`s, taken from the sources of the last Run. It's a dry run that leaves the namespace unchanged.
- Columns: `ast` counts UTF-8 bytes, `tokenize` characters, the UI UTF-16 code units. Every offset into a line goes through `_glpython_text_before` (the text in front of it, for slicing) or `_glpython_ui_column` (a column for the UI); never slice a line by an `ast` offset directly.
- Values are shown through `_glpython_safe_repr`, which writes types as learners do (`int`, not `<class 'int'>`); the program's own output keeps Python's repr.

Worker messages:
- `run` → `result`/`error`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"lint": "oxlint",
"preview:embed": "vite preview --config vite.embed.config.ts",
"test:protocol": "npm run build && node scripts/check-embed-protocol.mjs",
"test:runtime": "python3 -B scripts/check-runtime-columns.py",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py",
"prepare": "npm run build"
},
"peerDependencies": {
Expand Down
20 changes: 20 additions & 0 deletions scripts/check-runtime-columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ def walk(node_dict):
check_trace("REPL", "<repl>", {"main.py": PROGRAM, "<repl>": "länge(3) + 1"},
lambda: namespace["_glpython_run_visual_repl"]("länge(3) + 1", json.dumps({"main.py": PROGRAM}), "main.py"))

# A docstring, and the evaluation chain's condition comment («# 'ä🙂' == ...
# ist True»), on lines with ä and 🙂: both slice lines through the same
# conversion as the columns above.
DOC_PROGRAM = '''def prüfe(wört, zähler):
"""Prüft, ob wört «ä🙂» ist."""
if "🙂" in wört and zähler > 2: # Kommentar mit ö
return "ja"
return "nein"

prüfe("ä🙂", 3)
'''
result = json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": DOC_PROGRAM}, None))
hidden = result["hidden"]["main.py"]
if [2, 0, 3, 0] not in hidden:
failures.append(f"docstring: line 2 not hidden as a docstring, hidden ranges are {hidden}")
conditions = [step.get("conditionText") for step in result["trace"] if step["kind"] == "branch"]
expected_condition = """"🙂" in 'ä🙂' and 3 > 2"""
if conditions != [expected_condition]:
failures.append(f"condition comment: got {conditions}, expected {[expected_condition]}")

if failures:
print("\n".join(failures))
sys.exit(f"{len(failures)} column check(s) failed.")
Expand Down
131 changes: 131 additions & 0 deletions scripts/check-runtime-doctests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Regression test: the Tests button's doctest runner (src/runtime/doctests.py)
- the summary, each failing example with doctest's own report and the texts it
compared, the print hint, and a file that doesn't load.

Runs the runtime with CPython (no Pyodide needed):
python3 -B scripts/check-runtime-doctests.py
"""
import contextlib
import io
import json
import os
import sys
import tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RUNTIME = os.path.join(ROOT, "src", "runtime")

namespace = {"__name__": "glpython_runtime"}
with open(os.path.join(RUNTIME, "doctests.py"), encoding="utf-8") as handle:
exec(compile(handle.read(), "doctests.py", "exec"), namespace)
run_doctests = namespace["_glpython_run_doctests"]

failures = []
folder = tempfile.mkdtemp(prefix="glpython-doctests-")
sys.path.insert(0, folder) # the worker mounts a project's files next to each other


def run(source, name="main.py"):
"""The report for one file, and what the run printed outside the examples."""
path = os.path.join(folder, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(source)
printed = io.StringIO()
with contextlib.redirect_stdout(printed):
report = json.loads(run_doctests(path))
return report, printed.getvalue()


def expect(label, got, expected):
if got != expected:
failures.append(f"{label}: got {got!r}, expected {expected!r}")


PROGRAM = '''def doppelt(x):
"""
>>> doppelt(2)
4
>>> doppelt(3)
6
"""
return x * x

def zeige(x):
"""
>>> zeige(3)
9
"""
print(x * x)

def teile(a, b):
"""
>>> teile(1, 0)
0
"""
return a / b

def länge(text):
"""
>>> länge("ä🙂")
2
"""
return len(text)

if __name__ == "__main__":
print("main läuft")
'''
report, printed = run(PROGRAM)
expect("attempted", report.get("attempted"), 5)
expect("failed", report.get("failed"), 2)
expect("main block", printed, "")
failed = report.get("failures", [])
expect("failing examples", [(f["name"], f["line"], f["source"]) for f in failed],
[("main.doppelt", 5, "doppelt(3)"), ("main.teile", 19, "teile(1, 0)")])
if len(failed) == 2:
# A wrong result: doctest's own words, and both texts for «Erhalten · Erwartet».
expect("want", failed[0].get("want"), "6\n")
expect("got", failed[0].get("got"), "9\n")
for part in ("Failed example:", "doppelt(3)", "Expected:", "Got:"):
if part not in failed[0]["text"]:
failures.append(f"report of doppelt(3) lacks {part!r}: {failed[0]['text']!r}")
# An exception: no texts to compare.
expect("exception want/got", ("want" in failed[1], "got" in failed[1]), (False, False))
for part in ("Exception raised:", "ZeroDivisionError"):
if part not in failed[1]["text"]:
failures.append(f"report of teile(1, 0) lacks {part!r}: {failed[1]['text']!r}")
# Passes in doctest, but printed instead of returning.
expect("print hints", report.get("printHints"), [{"source": "zeige(3)", "output": "9"}])
# The file's definitions stay available to visualize a failing example.
expect("test namespace", sorted(n for n in ("doppelt", "zeige", "teile", "länge") if n in namespace["_glpython_test_namespace"]),
["doppelt", "länge", "teile", "zeige"])
# doctest swaps sys.displayhook per example; the runner restores it.
expect("displayhook restored", sys.__displayhook__ is sys.displayhook, True)

# Importing another file of the project.
run("def halb(x):\n return x / 2\n", "hilfe.py")
report, _ = run('''from hilfe import halb

def viertel(x):
"""
>>> viertel(8)
2.0
"""
return halb(halb(x))
''')
expect("import: attempted/failed", (report.get("attempted"), report.get("failed")), (1, 0))

# No examples at all.
report, _ = run("def f(x):\n return x\n")
expect("no examples", (report.get("attempted"), report.get("failed"), report.get("failures"), report.get("printHints")), (0, 0, [], []))

# A file that doesn't load: Python's error (for a syntax error with the line
# and caret above it).
report, _ = run("def f(x)\n return x\n")
expect("syntax error", str(report.get("error", "")).splitlines()[-1:], ["SyntaxError: expected ':'"])
report, _ = run("x = 1 / 0\n")
expect("error while loading", report.get("error"), "ZeroDivisionError: division by zero")

if failures:
print("\n".join(failures))
sys.exit(f"{len(failures)} doctest check(s) failed.")
print("Runtime doctest checks passed.")
50 changes: 50 additions & 0 deletions scripts/check-runtime-values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Regression test: values as the visualizer shows them. A type reads the way
learners write it ("int", "list", "NoneType", not "<class 'int'>"), in the
steps the UI substitutes and in the condition text the → chain uses, while the
program's own output keeps Python's repr.

Runs the visualizer runtime with CPython (no Pyodide needed):
python3 -B scripts/check-runtime-values.py
"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RUNTIME = os.path.join(ROOT, "src", "runtime")
sys.path.insert(0, RUNTIME) # for "import gturtle"

namespace = {"__name__": "glpython_runtime"}
for name in ("session.py", "visualizer.py"):
with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle:
exec(compile(handle.read(), name, "exec"), namespace)

failures = []

# The course's evaluator (Etappen 4-5) asks "type(e) != list".
PROGRAM = '''def ist_blatt(e):
return type(e) != list

a = 3
if type(2.5) != str:
print(ist_blatt(a), ist_blatt([1]), type(None), type(True))
'''
result = json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": PROGRAM}, None))
steps = [(step["label"], step.get("valueText")) for step in result["trace"] if step["kind"] == "eval"]
for expected in [("type(...)", "int"), ("int != list", "True"), ("type(...)", "list"), ("list != list", "False"),
("type(...)", "NoneType"), ("type(...)", "bool"), ("type(...)", "float"), ("float != str", "True")]:
if expected not in steps:
failures.append(f"no step {expected[0]!r} -> {expected[1]!r}; eval steps: {steps}")
# The → chain's reason comment for that if: the condition before it runs.
conditions = [step.get("conditionText") for step in result["trace"] if step["kind"] == "branch"]
if conditions != ["type(2.5) != str"]:
failures.append(f"condition text {conditions}, expected ['type(2.5) != str']")
if any("<class" in (value or "") for _, value in steps):
failures.append(f"a step still shows <class ...>: {steps}")
if result["output"].strip() != "True False <class 'NoneType'> <class 'bool'>":
failures.append(f"output changed: {result['output']!r}")

if failures:
print("\n".join(failures))
sys.exit(f"{len(failures)} value check(s) failed.")
print("Runtime value checks passed.")
30 changes: 19 additions & 11 deletions src/runtime/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ def _glpython_is_uninteresting_for_substitution(value):
# enclosing Call node's own eval step show the real result instead.
return callable(value) or isinstance(value, _glpython_types.ModuleType)

# A value as the visualizer shows it: Python's repr, except that a type reads
# the way learners write it ("int", "list", "NoneType" instead of
# "<class 'int'>"), so "type(a) != list" steps to "int != list". The program's
# own output (print, the REPL transcript) keeps Python's repr.
def _glpython_safe_repr(value, limit=160):
try:
text = repr(value)
text = value.__name__ if isinstance(value, type) else repr(value)
except Exception:
text = "<unrepresentable>"
if len(text) > limit:
Expand Down Expand Up @@ -163,11 +167,15 @@ class _GlpythonCallDepthExceeded(Exception):
# The UI indexes text in UTF-16 code units (JavaScript strings), but Python's
# ast reports columns in UTF-8 bytes and tokenize in characters - they only
# agree on plain ASCII lines, not with "ü" (2 bytes, 1 unit) or "🙂" (4 bytes,
# 1 character, 2 units). Every column sent to the UI goes through here; `unit`
# says what `offset` counts ("utf8" bytes or "chars").
# 1 character, 2 units). Every offset into a line goes through these two:
# _glpython_text_before for the text in front of it (to slice a Python
# string), _glpython_ui_column for a column sent to the UI. `unit` says what
# `offset` counts ("utf8" bytes or "chars").
def _glpython_text_before(line, offset, unit="utf8"):
return line.encode("utf-8")[:offset].decode("utf-8", "replace") if unit == "utf8" else line[:offset]

def _glpython_ui_column(line, offset, unit="utf8"):
prefix = line.encode("utf-8")[:offset].decode("utf-8", "replace") if unit == "utf8" else line[:offset]
return len(prefix.encode("utf-16-le")) // 2
return len(_glpython_text_before(line, offset, unit).encode("utf-16-le")) // 2

# A node's position for the UI: lines as Python counts them, columns in UTF-16
# code units (set by _glpython_preprocess; nodes it never saw keep Python's).
Expand Down Expand Up @@ -834,7 +842,7 @@ def _glpython_source_with_values(expr, scope):
source = getattr(expr, "_glpython_source", None)
if source is None or getattr(expr, "lineno", None) is None:
return None
lines = [line.encode("utf-8") for line in source.split("\n")]
lines = source.split("\n")
replacements = []
for name in _glpython_names_read(expr):
try:
Expand All @@ -849,10 +857,10 @@ def _glpython_source_with_values(expr, scope):
shift = expr.col_offset if row == 0 else 0
if not 0 <= row < len(lines):
continue
start = len(lines[row][:name.col_offset - shift].decode("utf-8", "replace"))
end = len(lines[row][:name.end_col_offset - shift].decode("utf-8", "replace"))
start = len(_glpython_text_before(lines[row], name.col_offset - shift))
end = len(_glpython_text_before(lines[row], name.end_col_offset - shift))
replacements.append((row, start, end, _glpython_safe_repr(value)))
text_lines = [line.decode("utf-8") for line in lines]
text_lines = list(lines)
for row, start, end, value_text in sorted(replacements, reverse=True):
text_lines[row] = text_lines[row][:start] + value_text + text_lines[row][end:]
return " ".join(line.strip() for line in text_lines)
Expand Down Expand Up @@ -1376,7 +1384,7 @@ def _glpython_hidden_ranges(source, tree):
if token.type != tokenize.COMMENT:
continue
(row, col), (end_row, end_col) = token.start, token.end
before = lines[row - 1][:col]
before = _glpython_text_before(lines[row - 1], col, "chars")
if before.strip():
# A comment after code: drop it together with the spaces before it.
# (tokenize counts characters; the UI needs UTF-16 code units.)
Expand All @@ -1392,7 +1400,7 @@ def _glpython_hidden_ranges(source, tree):
if not (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str)):
continue
# Only a docstring on lines of its own (not "def f(): \"\"\"doc\"\"\"; ...").
if lines[first.lineno - 1][:first.col_offset].strip() or first.lineno == node.lineno:
if _glpython_text_before(lines[first.lineno - 1], first.col_offset).strip() or first.lineno == node.lineno:
continue
ranges.append([first.lineno, 0, first.end_lineno + 1, 0])
return ranges
Expand Down
Loading