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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain
- 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.
- It abbreviates long values (`[1, 2, 3, 4, 5, …, 100]`; levels in `_GLPYTHON_ABBREVIATION_LEVELS`) and notes each full text. The next recorded step gets `fullValues` (`{shown text: index}`), the trace result `values` (`{text, cut, length, kind}`). The UI (`fullValuesUpTo`, `ValueText`, `LocalsValue`, `FullValueDialog`) opens one on click.
- A name indexed in a subscript (`dauer` in `dauer[1]`, `_glpython_indexed`) gets no step of its own, so `dauer[1]` is one step to the element.
- A display (`[…]`, `(…)`, `{…}`) gets no step of its own (`_glpython_record_display`), only where its value reads differently (unpacking, duplicates); nor does a negative number literal (`_glpython_is_negative_number`). Every step must change something.

Worker messages:
- `run` → `result`/`error`; while it runs, `output` messages (`{id, text}`, `id` undefined for a program, the input's id for a REPL input) stream what was printed, at most 50 per 50 ms. The final message brings the whole output again.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,16 @@ the variables panel. The same holds for a tuple, a string or a dict, and for a
slice (`w[1:3]`). A variable passed to a call is still replaced by its value
(`notenschnitt(noten) → notenschnitt([5, 4.5, 6])`), and so is a parameter in a
substituted function's expression. A list written out and indexed directly
(`[1, 2, 30][1]`) keeps its own step.
is one step too: `[1, 2, 30][1] → 2`.

A list, tuple, set or dict written out has no step of its own: once its parts
are values, it reads as its value, as on paper. So a display of literals has
no steps at all, nested at any depth and with negative numbers (`-3`, `-2.5`):
`baum = ["+", ["*", 2, ["-", 7, -3]], 4]` is one step, the assignment. Otherwise
only the parts that change have steps: `[1 + 2, 3, 4] → [3, 3, 4]`,
`["+", 1, ["*", n, 3]] → ["+", 1, ["*", 2, 3]]`. A display keeps its step only
where its value reads differently: unpacking (`[*a, 2] → [1, 2]`) and a set or
dict whose duplicates go (`{1, 1} → {1}`).

A long value is abbreviated, in the step and in the variables panel alike,
keeping its first elements and its last one: `[1, 2, 3, 4, 5, …, 100]`.
Expand Down
31 changes: 31 additions & 0 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// visualizer), and without isolation stops with an error and its hint.
// The visualizer (P39): a named list indexed in one step, a long list
// abbreviated in the step and the variables and shown in full on request.
// A display of literals has no steps, in Ersetzung and Kasten (P40); every
// step changes the code or the variables.
// The run file is the module __main__ (P37), also in the REPL after it.
//
// Drives the built embed (dist-embed/, from npm run build:embed) in Chrome
Expand Down Expand Up @@ -276,6 +278,35 @@ try {
await dialog.waitFor({ state: 'detached', timeout: 5000 })
}
console.log('visualizer: a long list abbreviated, in full on request: ok')

// P40: only the parts that change have steps, in both modes.
const displays = 'baum = ["+", ["*", 2, ["-", 7, -3]], ["/", ["+", 1, 2], 3]]\nr = [-1, 2]\nx = [1 + 2, 3, 4]\na = 5\ny = [a, 2]\nn = 2\nw = ["+", 1, ["*", n, 3]]\n'
for (const mode of ['Kasten', 'Ersetzung']) {
await frame.locator('.visualizer-mode').getByRole('button', { name: mode }).click()
await page.evaluate((source) => window.send('visualize', source), displays)
await frame.locator('.frame-box--root > .frame-code .frame-line').filter({ hasText: 'baum = ' }).waitFor({ timeout: 30000 })
if (!(await stepButton(0).isDisabled())) await stepButton(0).click()
await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 5000 })
const snapshot = async () => JSON.stringify([await lines(), await frame.locator('.locals-row').allInnerTexts()])
const seen = { 1: [], 3: [], 5: [], 7: [] }
let previous = await snapshot()
let steps = 0
while (!(await stepButton(2).isDisabled())) {
await stepButton(2).click()
steps += 1
const current = await snapshot()
assert.notEqual(current, previous, `${mode}, step ${steps}: something changes`)
previous = current
const texts = await lines()
for (const line of [1, 3, 5, 7]) if (seen[line].at(-1) !== texts[line - 1]) seen[line].push(texts[line - 1])
}
assert.equal(steps, 10, `${mode}: seven assignments and three substitutions`)
assert.deepEqual(seen[1], ['baum = ["+", ["*", 2, ["-", 7, -3]], ["/", ["+", 1, 2], 3]]'], `${mode}: the literal tree`)
assert.ok(seen[3].includes('x = [3, 3, 4]'), `${mode}: [1 + 2, 3, 4]: ${JSON.stringify(seen[3])}`)
assert.ok(seen[5].includes('y = [5, 2]'), `${mode}: [a, 2]: ${JSON.stringify(seen[5])}`)
assert.ok(seen[7].includes('w = ["+", 1, ["*", 2, 3]]'), `${mode}: the tree with n: ${JSON.stringify(seen[7])}`)
}
console.log('visualizer: a display of literals has no steps: ok')
} catch (error) {
failed += 1
console.error(error.message)
Expand Down
57 changes: 53 additions & 4 deletions scripts/check-runtime-lists.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Regression test: lists in the visualizer (P39). Indexing a named list is
"""Regression test: lists in the visualizer (P39, P40). Indexing a named list is
one step to the element ("dauer[1]" -> "2", not first "[1, 2, 30][1]"), as
the course writes chains on paper; a named list passed to a call is still
replaced by its value, and a list literal indexed directly keeps its step.
replaced by its value, and a list literal indexed directly is one step.
A display ("[1, 2]", "(a, b)", "{x}", "{k: v}") has no step of its own: of
literals (negative numbers too) it has none at all, otherwise only its parts
that change have one.
A long value is abbreviated ("[1, 2, 3, 4, 5, …, 100]"), the same in the
step and in the variables panel, with its full value in the trace's
"values"; short values and the course's expression trees stay complete.
Expand Down Expand Up @@ -43,8 +46,8 @@ def evals_on_line(result, line):
expect("dauer[1]", evals_on_line(result, 2), [("dauer[1]", "2")])
expect("dauer[0] + dauer[1]", evals_on_line(result, 3), [("dauer[0]", "1"), ("dauer[1]", "2"), ("1 + 2", "3")])
expect("the list in the panel", result["trace"][-1]["locals"]["dauer"], "[1, 2, 30]")
# A literal list indexed directly: its own step, then the element.
expect("[1, 2, 30][1]", evals_on_line(result, 4), [("[1, 2, 30]", "[1, 2, 30]"), ("[1, 2, 30][1]", "2")])
# A literal list indexed directly: one step to the element.
expect("[1, 2, 30][1]", evals_on_line(result, 4), [("[1, 2, 30][1]", "2")])

# The same for a tuple, a string, a dict, a slice, an index that is a
# variable (its value is shown) and a list in a list.
Expand Down Expand Up @@ -95,6 +98,52 @@ def evals_on_line(result, line):
result = trace('baum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]\n')
expect("expression tree: no full value", (result["values"], result["trace"][-1]["locals"]["baum"]), ([], "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]"))

# A display of literals is a value: no steps but the assignment, nested at
# any depth, negative numbers included (P40).
def steps_on_line(result, line):
return [(step["kind"], step["label"], step.get("valueText")) for step in result["trace"] if step["line"] == line]

LITERALS = [
'baum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]',
'baum = ["-", 7, -3]',
'baum = [-1, 2]',
'baum = (-2.5, ("a", -1j), {"k": [-1, {2, -3}]}, {-4})',
'baum = -3',
]
for line in LITERALS:
result = trace(line + "\n")
expect(f"no steps: {line}", [kind for kind, _, _ in steps_on_line(result, 1)], ["exec"])
expect(f"its value: {line}", result["trace"][-1]["locals"]["baum"], repr(eval(line.split(" = ", 1)[1])))

# Names, calls and operators keep their steps; the display builds none.
for setup, line, expected, value in [
("", "w = [1 + 2, 3, 4]", [("1 + 2", "3")], "[3, 3, 4]"),
("a = 5", "w = [a, 2]", [("a", "5")], "[5, 2]"),
("x = 7", "w = [[1, 2], x]", [("x", "7")], "[[1, 2], 7]"),
("n = 2", 'w = ["+", 1, ["*", n, 3]]', [("n", "2")], "['+', 1, ['*', 2, 3]]"),
("n = 2", "w = (n, -n, {n: [n]})", [("n", "2"), ("n", "2"), ("-2", "-2"), ("n", "2"), ("n", "2")], "(2, -2, {2: [2]})"),
("", "w = [len([1, 2]), 3]", [("len(...)", "2")], "[2, 3]"),
]:
result = trace(f"{setup}\n{line}\n")
expect(f"one step each: {line}", evals_on_line(result, 2), expected)
expect(f"the assignment: {line}", [label for kind, label, _ in steps_on_line(result, 2) if kind == "exec"], [f"w = {value}"])

# Where the value reads differently from the display, the display has its
# step: unpacking, and duplicates in a set or dict. Unpacking as in Python.
for setup, line, expected in [
("b = [1]", "w = [*b, 2]", [("b", "[1]"), ("[*b, 2]", "[1, 2]")]),
("b = [1]", "w = (*b, 2)", [("b", "[1]"), ("(*b, 2)", "(1, 2)")]),
("b = [1]", "w = {*b, 2}", [("b", "[1]"), ("{*b, 2}", "{1, 2}")]),
('d = {"k": 1}', 'w = {**d, "j": 2}', [("d", "{'k': 1}"), ('{**d, "j": 2}', "{'k': 1, 'j': 2}")]),
("", "w = {1, 1}", [("{1, 1}", "{1}")]),
("", 'w = {"a": 1, "a": 2}', [('{"a": 1, "a": 2}', "{'a': 2}")]),
]:
result = trace(f"{setup}\n{line}\n")
expect(f"display step: {line}", evals_on_line(result, 2), expected)
namespace_python = {}
exec(f"{setup}\n{line}\n", namespace_python)
expect(f"value: {line}", result["trace"][-1]["locals"]["w"], repr(namespace_python["w"]))

# How other long values are abbreviated: dicts, sets and tuples as lists,
# a list in a list, long strings in their middle, recursion as Python shows it.
for value, expected in [
Expand Down
52 changes: 40 additions & 12 deletions src/runtime/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,10 +600,16 @@ def _eval_binop(node, scope):
_glpython_record(node, "eval", f"{_glpython_safe_repr(left, 40)} {symbol} {_glpython_safe_repr(right, 40)}", _glpython_safe_repr(value), _glpython_current_func())
return value

# A negative number ("-3", "-2.5") is a literal, as on paper: no step.
def _glpython_is_negative_number(node):
return isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) and isinstance(node.operand, ast.Constant) and type(node.operand.value) in (int, float, complex)

def _eval_unaryop(node, scope):
operand = _glpython_eval(node.operand, scope)
op_func = _GLPYTHON_UNARY_OPS.get(type(node.op))
value = op_func(operand) if op_func else _glpython_fallback_eval(node, scope)
if _glpython_is_negative_number(node):
return value
symbol = _GLPYTHON_OP_SYMBOLS.get(type(node.op), "?")
_glpython_record(node, "eval", f"{symbol}{_glpython_safe_repr(operand, 40)}", _glpython_safe_repr(value), _glpython_current_func())
return value
Expand Down Expand Up @@ -647,30 +653,52 @@ def _eval_ifexp(node, scope):
_glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(value), _glpython_current_func())
return value

def _eval_list(node, scope):
values = [_glpython_eval(item, scope) for item in node.elts]
_glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(values), _glpython_current_func())
# A display ("[1, 2]", "(a, b)", "{x}", "{k: v}") has no step of its own:
# once its parts are values, it already reads as its value, as on paper - so
# a display of literals has no steps at all, and "[1 + 2, 3]" only the one of
# "1 + 2". Only where the value reads differently is there a step:
# unpacking ("[*a, 2]", "{**d}") and a set or dict whose duplicates go.
def _glpython_record_display(node, value, rewrites):
if rewrites:
_glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(value), _glpython_current_func())
return value

def _glpython_display_items(node, scope):
values = []
for item in node.elts:
if isinstance(item, ast.Starred):
values.extend(_glpython_eval(item, scope))
else:
values.append(_glpython_eval(item, scope))
return values

def _glpython_has_starred(node):
return any(isinstance(item, ast.Starred) for item in node.elts)

def _eval_list(node, scope):
return _glpython_record_display(node, _glpython_display_items(node, scope), _glpython_has_starred(node))

def _eval_tuple(node, scope):
values = tuple(_glpython_eval(item, scope) for item in node.elts)
_glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(values), _glpython_current_func())
return values
return _glpython_record_display(node, tuple(_glpython_display_items(node, scope)), _glpython_has_starred(node))

def _eval_set(node, scope):
values = {_glpython_eval(item, scope) for item in node.elts}
_glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(values), _glpython_current_func())
return values
values = _glpython_display_items(node, scope)
result = set(values)
return _glpython_record_display(node, result, _glpython_has_starred(node) or len(result) < len(values))

def _eval_dict(node, scope):
result = {}
entries = 0
for key_node, value_node in zip(node.keys, node.values):
if key_node is None:
result.update(_glpython_eval(value_node, scope))
entries = None
else:
result[_glpython_eval(key_node, scope)] = _glpython_eval(value_node, scope)
_glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(result), _glpython_current_func())
return result
key = _glpython_eval(key_node, scope)
result[key] = _glpython_eval(value_node, scope)
if entries is not None:
entries += 1
return _glpython_record_display(node, result, entries is None or len(result) < entries)

def _eval_attribute(node, scope):
target = _glpython_eval(node.value, scope)
Expand Down
Loading