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:
ackandClaude Fable 5.1 committed 2026-09-08 16:54:21 +02:00
1 parent e4f1307aa9
commit 760fda5470
17 files changed
+1241 -78

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot. applied to the development tree and appear in a following snapshot.
This snapshot was assembled from development commit `6594c7ff8c1f`. This snapshot was assembled from development commit `ed13dfaa5b7a`.
## License ## License
+1
View File
@@ -96,6 +96,7 @@ else
# it shares the class layouts: a stale one after a header change is # it shares the class layouts: a stale one after a header change is
# the same memory-corruption hazard as a stale sks/document.so. # the same memory-corruption hazard as a stale sks/document.so.
$(MAKE) -C $(K)/ref clean $(MAKE) -C $(K)/ref clean
$(MAKE) -C $(K)/kpy clean
$(MAKE) clean $(MAKE) clean
$(MAKE) all $(MAKE) all
endif endif
+2 -18
View File
@@ -58,24 +58,8 @@ int main(int argc, char* argv[])
expand_tilde(args.as_string("o")), args.as_bool("d")); expand_tilde(args.as_string("o")), args.as_bool("d"));
Machine M; Machine M;
M.m_state.open_frame("ktext"); set_command_state(M, "ktext", target, input_filenames, output_dir,
M.m_state.set("K_target", target); output_basename, display_only);
M.m_state.set("K_output_dir", output_dir);
M.m_state.set("K_output_basename", output_basename);
M.m_state.set("K_stdout_only", display_only ? "true" : "false");
// The list form uses the standalone-"/" separator (filenames may
// contain spaces); K_input_filename is the single root input file.
M.m_state.set("K_input_filenames", join(input_filenames, " / "));
M.m_state.set("K_input_filename",
input_filenames.empty() ? "" : input_filenames[0]);
M.m_state.set("K_verbose_level", std::to_string(verbose_level));
if (!input_filenames.empty()) {
M.m_state.set(
"K_input_dir",absolute_pathname(file_directory(input_filenames[0])));
} else {
M.m_state.set("K_input_dir", fs::current_path().string());
}
load_klammersets(M, word_split(args.as_string("klammersets"))); load_klammersets(M, word_split(args.as_string("klammersets")));
+6
View File
@@ -42,6 +42,12 @@ unset _kt_tl _kt_arch
# --- PATH (TeX Live appended only if one was found) --------------------------- # --- PATH (TeX Live appended only if one was found) ---------------------------
export PATH="$KLAMMERTEXT_HOME/bin:$KLAMMERTEXT_HOME/tst${KLAMMERTEXT_TEXLIVE_BIN:+:$KLAMMERTEXT_TEXLIVE_BIN}:$PATH" export PATH="$KLAMMERTEXT_HOME/bin:$KLAMMERTEXT_HOME/tst${KLAMMERTEXT_TEXLIVE_BIN:+:$KLAMMERTEXT_TEXLIVE_BIN}:$PATH"
# --- The Python binding (kpy/) -----------------------------------------------
# "import klammertext" with no setup, as the container image already arranges
# through its own PYTHONPATH. Prepended, so a source build shadows any other
# copy; the module is bound to the Python the library was built against.
export PYTHONPATH="$KLAMMERTEXT_HOME/kpy${PYTHONPATH:+:$PYTHONPATH}"
# --- Per-platform: LSan suppressions + shared library search path ------------- # --- Per-platform: LSan suppressions + shared library search path -------------
# The shared library path deliberately includes ONLY Klammertext's own .so dirs, # The shared library path deliberately includes ONLY Klammertext's own .so dirs,
# NOT third-party bundled-library dirs. In particular NVIDIA iray's platforms/ # NOT third-party bundled-library dirs. In particular NVIDIA iray's platforms/
+51
View File
@@ -0,0 +1,51 @@
# kpy/ -- the Python binding of the Klammermachine.
#
# klammertext.cpp is a CPython extension module (see its header comment).
# Like ref/reflect.so and sks/document/document.so it links against
# libklammertext.so and shares the engine's class layouts, so it is rebuilt
# whenever they are (mac/Makefile's "all" runs "make -C kpy"; com/Makefile's
# "redo" and dbg/rebuild.sh clean it -- a clean that misses a directory is a
# mixed-object hazard, not a tidiness one).
#
# The module's filename carries the interpreter's ABI tag
# (klammertext.cpython-3XX-<arch>.so) from python3-config, the same tool
# makefile.env takes the include and link flags from: the module is bound to
# the ONE Python the library was built against, by decision. The library
# already links that libpython, so the link line is the commands' link line
# with $(SHARED) in place of $(EXPORT_DYNAMIC).
K := $(KLAMMERTEXT_HOME)
include $(K)/env/makefile.env
LIBDIR := $(K)/lib
LIBRARY := $(LIBDIR)/libklammertext.so
EXT_SUFFIX := $(shell python3-config --extension-suffix)
MODULE := klammertext$(EXT_SUFFIX)
# "../lib": the module sits in $(K)/kpy, one level below $(K).
KPY_LDFLAGS := $(SHARED) -Wl,-rpath,'$(ORIGIN)/../lib' -L$(LIBDIR)
OBJECTS := klammertext.o
DEPFILES := $(OBJECTS:.o=.d)
DEPFLAGS = -MMD -MP -MF $(@:.o=.d)
.PHONY: all clean
all : $(MODULE)
%.o : %.cpp
@echo Compiling $<
$(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $(DEPFLAGS) $< -o $@
$(MODULE) : $(OBJECTS) $(LIBRARY)
$(CXX) $(CPPFLAGS) $(CXXFLAGS) $(KPY_LDFLAGS) $(LDFLAGS) \
-o $@ $(OBJECTS) -lklammertext $(LDLIBS)
clean :
rm -f $(OBJECTS) $(DEPFILES) klammertext.*.so *~
rm -rf __pycache__
-include $(DEPFILES)
+619
View File
@@ -0,0 +1,619 @@
// kpy/klammertext.cpp -- the Python binding of the Klammermachine.
//
// A CPython extension module, written against the C API by hand: the
// project takes no third-party C++ library, which rules out pybind11 and
// nanobind, and the tree already links libpython for @eval, so this adds no
// dependency. It is ktext running in-process:
//
// import klammertext
// M = klammertext.Machine(target="html", files=["doc.kt"])
// M.read("@@version : 3.2.1 @@") # what "ktext -s" does
// M.read_files() # the constructor's files, in order
// html = M.apply()
//
// The constructor does what the top of ktext's main() does -- the same
// K_* state, through the same set_command_state(), and the same klammerset
// loading through load_klammersets() -- so a document that renders under
// ktext renders under the binding, and the two cannot drift apart because
// neither restates the other.
//
// Decisions (Andy, 2026-09-08), each of which made the binding smaller:
//
// * ONE Machine at a time. A second constructor call while one exists
// raises RuntimeError. The engine keeps process-wide state -- the
// klammerset search path is frozen on first use, the per-directory
// Python-file answer is cached per process, the recursion counter is a
// translation-unit static -- and with one Machine every one of them is
// correct by construction. A new Machine may be created once the old
// one is garbage-collected.
// * ONE Python version: the one the library was built against. No stable
// ABI, no wheels; the module is found on PYTHONPATH.
// * Errors map ONE-TO-ONE. The C++ Error hierarchy is a base with a kind
// string and nine subclasses that add nothing but their kind, so the
// Python side is klammertext.Error with nine subclasses under the C++
// NAMES VERBATIM (Parsing_error, ...): an identity mapping is the
// easiest kind to document, and two of the nine (Environment_error,
// Recursion_error) would otherwise shadow Python builtins. str(e) is
// the report text ktext prints; e.kind, e.description, e.filename,
// e.line, e.column carry the parts. An @eval that raised a Python
// exception arrives as a Parsing_error whose __cause__ IS that
// exception, traceback attached (Eval_python::take_last_exception).
// * Non-standard behaviour is documented, not removed: @eval appends to
// the host's sys.path, logging goes to stderr under the verbose level,
// and the constructor's verbose= sets the process-wide level.
//
// The Machine's @eval runs in the host interpreter (Eval_python checks
// Py_IsInitialized) in globals of its own, so a script's variables and a
// document's @eval code do not see each other; data crosses through the
// state (M.set), exactly as it crosses ktext's command line through -s.
#include <Python.h>
#include <map>
#include <string>
#include <vector>
#include "command.h"
#include "error.h"
#include "eval_python.h"
#include "file.h"
#include "log.h"
#include "machine.h"
#include "target_registry.h"
#include "util.h"
namespace {
// ---------------------------------------------------------------------------
// The exception classes
// ---------------------------------------------------------------------------
PyObject* error_base = nullptr;
std::map<std::string, PyObject*> error_classes {}; // kind -> class
// The nine C++ subclasses, by the kind string their constructors pass to
// Error(), in error.h's order. A kind not listed here (there is none today)
// would arrive as the base class, so nothing is ever lost.
const std::vector<std::pair<std::string, std::string>> error_kinds = {
{"parsing", "Parsing_error"},
{"file", "File_error"},
{"target", "Target_error"},
{"klammerset", "Klammerset_error"},
{"definition", "Definition_error"},
{"argument", "Argument_error"},
{"environment", "Environment_error"},
{"recursion", "Recursion_error"},
{"internal", "Internal_error"},
};
void set_attr(PyObject* obj, const char* name, PyObject* value)
{
// value: a NEW reference, consumed here (nullptr -> None).
if (!value) {
Py_INCREF(Py_None);
value = Py_None;
}
PyObject_SetAttrString(obj, name, value);
Py_DECREF(value);
}
// Raise the Python exception for a C++ Error. Always returns nullptr, so a
// method can write "return raise_error(e);".
PyObject* raise_error(const Error& e)
{
auto found = error_classes.find(e.m_type);
PyObject* cls = found == error_classes.end() ? error_base : found->second;
PyObject* inst = PyObject_CallFunction(cls, "s", e.message().c_str());
if (!inst) {
return nullptr;
}
set_attr(inst, "kind", PyUnicode_FromString(e.m_type.c_str()));
set_attr(inst, "description", PyUnicode_FromString(justify(e.m_desc, 80, 0).c_str()));
set_attr(inst, "filename",
e.m_loc.m_filename.empty() ? nullptr : PyUnicode_FromString(e.m_loc.m_filename.c_str()));
set_attr(inst, "line", e.m_loc.m_line > -1 ? PyLong_FromLong(e.m_loc.m_line) : nullptr);
// 1-based, as the report prints it (the Locator holds a 0-based index).
set_attr(inst, "column", e.m_loc.m_chr > -1 ? PyLong_FromLong(e.m_loc.m_chr + 1) : nullptr);
if (PyObject* cause = Eval_python::take_last_exception()) {
PyException_SetCause(inst, cause); // steals the reference
}
PyErr_SetObject(cls, inst);
Py_DECREF(inst);
return nullptr;
}
// Run f() and translate anything it throws. Every entry point from Python
// goes through here: a C++ exception must never reach the interpreter.
template <typename F>
PyObject* guarded(F f)
{
try {
return f();
}
catch (const Error& e) {
return raise_error(e);
}
catch (const std::exception& e) {
PyErr_SetString(PyExc_RuntimeError, e.what());
return nullptr;
}
}
// ---------------------------------------------------------------------------
// Argument conversion
// ---------------------------------------------------------------------------
// None -> empty; str -> its words; a sequence of str -> the strings. Used
// for klammersets= and files=, both of which ktext takes as word lists.
bool strings_from(PyObject* obj, const char* what, std::vector<std::string>& out)
{
out.clear();
if (!obj || obj == Py_None) {
return true;
}
if (PyUnicode_Check(obj)) {
out = word_split(PyUnicode_AsUTF8(obj));
return true;
}
PyObject* seq = PySequence_Fast(obj, "expected a string or a sequence of strings");
if (!seq) {
return false;
}
Py_ssize_t n = PySequence_Fast_GET_SIZE(seq);
for (Py_ssize_t i = 0; i < n; ++i) {
PyObject* item = PySequence_Fast_GET_ITEM(seq, i); // borrowed
if (!PyUnicode_Check(item)) {
Py_DECREF(seq);
PyErr_Format(PyExc_TypeError, "%s: element %zd is not a string", what, i);
return false;
}
out.push_back(PyUnicode_AsUTF8(item));
}
Py_DECREF(seq);
return true;
}
// None -> "", str -> itself.
bool string_from(PyObject* obj, const char* what, std::string& out)
{
out.clear();
if (!obj || obj == Py_None) {
return true;
}
if (!PyUnicode_Check(obj)) {
PyErr_Format(PyExc_TypeError, "%s must be a string or None", what);
return false;
}
out = PyUnicode_AsUTF8(obj);
return true;
}
PyObject* string_list(const std::vector<std::string>& v)
{
PyObject* list = PyList_New(Py_ssize_t(v.size()));
for (size_t i = 0; i < v.size(); ++i) {
PyList_SET_ITEM(list, Py_ssize_t(i), PyUnicode_FromString(v[i].c_str()));
}
return list;
}
// ---------------------------------------------------------------------------
// The Machine type
// ---------------------------------------------------------------------------
struct Machine_object {
PyObject_HEAD
Machine* machine;
std::string* target; // the constructor's target
std::string* output_filename; // as parse_args derived it
std::vector<std::string>* files;
};
int instances = 0;
// The pseudo source file for string input: a file IN THE CWD named after the
// module, so a relative @read in read() text resolves against the directory
// the script runs in -- the same rule ktext applies to -s input, and set at
// each read() because a script may chdir between two of them.
void set_string_source()
{
command_pathname = (fs::current_path() / "klammertext").string();
}
PyObject* Machine_new(PyTypeObject* type, PyObject*, PyObject*)
{
if (instances > 0) {
PyErr_SetString(
PyExc_RuntimeError,
"Only one klammertext.Machine may exist at a time: the engine keeps "
"process-wide state (the klammerset search path, the module-directory "
"cache, the recursion counter). Delete the existing Machine first.");
return nullptr;
}
auto* self = reinterpret_cast<Machine_object*>(type->tp_alloc(type, 0));
if (!self) {
return nullptr;
}
self->machine = nullptr;
self->target = new std::string();
self->output_filename = new std::string();
self->files = new std::vector<std::string>();
++instances;
return reinterpret_cast<PyObject*>(self);
}
void Machine_dealloc(PyObject* obj)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
delete self->machine;
delete self->target;
delete self->output_filename;
delete self->files;
--instances;
Py_TYPE(obj)->tp_free(obj);
}
int Machine_init(PyObject* obj, PyObject* args, PyObject* kwargs)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
static const char* kwlist[] = {
"target", "klammersets", "files", "output", "display", "verbose", nullptr};
const char* target_arg = nullptr;
PyObject* klammersets_arg = nullptr;
PyObject* files_arg = nullptr;
PyObject* output_arg = nullptr;
int display = 0;
int verbose = 0;
if (!PyArg_ParseTupleAndKeywords(
args, kwargs, "|sOOOpi", const_cast<char**>(kwlist),
&target_arg, &klammersets_arg, &files_arg, &output_arg, &display, &verbose)) {
return -1;
}
std::vector<std::string> klammersets {};
std::vector<std::string> files {};
std::string output {};
if (!strings_from(klammersets_arg, "klammersets", klammersets)
|| !strings_from(files_arg, "files", files)
|| !string_from(output_arg, "output", output)) {
return -1;
}
PyObject* result = guarded([&]() -> PyObject* {
verbose_level = verbose;
std::string target = target_arg ? target_arg : Target_registry::general_name;
// argv boundaries do not exist here -- a list element is one
// filename -- but the standalone-"/" list form and the existence
// rescue for a single space-containing string still apply.
files = group_filename_tokens(files);
for (auto& f : files) {
f = expand_tilde(f);
}
auto [output_target, output_dir, output_basename, output_filename,
write_files, display_only] =
parse_args(files, target, expand_tilde(output), display != 0);
(void)write_files;
delete self->machine;
self->machine = new Machine();
set_command_state(*self->machine, "klammertext", output_target, files,
output_dir, output_basename, display_only);
load_klammersets(*self->machine, klammersets);
*self->target = output_target;
*self->output_filename = output_filename;
*self->files = files;
Py_RETURN_NONE;
});
if (!result) {
return -1;
}
Py_DECREF(result);
return 0;
}
Machine& machine_of(PyObject* obj)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
if (!self->machine) {
throw Internal_error("The Machine was not initialized", Locator::none());
}
return *self->machine;
}
PyObject* Machine_read(PyObject* obj, PyObject* arg)
{
if (!PyUnicode_Check(arg)) {
PyErr_SetString(PyExc_TypeError, "read() takes the text to read, a string");
return nullptr;
}
std::string text = PyUnicode_AsUTF8(arg);
return guarded([&]() -> PyObject* {
set_string_source();
machine_of(obj).read(text + "\n");
Py_RETURN_NONE;
});
}
PyObject* Machine_read_file(PyObject* obj, PyObject* arg)
{
if (!PyUnicode_Check(arg)) {
PyErr_SetString(PyExc_TypeError, "read_file() takes a filename, a string");
return nullptr;
}
std::string filename = expand_tilde(PyUnicode_AsUTF8(arg));
return guarded([&]() -> PyObject* {
machine_of(obj).read(fs::path(filename));
Py_RETURN_NONE;
});
}
PyObject* Machine_read_files(PyObject* obj, PyObject*)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
return guarded([&]() -> PyObject* {
for (const auto& f : *self->files) {
machine_of(obj).read(fs::path(f));
}
Py_RETURN_NONE;
});
}
PyObject* Machine_apply(PyObject* obj, PyObject* args, PyObject* kwargs)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
static const char* kwlist[] = {"target", "final_processing", "escape_characters", nullptr};
const char* target_arg = nullptr;
int final_processing = 1;
int escape_characters = 1;
if (!PyArg_ParseTupleAndKeywords(
args, kwargs, "|spp", const_cast<char**>(kwlist),
&target_arg, &final_processing, &escape_characters)) {
return nullptr;
}
return guarded([&]() -> PyObject* {
std::string target = target_arg ? target_arg : *self->target;
// Trimmed, as ktext trims before it writes or displays.
std::string result = trim(
machine_of(obj).apply(target, final_processing != 0, escape_characters != 0));
return PyUnicode_FromString(result.c_str());
});
}
PyObject* Machine_write(PyObject* obj, PyObject* args, PyObject* kwargs)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
static const char* kwlist[] = {"text", nullptr};
PyObject* text_arg = nullptr;
if (!PyArg_ParseTupleAndKeywords(
args, kwargs, "|O", const_cast<char**>(kwlist), &text_arg)) {
return nullptr;
}
std::string text {};
if (!string_from(text_arg, "text", text)) {
return nullptr;
}
return guarded([&]() -> PyObject* {
if (!text_arg || text_arg == Py_None) {
text = trim(machine_of(obj).m_result);
}
// ktext writes nothing when the result is empty: a klammer that
// handles its own output (@document under html) leaves it so.
if (text.empty()) {
Py_RETURN_NONE;
}
string_to_file(*self->output_filename, text + "\n");
(void)K::log(1, "Wrote file: " + *self->output_filename);
return PyUnicode_FromString(self->output_filename->c_str());
});
}
PyObject* Machine_get(PyObject* obj, PyObject* arg)
{
if (!PyUnicode_Check(arg)) {
PyErr_SetString(PyExc_TypeError, "get() takes a state variable name, a string");
return nullptr;
}
std::string name = PyUnicode_AsUTF8(arg);
return guarded([&]() -> PyObject* {
return PyUnicode_FromString(machine_of(obj).m_state.value(name, true).c_str());
});
}
PyObject* Machine_set(PyObject* obj, PyObject* args)
{
const char* name = nullptr;
const char* value = nullptr;
if (!PyArg_ParseTuple(args, "ss", &name, &value)) {
return nullptr;
}
return guarded([&]() -> PyObject* {
machine_of(obj).m_state.set(name, value);
Py_RETURN_NONE;
});
}
PyObject* Machine_names(PyObject* obj, PyObject*)
{
return guarded([&]() -> PyObject* {
return string_list(machine_of(obj).m_state.all_names());
});
}
PyObject* Machine_describe(PyObject* obj, PyObject*)
{
return guarded([&]() -> PyObject* {
return PyUnicode_FromString(machine_of(obj).m_state.describe().c_str());
});
}
PyObject* Machine_get_target(PyObject* obj, void*)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
return PyUnicode_FromString(self->target->c_str());
}
PyObject* Machine_get_output_filename(PyObject* obj, void*)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
return PyUnicode_FromString(self->output_filename->c_str());
}
PyObject* Machine_get_files(PyObject* obj, void*)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
return string_list(*self->files);
}
PyObject* Machine_get_result(PyObject* obj, void*)
{
return guarded([&]() -> PyObject* {
return PyUnicode_FromString(machine_of(obj).m_result.c_str());
});
}
PyObject* Machine_repr(PyObject* obj)
{
auto* self = reinterpret_cast<Machine_object*>(obj);
return PyUnicode_FromFormat("<klammertext.Machine target='%s'>", self->target->c_str());
}
PyMethodDef Machine_methods[] = {
{"read", Machine_read, METH_O,
"read(text)\n\nRead Klammertext from a string, as \"ktext -s\" does: definitions\n"
"it makes are available to what is read after it."},
{"read_file", Machine_read_file, METH_O,
"read_file(filename)\n\nRead a Klammertext file (\"~\" is expanded)."},
{"read_files", Machine_read_files, METH_NOARGS,
"read_files()\n\nRead the constructor's files, in order."},
{"apply", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)()>(Machine_apply)),
METH_VARARGS | METH_KEYWORDS,
"apply(target=None, final_processing=True, escape_characters=True) -> str\n\n"
"Apply every klammer under the target (the constructor's by default) and\n"
"return the result, trimmed as ktext trims it. Empty when a klammer wrote\n"
"its own files (@document under html)."},
{"write", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)()>(Machine_write)),
METH_VARARGS | METH_KEYWORDS,
"write(text=None) -> str | None\n\n"
"Write text (by default the last apply() result) to output_filename and\n"
"return that name; nothing is written for an empty text, and None returned."},
{"get", Machine_get, METH_O,
"get(name) -> str\n\nThe value of a state variable; an error if it is not defined."},
{"set", Machine_set, METH_VARARGS,
"set(name, value)\n\nSet a state variable in the current frame."},
{"names", Machine_names, METH_NOARGS,
"names() -> list[str]\n\nEvery defined state variable name."},
{"describe", Machine_describe, METH_NOARGS,
"describe() -> str\n\nThe state as ktext -v describes it."},
{nullptr, nullptr, 0, nullptr}
};
PyGetSetDef Machine_getset[] = {
{"target", Machine_get_target, nullptr, "The constructor's target.", nullptr},
{"output_filename", Machine_get_output_filename, nullptr,
"Where write() writes: derived from files= and output= as ktext derives it.", nullptr},
{"files", Machine_get_files, nullptr, "The constructor's input files, resolved.", nullptr},
{"result", Machine_get_result, nullptr, "The last apply() result, untrimmed.", nullptr},
{nullptr, nullptr, nullptr, nullptr, nullptr}
};
// Every slot is assigned in PyInit_klammertext(); the aggregate here only
// supplies the object header, which is what the CPython docs show. The
// remaining fields are deliberately left to the aggregate's zero-fill.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
PyTypeObject Machine_type = {
PyVarObject_HEAD_INIT(nullptr, 0)
};
#pragma GCC diagnostic pop
const char* Machine_doc =
"Machine(target='*', klammersets=None, files=None, output=None, display=False, verbose=0)\n"
"\n"
"The Klammermachine, set up as ktext sets it up for\n"
" ktext <files> -t <target> --klammersets <klammersets> -o <output>\n"
"and with the klammersets loaded (the Standard Klammer Set when klammersets\n"
"is None; 'none' loads none). Nothing is read: call read(), read_file()\n"
"or read_files(), then apply(). Only one Machine may exist at a time.";
PyMethodDef module_methods[] = {
{nullptr, nullptr, 0, nullptr}
};
PyModuleDef module_def = {
PyModuleDef_HEAD_INIT,
"klammertext",
"The Python binding of the Klammermachine: ktext in-process.\n\n"
" import klammertext\n"
" M = klammertext.Machine(target='html', klammersets=['sks'])\n"
" M.read('@i Hello @')\n"
" print(M.apply())\n\n"
"Errors are klammertext.Error and its subclasses, named as the C++ classes\n"
"are (Parsing_error, File_error, ...).",
-1,
module_methods,
nullptr, nullptr, nullptr, nullptr
};
bool make_error_classes(PyObject* module)
{
error_base = PyErr_NewExceptionWithDoc(
"klammertext.Error",
"A Klammertext error: str(e) is the report ktext prints; e.kind,\n"
"e.description, e.filename, e.line and e.column are its parts. An\n"
"@eval that raised a Python exception has it as e.__cause__.",
nullptr, nullptr);
if (!error_base) {
return false;
}
Py_INCREF(error_base);
PyModule_AddObject(module, "Error", error_base);
for (const auto& [kind, name] : error_kinds) {
std::string qualified = "klammertext." + name;
std::string doc = "A Klammertext " + kind + " error (the C++ " + name + ").";
PyObject* cls = PyErr_NewExceptionWithDoc(
qualified.c_str(), doc.c_str(), error_base, nullptr);
if (!cls) {
return false;
}
error_classes[kind] = cls;
Py_INCREF(cls);
PyModule_AddObject(module, name.c_str(), cls);
}
return true;
}
} // namespace
PyMODINIT_FUNC PyInit_klammertext(void)
{
Machine_type.tp_name = "klammertext.Machine";
Machine_type.tp_basicsize = sizeof(Machine_object);
Machine_type.tp_itemsize = 0;
Machine_type.tp_flags = Py_TPFLAGS_DEFAULT;
Machine_type.tp_doc = Machine_doc;
Machine_type.tp_new = Machine_new;
Machine_type.tp_init = Machine_init;
Machine_type.tp_dealloc = Machine_dealloc;
Machine_type.tp_repr = Machine_repr;
Machine_type.tp_methods = Machine_methods;
Machine_type.tp_getset = Machine_getset;
if (PyType_Ready(&Machine_type) < 0) {
return nullptr;
}
PyObject* module = PyModule_Create(&module_def);
if (!module) {
return nullptr;
}
Py_INCREF(&Machine_type);
PyModule_AddObject(module, "Machine", reinterpret_cast<PyObject*>(&Machine_type));
if (!make_error_classes(module)) {
return nullptr;
}
// What an error report names as the command.
command_name = "klammertext";
set_string_source();
const char* home = std::getenv(klammertext_home_var.c_str());
PyObject* home_obj = home ? PyUnicode_FromString(home) : Py_None;
if (!home) {
Py_INCREF(Py_None);
}
PyModule_AddObject(module, "home", home_obj);
return module;
}
+9 -1
View File
@@ -31,11 +31,12 @@ DEPFLAGS = -MMD -MP -MF $(@:.o=.d)
$(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $(DEPFLAGS) $< -o $@ $(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $(DEPFLAGS) $< -o $@
# Default target # Default target
.PHONY: all sks ref clean redo clang .PHONY: all sks ref kpy clean redo clang
all : $(LIBRARY) all : $(LIBRARY)
$(MAKE) sks $(MAKE) sks
$(MAKE) ref $(MAKE) ref
$(MAKE) kpy
# Create lib directory # Create lib directory
$(LIBDIR): $(LIBDIR):
@@ -60,6 +61,13 @@ sks :
ref : ref :
$(MAKE) -C $(K)/ref $(MAKE) -C $(K)/ref
# kpy/ is the Python binding: a CPython extension module that links against
# libklammertext.so and shares its class layouts, like the sks DSOs and
# ref/. A fourth consumer of the library, and neither a command nor a
# klammer set.
kpy :
$(MAKE) -C $(K)/kpy
clean : clean :
rm -f $(OBJECTS) $(DEPFILES) $(LIBRARY) *~ rm -f $(OBJECTS) $(DEPFILES) $(LIBRARY) *~
+23
View File
@@ -121,6 +121,29 @@ parse_args(
} }
void set_command_state(
Machine& machine, const std::string& frame_name, const std::string& target,
const std::vector<std::string>& input_filenames, const std::string& output_dir,
const std::string& output_basename, bool display_only)
{
State& S = machine.m_state;
S.open_frame(frame_name);
S.set("K_target", target);
S.set("K_output_dir", output_dir);
S.set("K_output_basename", output_basename);
S.set("K_stdout_only", display_only ? "true" : "false");
// The list form uses the standalone-"/" separator (filenames may
// contain spaces); K_input_filename is the single root input file.
S.set("K_input_filenames", join(input_filenames, " / "));
S.set("K_input_filename", input_filenames.empty() ? "" : input_filenames[0]);
S.set("K_verbose_level", std::to_string(verbose_level));
if (!input_filenames.empty()) {
S.set("K_input_dir", absolute_pathname(file_directory(input_filenames[0])));
} else {
S.set("K_input_dir", fs::current_path().string());
}
}
void load_klammersets(Machine& machine, const strings_t& symbols) void load_klammersets(Machine& machine, const strings_t& symbols)
{ {
// "none" is the absence of a klammerset, not the name of one, so it says // "none" is the absence of a klammerset, not the name of one, so it says
+15
View File
@@ -20,3 +20,18 @@ parse_args(
std::string output_basename, bool display_only); std::string output_basename, bool display_only);
void load_klammersets(Machine& machine, const strings_t& symbols); void load_klammersets(Machine& machine, const strings_t& symbols);
// The state a command gives a Machine before anything is read: the frame
// named for the command and the K_* variables that describe the run --
// target, output directory and basename, whether output is displayed rather
// than written, the input filenames, the verbosity, and the input directory
// (the first input file's, or the cwd when there is none). This was the top
// of ktext's main(), and it is policy, not mechanism: a klammer set reads
// these names (@document reads K_stdout_only and K_output_dir), so a second
// consumer of the Machine -- the Python binding in kpy/ -- must set exactly
// the same ones or a document that renders under ktext fails under it. One
// function, two callers, nothing restated.
void set_command_state(
Machine& machine, const std::string& frame_name, const std::string& target,
const std::vector<std::string>& input_filenames, const std::string& output_dir,
const std::string& output_basename, bool display_only);
+17 -8
View File
@@ -1,3 +1,5 @@
#include <sstream>
#include "error.h" #include "error.h"
#include "show.h" #include "show.h"
#include "util.h" #include "util.h"
@@ -13,26 +15,33 @@ bool display_source(const std::string& filename_arg)
} }
} }
void Error::print_message(const std::string& epilog) std::string Error::message(const std::string& epilog) const
{ {
// The separator is not cosmetic: the description ends with a sentence, and // The separator is not cosmetic: the description ends with a sentence, and
// appending the advice straight onto it ran the two together into one word // appending the advice straight onto it ran the two together into one word
// ("...for an unspecified targetkdiag loads no klammerset..."). m_desc is // ("...for an unspecified targetkdiag loads no klammerset..."). m_desc is
// then justified to 80 columns, so the break has to be a blank line rather // then justified to 80 columns, so the break has to be a blank line rather
// than a single newline, which justify() would fold back into the flow. // than a single newline, which justify() would fold back into the flow.
std::string desc = m_desc;
if (epilog != "") { if (epilog != "") {
m_desc += "\n\n" + epilog + "\n"; desc += "\n\n" + epilog + "\n";
} }
m_desc = justify(m_desc, 80, 0); std::stringstream ss {};
std::cerr << "\n" << red << command_name << " (" << m_type << " error)"; ss << command_name << " (" << m_type << " error)";
if (display_source(m_loc.m_filename)) { if (display_source(m_loc.m_filename)) {
std::cerr << ":\n " << m_loc.m_filename; ss << ":\n " << m_loc.m_filename;
} }
if (m_loc.m_line > -1) { if (m_loc.m_line > -1) {
std::cerr << ", line " << m_loc.m_line; ss << ", line " << m_loc.m_line;
} }
if (m_loc.m_chr > -1) { if (m_loc.m_chr > -1) {
std::cerr << ", character " << m_loc.m_chr + 1; ss << ", character " << m_loc.m_chr + 1;
} }
std::cerr << ":\n\n" << m_desc << reset << "\n"; ss << ":\n\n" << justify(desc, 80, 0);
return ss.str();
}
void Error::print_message(const std::string& epilog)
{
std::cerr << "\n" << red << message(epilog) << reset << "\n";
} }
+8
View File
@@ -17,6 +17,14 @@ public:
, m_loc(locator) , m_loc(locator)
{} {}
// The report text as the commands print it -- the header naming the
// command and the error kind, the location when there is one, and the
// description justified to 80 columns -- WITHOUT colour or the
// surrounding newlines. print_message() writes exactly this to stderr;
// the Python binding (kpy/) makes it the exception's str(), which is
// why the two are one function: an error reads the same from a script
// as from ktext. const: the former print_message mutated m_desc.
std::string message(const std::string& epilog="") const;
void print_message(const std::string& epilog=""); void print_message(const std::string& epilog="");
std::string m_type {}; std::string m_type {};
+85 -49
View File
@@ -4,6 +4,7 @@
#include "log.h" #include "log.h"
std::regex Eval_python::statement_delimiter("\\s*;\\s*"); std::regex Eval_python::statement_delimiter("\\s*;\\s*");
PyObject* Eval_python::s_last_exception = nullptr;
Eval_python::Eval_python(Machine& machine, const Locator& loc) Eval_python::Eval_python(Machine& machine, const Locator& loc)
: m_machine(machine) : m_machine(machine)
@@ -119,52 +120,10 @@ void Eval_python::import_module(const std::string& module_name, bool verify)
} }
PyObject* module = PyImport_ImportModule(module_name.c_str()); PyObject* module = PyImport_ImportModule(module_name.c_str());
if (module == nullptr) { if (module == nullptr) {
// Extract the Python traceback before clearing the error. // The traceback, not just the top-level module name that failed to
// This reveals the actual source of the failure (e.g., a syntax // load: it reveals the actual source of the failure (e.g. a syntax
// error in a transitively imported module), not just the top-level // error in a transitively imported module).
// module name that failed to load. std::string detail = fetch_python_exception();
std::string detail;
PyObject* ptype;
PyObject* pvalue;
PyObject* ptraceback;
PyErr_Fetch(&ptype, &pvalue, &ptraceback);
if (pvalue) {
PyErr_NormalizeException(&ptype, &pvalue, &ptraceback);
PyObject* str = PyObject_Str(pvalue);
if (str) {
detail = PyUnicode_AsUTF8(str);
Py_DECREF(str);
}
// Format the traceback if available
if (ptraceback) {
PyObject* tb_module = PyImport_ImportModule("traceback");
if (tb_module) {
PyObject* format_tb = PyObject_GetAttrString(tb_module, "format_exception");
if (format_tb) {
PyObject* args = PyTuple_Pack(3, ptype, pvalue, ptraceback);
PyObject* tb_list = PyObject_CallObject(format_tb, args);
if (tb_list) {
PyObject* separator = PyUnicode_FromString("");
PyObject* joined = PyUnicode_Join(separator, tb_list);
if (joined) {
detail = PyUnicode_AsUTF8(joined);
Py_DECREF(joined);
}
Py_DECREF(separator);
Py_DECREF(tb_list);
}
Py_XDECREF(args);
Py_DECREF(format_tb);
}
Py_DECREF(tb_module);
}
}
}
Py_XDECREF(ptype);
Py_XDECREF(pvalue);
Py_XDECREF(ptraceback);
PyErr_Clear();
std::string message = "Cannot import module \"" + module_name + "\""; std::string message = "Cannot import module \"" + module_name + "\"";
if (!detail.empty()) { if (!detail.empty()) {
message += ":\n\n" + detail; message += ":\n\n" + detail;
@@ -176,6 +135,68 @@ void Eval_python::import_module(const std::string& module_name, bool verify)
PyDict_SetItemString(m_globals, module_name.c_str(), module); PyDict_SetItemString(m_globals, module_name.c_str(), module);
} }
PyObject* Eval_python::take_last_exception()
{
PyObject* e = s_last_exception;
s_last_exception = nullptr;
return e;
}
std::string Eval_python::fetch_python_exception()
{
std::string detail {};
PyObject* ptype = nullptr;
PyObject* pvalue = nullptr;
PyObject* ptraceback = nullptr;
PyErr_Fetch(&ptype, &pvalue, &ptraceback);
if (!pvalue) {
Py_XDECREF(ptype);
Py_XDECREF(ptraceback);
PyErr_Clear();
return detail;
}
PyErr_NormalizeException(&ptype, &pvalue, &ptraceback);
if (ptraceback) {
// Attach the traceback to the instance, so whoever receives the
// object (the binding, as __cause__) can still print it.
PyException_SetTraceback(pvalue, ptraceback);
}
PyObject* str = PyObject_Str(pvalue);
if (str) {
detail = PyUnicode_AsUTF8(str);
Py_DECREF(str);
}
PyObject* tb_module = PyImport_ImportModule("traceback");
if (tb_module) {
PyObject* format = PyObject_GetAttrString(tb_module, "format_exception");
if (format) {
PyObject* args = PyTuple_Pack(
3, ptype, pvalue, ptraceback ? ptraceback : Py_None);
PyObject* tb_list = PyObject_CallObject(format, args);
if (tb_list) {
PyObject* separator = PyUnicode_FromString("");
PyObject* joined = PyUnicode_Join(separator, tb_list);
if (joined) {
detail = PyUnicode_AsUTF8(joined);
Py_DECREF(joined);
}
Py_DECREF(separator);
Py_DECREF(tb_list);
}
Py_XDECREF(args);
Py_DECREF(format);
}
Py_DECREF(tb_module);
}
// Keep the instance (it owns its traceback now); drop the rest.
Py_XDECREF(s_last_exception);
s_last_exception = pvalue;
Py_XDECREF(ptype);
Py_XDECREF(ptraceback);
PyErr_Clear();
return detail;
}
std::string Eval_python::get_result(PyObject* result_object) std::string Eval_python::get_result(PyObject* result_object)
{ {
std::string result {}; std::string result {};
@@ -184,9 +205,24 @@ std::string Eval_python::get_result(PyObject* result_object)
result = std::string(value); result = std::string(value);
Py_DECREF(result_object); Py_DECREF(result_object);
} else { } else {
std::cout << red; // Until 2026-09-08 this was "std::cout << red; PyErr_Print();" --
PyErr_Print(); // the colour escape on stdout, the traceback on stderr, and then an
throw Parsing_error("Python code error in @eval", m_loc); // Error carrying neither. The traceback now travels IN the error,
// indented so justify() leaves its lines verbatim (a traceback line
// is not prose), and the exception object is kept for the binding.
std::string detail = fetch_python_exception();
std::string message = "Python code error in @eval";
if (!detail.empty()) {
message += ":\n\n";
std::string::size_type from = 0;
while (from < detail.size()) {
auto nl = detail.find('\n', from);
if (nl == std::string::npos) nl = detail.size();
message += " " + detail.substr(from, nl - from) + "\n";
from = nl + 1;
}
}
throw Parsing_error(message, m_loc);
} }
return result; return result;
} }
+12
View File
@@ -17,6 +17,17 @@ public:
std::vector<std::string> parse_modules(std::string code); std::vector<std::string> parse_modules(std::string code);
void import_module(const std::string& module_name, bool verify = true); void import_module(const std::string& module_name, bool verify = true);
std::string get_result(PyObject* result_object); std::string get_result(PyObject* result_object);
// The Python exception currently raised (there must be one): clear it,
// return the formatted traceback, and keep the exception object in the
// slot below. ktext puts the text into the Error it throws; the Python
// binding (kpy/) additionally chains the object as the Error's __cause__,
// so a script sees the ZeroDivisionError its @eval raised, with its
// traceback, and not only a description of it.
std::string fetch_python_exception();
// Take ownership of the exception kept by the last fetch (nullptr if
// none); the slot is cleared. ktext never calls this: it terminates on
// the Error, and the interpreter is torn down with the process.
static PyObject* take_last_exception();
std::string eval_expression(const std::string& expression, bool import_modules = true); std::string eval_expression(const std::string& expression, bool import_modules = true);
std::string eval_statements(const std::string& script); std::string eval_statements(const std::string& script);
std::string eval(std::string code); std::string eval(std::string code);
@@ -29,4 +40,5 @@ public:
Locator m_loc; Locator m_loc;
PyObject* m_globals; PyObject* m_globals;
PyObject* m_locals; PyObject* m_locals;
static PyObject* s_last_exception;
}; };
+6 -1
View File
@@ -935,7 +935,12 @@ void Machine::escape_target_characters(const Target& target, katom_list& katoms)
std::string Machine::apply(const std::string& target_name, bool final_processing, bool escape_characters) std::string Machine::apply(const std::string& target_name, bool final_processing, bool escape_characters)
{ {
(void)K::log(3, "top level"); (void)K::log(3, "top level");
m_state.set("K_target", target_name); // update=true: K_target is THIS apply's target. A command applies once,
// but the Python binding (kpy/) applies the same Machine under several
// targets -- apply("html") then apply("tex") -- and the second call
// found the first's value in the same frame and died with "Variable
// K_target is already defined" (found by tst/kpy_test.sh, 2026-09-08).
m_state.set("K_target", target_name, true);
m_state.subst(m_katoms.begin(), m_katoms.end()); m_state.subst(m_katoms.begin(), m_katoms.end());
// Escape target-specific characters in writer text before klammer application. // Escape target-specific characters in writer text before klammer application.
+1
View File
@@ -84,6 +84,7 @@ std::set sks_commands = {
"ktext"s, "ktext"s,
"kdesc"s, "kdesc"s,
"kdiag"s, "kdiag"s,
"klammertext"s, // the Python binding's string input (kpy/)
"argument_test"s, "argument_test"s,
"argtype_test"s "argtype_test"s
}; };
+1
View File
@@ -32,3 +32,4 @@ test:
./kdesc_test.sh ./kdesc_test.sh
./kdiag_test.sh ./kdiag_test.sh
./editor_test.sh ./editor_test.sh
./kpy_test.sh
+384
View File
@@ -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 ]