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