The Python binding (kpy/): klammertext.Machine from a script
Assembled from klammertext-dev ed13dfaa5b7a. kpy/klammertext.cpp is a CPython extension module giving a Python script an instance of the Machine class -- ktext in-process, set up through the same functions the command uses. Errors map one-to-one to Python exceptions under the C++ names; an @eval's Python exception is chained as __cause__. Built by make -C mac, found through PYTHONPATH (env/runtime.env sets it), tested by tst/kpy_test.sh. Three engine changes came with it: Error::message(), set_command_state(), and the @eval error report carrying its traceback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uv7KWxafinDPraswEf4GsC
This commit is contained in:
1 parent
e4f1307aa9
commit
760fda5470
17 files changed
+1241
-78
No files matched your search
Executable
+384
@@ -0,0 +1,384 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# kpy_test.sh — the Python binding of the Klammermachine (kpy/).
|
||||
#
|
||||
# The binding is ktext in-process: a klammertext.Machine is set up through
|
||||
# the same set_command_state() and load_klammersets() the command uses, so
|
||||
# what this suite asserts is the CONTRACT the binding adds on top of the
|
||||
# engine, not the engine itself (the other suites do that):
|
||||
#
|
||||
# * the module's surface -- Machine, Error and the nine subclasses under
|
||||
# the C++ names, home;
|
||||
# * read()/read_file()/read_files()/apply()/write() behaving as the
|
||||
# command-line forms they mirror (-s, a file argument, -t, -o);
|
||||
# * the state crossing (set/get/names) and the ISOLATION of a script's
|
||||
# globals from a document's @eval code;
|
||||
# * the error conversion: the C++ kind decides the Python class, the
|
||||
# location becomes attributes, str(e) is the report ktext prints, and an
|
||||
# @eval that raised a Python exception has it as __cause__ with its
|
||||
# traceback -- the one place the conversion needed real work;
|
||||
# * the one-Machine rule and its release on garbage collection;
|
||||
# * the output policy: a script's stdout carries only what the script
|
||||
# printed, logging goes to stderr and only under verbose=.
|
||||
#
|
||||
# Engine tier: every case loads klammersets="none" and declares any target it
|
||||
# needs inline. The one exception asserts the DEFAULT -- that no
|
||||
# klammersets= loads the SKS -- by the -v 1 log line, as verbosity_test.sh
|
||||
# does, and applies no SKS klammer.
|
||||
#
|
||||
# Usage: ./kpy_test.sh (needs KLAMMERTEXT_HOME set and kpy/ built)
|
||||
# Exit code: 0 if all tests pass, 1 otherwise.
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
|
||||
PYTHON=${PYTHON:-python3}
|
||||
|
||||
red=$'\033[31m'
|
||||
green=$'\033[32m'
|
||||
bold=$'\033[1m'
|
||||
reset=$'\033[0m'
|
||||
|
||||
OUTF=$(mktemp /tmp/kpy_out.XXXXXX)
|
||||
ERRF=$(mktemp /tmp/kpy_err.XXXXXX)
|
||||
WORK=$(mktemp -d /tmp/kpy_work.XXXXXX)
|
||||
trap 'rm -rf "$OUTF" "$ERRF" "$WORK"' EXIT
|
||||
|
||||
export PYTHONPATH="$K/kpy${PYTHONPATH:+:$PYTHONPATH}"
|
||||
|
||||
# A DEBUG=1 build instruments the module with AddressSanitizer, and an
|
||||
# instrumented extension imported into an uninstrumented python needs the
|
||||
# ASan runtime loaded FIRST -- the import otherwise fails with "ASan runtime
|
||||
# does not come first in initial library list". Preload it when the module
|
||||
# carries the symbol; the runtime's path comes from the compiler that built
|
||||
# the module (gcc's libasan.so, clang's libclang_rt.asan-*.so). libstdc++
|
||||
# is preloaded WITH it: python does not link libstdc++, so at the runtime's
|
||||
# startup there is no __cxa_throw to intercept, and the first C++ exception
|
||||
# the engine throws dies with "CHECK failed: asan_interceptors.cpp ...
|
||||
# real___cxa_throw != 0" (every error case of this suite, 2026-09-08).
|
||||
# Linux only (LD_PRELOAD); macOS ASan runs are not supported here.
|
||||
MODULE=$(ls "$K"/kpy/klammertext.*.so 2>/dev/null | head -1)
|
||||
if [ -z "$MODULE" ]; then
|
||||
echo "${red}FAIL${reset} kpy/ is not built (no kpy/klammertext.*.so)"
|
||||
exit 1
|
||||
fi
|
||||
if nm -D "$MODULE" 2>/dev/null | grep -q '__asan_init'; then
|
||||
for rt in "$(gcc -print-file-name=libasan.so 2>/dev/null)" \
|
||||
"$(clang -print-file-name=libclang_rt.asan-x86_64.so 2>/dev/null)" \
|
||||
"$(clang -print-file-name=libclang_rt.asan-aarch64.so 2>/dev/null)"; do
|
||||
if [ -n "$rt" ] && [ -f "$rt" ]; then
|
||||
stdcxx=$(g++ -print-file-name=libstdc++.so 2>/dev/null)
|
||||
export LD_PRELOAD="$rt${stdcxx:+:$stdcxx}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# A leak report at exit would turn every case's status nonzero; the
|
||||
# interpreter's own leaks are not the binding's.
|
||||
export ASAN_OPTIONS="detect_leaks=0${ASAN_OPTIONS:+:$ASAN_OPTIONS}"
|
||||
fi
|
||||
|
||||
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
|
||||
# run — runs the Python program on stdin; sets STATUS, fills OUTF/ERRF.
|
||||
STATUS=0
|
||||
run() {
|
||||
(cd "$WORK" && "$PYTHON" - >"$OUTF" 2>"$ERRF")
|
||||
STATUS=$?
|
||||
}
|
||||
|
||||
pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# expect LABEL REGEX — the program exited 0 and its stdout matches REGEX
|
||||
# (wrap-insensitively: newlines collapse to spaces).
|
||||
expect() {
|
||||
local label="$1" rgx="$2"
|
||||
if [ "$STATUS" -ne 0 ]; then
|
||||
fail "$label" "exit status $STATUS; stderr: $(plain < "$ERRF" | tr '\n' ' ' | head -c 300)"
|
||||
elif plain < "$OUTF" | tr '\n' ' ' | tr -s ' ' | grep -qE "$rgx"; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label" "stdout: $(plain < "$OUTF" | tr '\n' ' ' | head -c 300)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "${bold}Python binding tests${reset}"
|
||||
echo "===================="
|
||||
echo
|
||||
|
||||
# --- 1. The module's surface ------------------------------------------------
|
||||
|
||||
run <<'EOF'
|
||||
import os, klammertext as kt
|
||||
names = ["Parsing_error", "File_error", "Target_error", "Klammerset_error",
|
||||
"Definition_error", "Argument_error", "Environment_error",
|
||||
"Recursion_error", "Internal_error"]
|
||||
ok = (issubclass(kt.Error, Exception)
|
||||
and all(issubclass(getattr(kt, n), kt.Error) for n in names)
|
||||
and kt.home == os.environ["KLAMMERTEXT_HOME"]
|
||||
and callable(kt.Machine))
|
||||
print("surface", ok)
|
||||
EOF
|
||||
expect " 1. Machine, Error, the nine subclasses, home" 'surface True'
|
||||
|
||||
# --- 2. Reading and applying ------------------------------------------------
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@@x : hello @@")
|
||||
M.read("@x@")
|
||||
print("result", repr(M.apply()))
|
||||
EOF
|
||||
expect " 2. read() is -s: a definition, then its application" "result 'hello'"
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@eval 6 * 7 @")
|
||||
print("eval", M.apply())
|
||||
EOF
|
||||
expect " 3. @eval runs in the host interpreter" 'eval 42'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
secret = "script-side"
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@eval 'secret' in globals() @")
|
||||
print("isolated", M.apply())
|
||||
EOF
|
||||
expect " 4. a document's @eval does not see the script's globals" 'isolated False'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.set("Name", "Andy")
|
||||
M.read("*Name*")
|
||||
print("subst", M.apply(), "|", M.get("K_target"), "|", "K_input_dir" in M.names())
|
||||
EOF
|
||||
expect " 5. set()/get()/names(): data crosses through the state" 'subst Andy \| \* \| True'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@@@target one | fixture @@@ @@@target two | fixture @@@")
|
||||
M.read("@@w.one : ONE @@ @@w.two : TWO @@ @w@")
|
||||
first = M.apply("one")
|
||||
del M
|
||||
N = kt.Machine(klammersets="none")
|
||||
N.read("@@@target one | fixture @@@ @@@target two | fixture @@@")
|
||||
N.read("@@w.one : ONE @@ @@w.two : TWO @@ @w@")
|
||||
print("targets", first, N.apply("two"))
|
||||
EOF
|
||||
expect " 6. apply(target) overrides the constructor's target; one Machine per target" 'targets ONE TWO'
|
||||
|
||||
# apply() reduces the katoms IN PLACE, as the engine does for ktext's single
|
||||
# apply: a second apply() on the same Machine cannot re-render under another
|
||||
# target (case 6 makes a second Machine for that), but it may follow further
|
||||
# reading, and then returns everything read so far.
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@eval 'first' @")
|
||||
a = M.apply()
|
||||
M.read("@eval 'second' @")
|
||||
b = M.apply()
|
||||
print("accumulate", repr(a), repr(b.replace("\n", "|")))
|
||||
EOF
|
||||
expect " 6a. apply() after more reading returns everything read so far" "accumulate 'first' 'first\\|\\|second'"
|
||||
|
||||
# --- 3. Files and output ----------------------------------------------------
|
||||
|
||||
cat > "$WORK/doc.kt" <<'EOF'
|
||||
@@@target txt | fixture @@@
|
||||
@@greet : hello from a file @@
|
||||
@greet@
|
||||
EOF
|
||||
|
||||
run <<'EOF'
|
||||
import os, klammertext as kt
|
||||
M = kt.Machine(klammersets="none", target="txt", files=["doc.kt"])
|
||||
M.read_files()
|
||||
out = M.output_filename
|
||||
print("files", M.apply(), "|", os.path.basename(out), "|", os.path.dirname(out) == os.getcwd())
|
||||
EOF
|
||||
expect " 7. files= and read_files(); output_filename beside the input" 'files hello from a file \| doc.txt \| True'
|
||||
|
||||
run <<'EOF'
|
||||
import os, klammertext as kt
|
||||
M = kt.Machine(klammersets="none", target="txt", files=["doc.kt"])
|
||||
M.read_file("doc.kt")
|
||||
M.apply()
|
||||
written = M.write()
|
||||
print("write", written == M.output_filename, repr(open(written).read()))
|
||||
EOF
|
||||
expect " 8. write() writes the result plus a newline and returns the name" "write True 'hello from a file\\\\n'"
|
||||
|
||||
run <<'EOF'
|
||||
import os, klammertext as kt
|
||||
M = kt.Machine(klammersets="none", target="txt", files=["doc.kt"], output="out/renamed")
|
||||
M.read_file("doc.kt")
|
||||
M.apply()
|
||||
print("output", os.path.relpath(M.write()), os.path.exists("out/renamed.txt"))
|
||||
EOF
|
||||
expect " 9. output= is -o: directory plus basename" 'output out/renamed.txt True'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@@@target t | fixture @@@")
|
||||
M.read("#[ nothing survives ]#")
|
||||
print("empty", M.write(), M.apply("t") == "")
|
||||
EOF
|
||||
expect "10. write() of an empty result writes nothing and returns None" 'empty None True'
|
||||
|
||||
run <<'EOF'
|
||||
import os, klammertext as kt
|
||||
os.makedirs("sub", exist_ok=True)
|
||||
open("sub/inc.kt", "w").write("included text\n")
|
||||
os.chdir("sub")
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@read inc.kt @")
|
||||
print("relative", M.apply())
|
||||
EOF
|
||||
expect "11. a relative @read in read() text resolves against the cwd" 'relative included text'
|
||||
|
||||
# --- 4. Errors --------------------------------------------------------------
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
try:
|
||||
M.read("@nosuch x @")
|
||||
M.apply()
|
||||
print("no error")
|
||||
except kt.Error as e:
|
||||
print("error", type(e).__name__, e.kind, e.line, e.column,
|
||||
str(e).startswith("klammertext (definition error)"),
|
||||
"is not defined" in e.description)
|
||||
EOF
|
||||
expect "12. an undefined klammer: Definition_error with kind, line, column, report" 'error Definition_error definition 1 1 True True'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
try:
|
||||
kt.Machine(klammersets="none", files=["missing.kt"]).read_files()
|
||||
except kt.File_error as e:
|
||||
print("file", e.kind, e.filename, "missing.kt" in e.description)
|
||||
EOF
|
||||
expect "13. a missing file: File_error, no location" 'file file None True'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
try:
|
||||
M.read("@eval 1/0 @")
|
||||
except kt.Parsing_error as e:
|
||||
c = e.__cause__
|
||||
print("cause", type(c).__name__, c.__traceback__ is not None,
|
||||
"ZeroDivisionError" in e.description)
|
||||
EOF
|
||||
expect "14. a Python exception in @eval is the Parsing_error's __cause__, with traceback" 'cause ZeroDivisionError True True'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
try:
|
||||
M.read("@eval nosuchmodule.f() @")
|
||||
except kt.Argument_error as e:
|
||||
print("import", type(e.__cause__).__name__)
|
||||
EOF
|
||||
expect "15. a failed module import in @eval chains the ModuleNotFoundError" 'import ModuleNotFoundError'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
try:
|
||||
M.read("@@f : x @f@ @@ @f@")
|
||||
M.apply()
|
||||
except kt.Recursion_error as e:
|
||||
print("recursion", e.kind)
|
||||
EOF
|
||||
expect "16. unbounded recursion: Recursion_error (the C++ name, not the builtin)" 'recursion recursion'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
try:
|
||||
kt.Machine(klammersets=["none", "sks"])
|
||||
except kt.Klammerset_error as e:
|
||||
print("none", "cannot be combined" in e.description)
|
||||
EOF
|
||||
expect "17. \"none\" combined with a symbol: Klammerset_error, the command's rule" 'none True'
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
try:
|
||||
kt.Machine(klammersets="none", files=[1, 2])
|
||||
except TypeError as e:
|
||||
print("type", "files" in str(e))
|
||||
EOF
|
||||
expect "18. a wrong argument type is a TypeError, not a Klammertext error" 'type True'
|
||||
|
||||
# --- 5. One Machine at a time -----------------------------------------------
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
try:
|
||||
kt.Machine(klammersets="none")
|
||||
print("second allowed")
|
||||
except RuntimeError as e:
|
||||
print("second", "Only one" in str(e))
|
||||
del M
|
||||
N = kt.Machine(klammersets="none")
|
||||
N.read("@eval 'again' @")
|
||||
print("after del", N.apply())
|
||||
EOF
|
||||
expect "19. a second Machine is a RuntimeError; a new one works after del" 'second True after del again'
|
||||
|
||||
# --- 6. The output policy ---------------------------------------------------
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
M.read("@eval 1 + 1 @")
|
||||
print("quiet", M.apply())
|
||||
EOF
|
||||
expect "20. verbose=0: a successful run says nothing on stderr" 'quiet 2'
|
||||
if [ "$STATUS" -eq 0 ] && [ ! -s "$ERRF" ]; then
|
||||
pass "20a. ... stderr is empty"
|
||||
else
|
||||
fail "20a. ... stderr is empty" "stderr: $(plain < "$ERRF" | head -c 200)"
|
||||
fi
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(verbose=1)
|
||||
print("default", M.get("K_verbose_level"))
|
||||
EOF
|
||||
expect "21. no klammersets=: the SKS is the default (stdout clean)" '^default 1 $'
|
||||
if plain < "$ERRF" | grep -q 'Klammerset (default): .*sks/sks\.k'; then
|
||||
pass "21a. ... and verbose=1 reports the file it loaded, on stderr"
|
||||
else
|
||||
fail "21a. ... and verbose=1 reports the file it loaded, on stderr" "stderr: $(plain < "$ERRF" | head -c 300)"
|
||||
fi
|
||||
|
||||
run <<'EOF'
|
||||
import klammertext as kt
|
||||
M = kt.Machine(klammersets="none")
|
||||
try:
|
||||
M.read("@eval 1/0 @")
|
||||
except kt.Error:
|
||||
pass
|
||||
print("caught")
|
||||
EOF
|
||||
expect "22. a caught error prints nothing itself: no traceback, no report" '^caught $'
|
||||
if [ ! -s "$ERRF" ]; then
|
||||
pass "22a. ... stderr is empty"
|
||||
else
|
||||
fail "22a. ... stderr is empty" "stderr: $(plain < "$ERRF" | head -c 300)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Passed: $PASS, Failed: $FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in new issue
Block a user