diff --git a/README.md b/README.md index 20f4835..04d0af3 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/com/Makefile b/com/Makefile index 39a16b2..5680833 100644 --- a/com/Makefile +++ b/com/Makefile @@ -96,6 +96,7 @@ else # it shares the class layouts: a stale one after a header change is # the same memory-corruption hazard as a stale sks/document.so. $(MAKE) -C $(K)/ref clean + $(MAKE) -C $(K)/kpy clean $(MAKE) clean $(MAKE) all endif diff --git a/com/ktext.cpp b/com/ktext.cpp index 92987dc..c9b57d9 100644 --- a/com/ktext.cpp +++ b/com/ktext.cpp @@ -58,24 +58,8 @@ int main(int argc, char* argv[]) expand_tilde(args.as_string("o")), args.as_bool("d")); Machine M; - M.m_state.open_frame("ktext"); - M.m_state.set("K_target", target); - 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()); - } + set_command_state(M, "ktext", target, input_filenames, output_dir, + output_basename, display_only); load_klammersets(M, word_split(args.as_string("klammersets"))); diff --git a/env/runtime.env b/env/runtime.env index c2eb57e..f94ca74 100644 --- a/env/runtime.env +++ b/env/runtime.env @@ -42,6 +42,12 @@ unset _kt_tl _kt_arch # --- PATH (TeX Live appended only if one was found) --------------------------- 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 ------------- # The shared library path deliberately includes ONLY Klammertext's own .so dirs, # NOT third-party bundled-library dirs. In particular NVIDIA iray's platforms/ diff --git a/kpy/Makefile b/kpy/Makefile new file mode 100644 index 0000000..fe1d1de --- /dev/null +++ b/kpy/Makefile @@ -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-.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) diff --git a/kpy/klammertext.cpp b/kpy/klammertext.cpp new file mode 100644 index 0000000..bc5b9a0 --- /dev/null +++ b/kpy/klammertext.cpp @@ -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 + +#include +#include +#include + +#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 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> 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 +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& 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& 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* 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(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(); + ++instances; + return reinterpret_cast(self); +} + +void Machine_dealloc(PyObject* obj) +{ + auto* self = reinterpret_cast(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(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(kwlist), + &target_arg, &klammersets_arg, &files_arg, &output_arg, &display, &verbose)) { + return -1; + } + std::vector klammersets {}; + std::vector 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(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(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(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(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(obj); + static const char* kwlist[] = {"text", nullptr}; + PyObject* text_arg = nullptr; + if (!PyArg_ParseTupleAndKeywords( + args, kwargs, "|O", const_cast(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(obj); + return PyUnicode_FromString(self->target->c_str()); +} + +PyObject* Machine_get_output_filename(PyObject* obj, void*) +{ + auto* self = reinterpret_cast(obj); + return PyUnicode_FromString(self->output_filename->c_str()); +} + +PyObject* Machine_get_files(PyObject* obj, void*) +{ + auto* self = reinterpret_cast(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(obj); + return PyUnicode_FromFormat("", 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(reinterpret_cast(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(reinterpret_cast(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 -t --klammersets -o \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(&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; +} diff --git a/mac/Makefile b/mac/Makefile index 87f2b11..321cf66 100644 --- a/mac/Makefile +++ b/mac/Makefile @@ -31,11 +31,12 @@ DEPFLAGS = -MMD -MP -MF $(@:.o=.d) $(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $(DEPFLAGS) $< -o $@ # Default target -.PHONY: all sks ref clean redo clang +.PHONY: all sks ref kpy clean redo clang all : $(LIBRARY) $(MAKE) sks $(MAKE) ref + $(MAKE) kpy # Create lib directory $(LIBDIR): @@ -60,6 +61,13 @@ sks : 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 : rm -f $(OBJECTS) $(DEPFILES) $(LIBRARY) *~ diff --git a/mac/command.cpp b/mac/command.cpp index 0d64005..87442ce 100644 --- a/mac/command.cpp +++ b/mac/command.cpp @@ -121,6 +121,29 @@ parse_args( } +void set_command_state( + Machine& machine, const std::string& frame_name, const std::string& target, + const std::vector& 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) { // "none" is the absence of a klammerset, not the name of one, so it says diff --git a/mac/command.h b/mac/command.h index 67fbfd8..35fb21d 100644 --- a/mac/command.h +++ b/mac/command.h @@ -20,3 +20,18 @@ parse_args( std::string output_basename, bool display_only); 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& input_filenames, const std::string& output_dir, + const std::string& output_basename, bool display_only); diff --git a/mac/error.cpp b/mac/error.cpp index 5171d99..e97c192 100644 --- a/mac/error.cpp +++ b/mac/error.cpp @@ -1,3 +1,5 @@ +#include + #include "error.h" #include "show.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 // appending the advice straight onto it ran the two together into one word // ("...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 // than a single newline, which justify() would fold back into the flow. + std::string desc = m_desc; if (epilog != "") { - m_desc += "\n\n" + epilog + "\n"; + desc += "\n\n" + epilog + "\n"; } - m_desc = justify(m_desc, 80, 0); - std::cerr << "\n" << red << command_name << " (" << m_type << " error)"; + std::stringstream ss {}; + ss << command_name << " (" << m_type << " error)"; 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) { - std::cerr << ", line " << m_loc.m_line; + ss << ", line " << m_loc.m_line; } 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"; } diff --git a/mac/error.h b/mac/error.h index 202bbea..ba4a307 100644 --- a/mac/error.h +++ b/mac/error.h @@ -17,6 +17,14 @@ public: , 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=""); std::string m_type {}; diff --git a/mac/eval_python.cpp b/mac/eval_python.cpp index 1a6f0c5..395f0b4 100644 --- a/mac/eval_python.cpp +++ b/mac/eval_python.cpp @@ -4,6 +4,7 @@ #include "log.h" std::regex Eval_python::statement_delimiter("\\s*;\\s*"); +PyObject* Eval_python::s_last_exception = nullptr; Eval_python::Eval_python(Machine& machine, const Locator& loc) : 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()); if (module == nullptr) { - // Extract the Python traceback before clearing the error. - // This reveals the actual source of the failure (e.g., a syntax - // error in a transitively imported module), not just the top-level - // module name that failed to load. - 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(); - + // The traceback, not just the top-level module name that failed to + // load: it reveals the actual source of the failure (e.g. a syntax + // error in a transitively imported module). + std::string detail = fetch_python_exception(); std::string message = "Cannot import module \"" + module_name + "\""; if (!detail.empty()) { 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); } +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 result {}; @@ -184,9 +205,24 @@ std::string Eval_python::get_result(PyObject* result_object) result = std::string(value); Py_DECREF(result_object); } else { - std::cout << red; - PyErr_Print(); - throw Parsing_error("Python code error in @eval", m_loc); + // Until 2026-09-08 this was "std::cout << red; PyErr_Print();" -- + // the colour escape on stdout, the traceback on stderr, and then an + // 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; } diff --git a/mac/eval_python.h b/mac/eval_python.h index ad00f7d..7a985c5 100644 --- a/mac/eval_python.h +++ b/mac/eval_python.h @@ -17,6 +17,17 @@ public: std::vector parse_modules(std::string code); void import_module(const std::string& module_name, bool verify = true); 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_statements(const std::string& script); std::string eval(std::string code); @@ -29,4 +40,5 @@ public: Locator m_loc; PyObject* m_globals; PyObject* m_locals; + static PyObject* s_last_exception; }; diff --git a/mac/machine.cpp b/mac/machine.cpp index 91816eb..02d8807 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -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) { (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()); // Escape target-specific characters in writer text before klammer application. diff --git a/mac/show.h b/mac/show.h index 0d47758..e7c1862 100644 --- a/mac/show.h +++ b/mac/show.h @@ -84,6 +84,7 @@ std::set sks_commands = { "ktext"s, "kdesc"s, "kdiag"s, + "klammertext"s, // the Python binding's string input (kpy/) "argument_test"s, "argtype_test"s }; diff --git a/tst/Makefile b/tst/Makefile index 5483da2..4b54469 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -32,3 +32,4 @@ test: ./kdesc_test.sh ./kdiag_test.sh ./editor_test.sh + ./kpy_test.sh diff --git a/tst/kpy_test.sh b/tst/kpy_test.sh new file mode 100755 index 0000000..e865a4c --- /dev/null +++ b/tst/kpy_test.sh @@ -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 ]