#!/usr/bin/env python3 """One interpreter start for every measurement 99-python3-cheetah makes. WHY ONE PROCESS. A python3 start on wasm32-hwjs is tens of seconds before the first line of a program runs, and this suite has ~100 things to say about Cheetah. Run as separate `python3 -c` invocations that would be an hour of interpreter starts and nothing else. The work inside is ~45 template compilations, each a Cheetah parse followed by a `compile()` of the Python source it generates -- microseconds beside the start on the x86-64 control, and the dominant cost here. THE OUTPUT CONTRACT. One `key=value` line per measurement, keys emitted in file order, `probe_done=yes` last and `emitted_count=N` beside it. The suite asserts both, so a probe that died two thirds of the way through is distinguishable from a library that returned the wrong answers -- without them the early cases stay green and the later ones read got:'' , which looks like a partial library. NEWLINES ARE ENCODED, NOT EMITTED. A rendered template is multi-line by nature and this file's contract is one line per key, so every rendered string goes through `enc()`, which spells LF as `\\n` and TAB as `\\t`. The suite's expectations carry the same spelling. Nothing is normalised away: a trailing newline Cheetah did or did not emit is visible in the value, which is the point -- `#end for` swallowing a line ending is the kind of thing a suite that stripped would never see. THE SEARCHLIST IS BUILT FROM LITERALS AND NOTHING READS THE ENVIRONMENT. Every fixture below is defined in this file, and no case depends on the clock, the locale, the TZ, the cwd or any file outside the one temporary directory the `#include` case writes. `id()`-carrying reprs are never emitted. WHAT THIS PROBE IS REALLY ABOUT. Cheetah ships a C accelerator, `Cheetah._namemapper`, that this image declines to build (see python3-cheetah_%.bbappend). That is not only a speed choice: `Compiler.py` reads `NameMapper.C_VERSION` and, finding it False, warns and forces `useStackFrames` off. So the pure-Python arm is a slightly SMALLER language -- a placeholder resolves through the searchList alone and never through the calling frame -- and the `cver_*` and `stackframes_*` keys are where that is stated rather than left to be inferred. """ import os import sys import tempfile import traceback import warnings COUNT = 0 def emit(key, value): global COUNT COUNT += 1 sys.stdout.write("%s=%s\n" % (key, value)) def enc(s): """One line per key, with the line structure of the value preserved.""" return (s.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") .replace("\r", "\\r")) def val(key, fn): """Emit fn()'s value as text, or `raised:` -- never a traceback. A case that expects a value and gets an exception name still reds, and the class name is what says which. The traceback goes to stderr, which bst_probe keeps at .err, so nothing is lost by not putting it on a key line the runner would parse as a stray result. """ try: emit(key, enc(str(fn()))) except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def yn(key, fn): """A yes/no measurement, with the exception name if it could not be taken.""" try: emit(key, "yes" if fn() else "no") except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def raised(key, fn): """The class name of what fn() raises, or `returned` if it did not. Stated this way round rather than as `raises_X=yes' because the interesting reds here are the ones where the WRONG exception arrives -- which is exactly what declining the C accelerator does to `getVar("$")'. """ try: fn() emit(key, "returned") except Exception: emit(key, sys.exc_info()[0].__name__) # --------------------------------------------------------------- the import --- # Before anything else, and separately from the render: an import failure here # ends the probe with everything below unmeasured, which the suite's # `probe-complete' case is what reports. from Cheetah import NameMapper # noqa: E402 from Cheetah.Template import Template # noqa: E402 from Cheetah.NameMapper import NotFound # noqa: E402 from Cheetah.Version import Version, VersionTuple, MinCompatibleVersion # noqa: E402,E501 from Cheetah.DirectiveAnalyzer import analyze # noqa: E402 import Cheetah # noqa: E402 emit("module_file", Cheetah.__file__) yn("is_package", lambda: hasattr(Cheetah, "__path__")) emit("version", Version) emit("version_tuple", ",".join(str(x) for x in VersionTuple)) emit("min_compatible", MinCompatibleVersion) # `Cheetah.Version' is a STRING on the package, not the module -- __init__.py # re-exports it and shadows the submodule of the same name. So `from Cheetah # import Version' gives text and `import Cheetah.Version' gives a module, which # is a trap worth pinning rather than a curiosity. emit("version_attr_type", type(Cheetah.Version).__name__) # The distribution metadata is the second, independent reading of the version. # Cheetah3 carries BOTH -- unlike python3-isodate, which has only the # dist-info -- so they are cross-checked against each other rather than each # asserted alone. try: from importlib.metadata import version as _dv emit("dist_version", _dv("Cheetah3")) except Exception: traceback.print_exc() emit("dist_version", "raised:%s" % sys.exc_info()[0].__name__) # The PyPI project name is `Cheetah3'; the importable is `Cheetah'. A reader who # assumed they matched would look up the wrong dist-info, so the mismatch is # stated rather than implied by the key above. raised("dist_version_wrong_name", lambda: _dv("Cheetah")) # ------------------------------------------------- the accelerator, declined --- # THE CENTRAL FACT OF THIS PORT. python3-cheetah_%.bbappend patches SetupConfig # so ext_modules is empty for class-target, so `Cheetah._namemapper' is not # built and not shipped -- and NameMapper.py's try/except therefore keeps the # pure-Python definitions it made first. yn("cver_c_version", lambda: NameMapper.C_VERSION) raised("cver_ext_import", lambda: __import__("Cheetah._namemapper")) # The pure-Python NotFound is a LookupError of NameMapper's own. The C build # defines the same name in C; a caller catching it by name is unaffected either # way, and this says which one answered. yn("cver_notfound_is_lookuperror", lambda: issubclass(NotFound, LookupError)) emit("cver_notfound_module", NotFound.__module__) # valueForKey and friends are plain Python functions here. `__module__' on the C # build is `Cheetah._namemapper'; this is the cheapest statement of which # implementation the whole file below is measuring. emit("cver_valueForName_module", NameMapper.valueForName.__module__) yn("cver_has_valueFromFrame", lambda: hasattr(NameMapper, "valueFromFrame")) # ------------------------------------- what declining it does to the compiler --- # Compiler.py reads C_VERSION when a ModuleCompiler is constructed and, finding # it False, warns on stderr and forces useStackFrames off. Both halves are # measured here, because the warning is what a user SEES and the setting is what # actually changes the language. with warnings.catch_warnings(record=True) as _w: warnings.simplefilter("always") _t = Template(source="$a", searchList=[{"a": 1}]) _msgs = [str(x.message) for x in _w] emit("stackframes_warning_count", str(len(_msgs))) yn("stackframes_warning_names_c", lambda: any("C version of NameMapper" in m for m in _msgs)) yn("stackframes_warning_names_option", lambda: any("useStackFrames" in m for m in _msgs)) # The setting lives on the COMPILER, not on the Template instance -- Template # does not inherit SettingsManager, so `t.setting(...)' raises AttributeError and # a suite reading it there would learn nothing. from Cheetah.Compiler import ModuleCompiler # noqa: E402 yn("stackframes_setting_off", lambda: ModuleCompiler(source="$a").setting("useStackFrames") is False) # The generated module source is where the consequence is visible: with # useStackFrames off the compiler emits VFFSL bound to valueFromSearchList, so # there is no `valueFromFrameOrSearchList' call anywhere in the code it writes. # `returnAClass=False' hands back the generated module source. It is BYTES on # this release -- a str/bytes seam a caller writing it to a text file trips # over -- so it is decoded here and asserted as bytes below. _src_raw = Template.compile(source="$a", returnAClass=False) _src = _src_raw.decode("utf-8") if isinstance(_src_raw, bytes) else _src_raw emit("stackframes_src_type", type(_src_raw).__name__) # THE EMITTED CALL FORM IS THE WHOLE OF THE DIFFERENCE. Both names are bound in # every generated module's preamble, so their mere presence says nothing; what # changes is which one the placeholder call sites use. With useStackFrames off # the compiler writes `VFSL([locals()]+SL+[globals(), builtin], "a", True)' -- # the local namespace prepended to the searchList as an ordinary dict. With the # C accelerator it writes `VFFSL(SL, "a", True)' and the frame walk happens # inside the extension. yn("stackframes_src_prepends_locals", lambda: "VFSL([locals()]+SL" in _src) yn("stackframes_src_no_vffsl_callsite", lambda: "VFFSL(SL," not in _src) # ---------------------------------------------------------------- fixtures --- # Everything the render cases resolve against, defined once and here. class Person(object): kind = "class attribute" def __init__(self): self.name = "Ada" self.tags = ["x", "y", "z"] self.inner = {"deep": "buried"} def greet(self): return "hi" def add(self, a, b=10): return a + b PERSON = Person() NS1 = {"who": "first", "only1": "one"} NS2 = {"who": "second", "only2": "two"} SL = [NS1, NS2, {"p": PERSON, "d": {"k": "dictval", "sub": {"k2": "nested"}}, "n": 3, "seq": ["a", "b", "c"], "items": ["a", "b", "c"], "empty": [], "f": lambda x: x * 2, "t": True, "fa": False, "u": u"\u00e9\u4e2d", "html": "&"}] def render(source, sl=None, **kw): return str(Template(source=source, searchList=sl if sl is not None else SL, **kw)) # ----------------------------------------------------------- the placeholder --- # `$name' resolution, which is the whole of what NameMapper does and therefore # the half of Cheetah this port's C-accelerator decision touches. val("ph_simple", lambda: render("Hello $who!")) val("ph_braced", lambda: render("${who}ly")) # A `$' the template means literally. Cheetah's escape is a backslash, and the # rendered output carries neither the backslash nor a substitution. val("ph_escaped", lambda: render("costs \\$who")) # A `$' followed by something that cannot start a name is left alone WITHOUT an # escape -- which is why a shell-flavoured template usually renders unharmed. val("ph_bare_dollar", lambda: render("100$ and $who")) val("ph_attr", lambda: render("$p.name")) val("ph_class_attr", lambda: render("$p.kind")) # AUTOCALLING. A placeholder naming a method CALLS it -- no parentheses -- which # is NameMapper's `executeCallables' and the single most surprising thing about # reading a Cheetah template. The explicit-call form is asserted beside it so # the two are visibly the same answer. val("ph_method_autocall", lambda: render("$p.greet")) val("ph_method_called", lambda: render("$p.greet()")) val("ph_method_args", lambda: render("$p.add(5)")) val("ph_method_kwargs", lambda: render("$p.add(5, b=1)")) val("ph_func_call", lambda: render("$f(7)")) # A DICT IS WALKED WITH A DOT. `$d.k' is not attribute access -- NameMapper # tries the mapping first -- and the bracket form means the same thing. A caller # who knows only Python reads the first as a typo. val("ph_dict_dot", lambda: render("$d.k")) val("ph_dict_brackets", lambda: render("$d['k']")) val("ph_dict_nested", lambda: render("$d.sub.k2")) val("ph_list_index", lambda: render("$seq[1]")) val("ph_attr_then_index", lambda: render("$p.tags[2]")) val("ph_attr_then_key", lambda: render("$p.inner.deep")) # SEARCHLIST ORDER IS FIRST-WINS, and a name absent from the head namespace # falls through to the next. Both directions, because either alone would pass # with the list reversed. val("ph_ns_first_wins", lambda: render("$who")) val("ph_ns_falls_through", lambda: render("$only2")) val("ph_unicode", lambda: render("$u")) # NO ESCAPING BY DEFAULT. Cheetah's default filter passes text through, so a # template rendering user data into HTML is not protected by the engine. Stated # because the opposite is the default in most engines a reader will have used. val("ph_no_html_escape", lambda: render("$html")) # A NAME THAT IS ALSO A `dict' METHOD DOES NOT REACH THE SEARCHLIST VALUE. Name # resolution consults a LOCAL namespace before the caller's -- as a prepended # `locals()' dict here, through the frame walk on the C arm -- and # `valueForName' falls back to `getattr' when the key is absent. `items', # `keys', `values', `get', `copy', `pop' and the rest of the mapping API # therefore resolve to that dict's BOUND METHOD, which the placeholder then # autocalls. `$items' is a `dict_items' view, so `$seq[1]' works and # `$items[1]' is a TypeError, and `#for $i in $items' iterates the local # namespace while the loop assigns into it -- "dictionary changed size during # iteration". # # MEASURED ON BOTH ARMS, so it is a property of Cheetah and of the name, not of # this image declining the accelerator: identical with `Cheetah._namemapper' # built (x86-64 control, 2026-09-02). Pinned because a template author picks # these names all the time and nothing warns. # The rendered value is a `dict_items' repr carrying object ADDRESSES, so only # its head is emitted -- a case asserting the whole of it would red at random. val("collide_dict_method", lambda: "dict_items" if render("$items").startswith("dict_items(") else render("$items")[:40]) raised("collide_dict_method_index", lambda: render("$items[1]")) raised("collide_dict_method_for", lambda: render("#for $i in $items\n$i\n#end for\n")) val("collide_not_a_method", lambda: render("$seq")) # ------------------------------------------------------------- the directives --- val("dir_if_true", lambda: render("#if $t\nyes\n#else\nno\n#end if\n")) val("dir_if_false", lambda: render("#if $fa\nyes\n#else\nno\n#end if\n")) # `#unless' is `#if not', and it is the one control directive with no Python # keyword behind its name. val("dir_unless", lambda: render("#unless $fa\nran\n#end unless\n")) val("dir_for", lambda: render("#for $i in $seq\n$i.\n#end for\n")) val("dir_for_empty", lambda: render("#for $i in $empty\n$i\n#end for\n")) val("dir_for_enumerate", lambda: render("#for $i, $v in enumerate($seq)\n$i=$v \n#end for\n")) val("dir_while", lambda: render( "#set $k = 0\n#while $k < 3\n$k#slurp\n#set $k = $k + 1\n#end while\n")) val("dir_set", lambda: render("#set $x = 40 + 2\n$x")) # `#set' writes into the template's OWN namespace, which the searchList does not # see and a later `#set' can shadow -- but it does NOT write back into the # caller's dict. Asserted because a template that mutated its input would be a # real defect and nothing else here would notice. val("dir_set_does_not_mutate_ns", lambda: render("#set $who = 'local'\n$who") + "|" + str(NS1["who"])) val("dir_echo", lambda: render("#echo 6 * 7\n")) val("dir_silent", lambda: render("#silent 6 * 7\n-")) # `#slurp' eats the newline after it; `##' is a line comment and eats its line. # Both are about whitespace, which is the thing a rendered-string comparison is # uniquely good at pinning and a `.strip()' would destroy. val("dir_slurp", lambda: render("a#slurp\nb")) val("dir_comment_line", lambda: render("a\n## not output\nb\n")) val("dir_comment_block", lambda: render("a\n#*\nnot output\n*#\nb\n")) val("dir_raw", lambda: render("#raw\n$who and #if x\n#end raw\n")) val("dir_def", lambda: render( "#def hello($n)\nHi $n!#slurp\n#end def\n$hello('Bob')")) val("dir_block", lambda: render("#block b\ndefault text\n#end block\n")) val("dir_attr", lambda: render("#attr $A = 'const'\n$A")) val("dir_pass_through_hash", lambda: render("# not a directive\n#if $t\nx\n#end if\n")) # ------------------------------------------------------- inheritance and files --- # `#extends' is CLASS inheritance: the compiled child subclasses the compiled # parent, and a `#block' the child redefines replaces the parent's. This is what # distinguishes Cheetah from a string-substitution engine, so it is asserted end # to end -- parent alone, child alone, and the parent's un-overridden block # surviving into the child. WORK = tempfile.mkdtemp(prefix="cheetah-probe.") BASE_TMPL = os.path.join(WORK, "base.tmpl") INC_TMPL = os.path.join(WORK, "inc.tmpl") BASE_SRC = ("#block title\nparent title\n#end block\n" "#block body\nparent body\n#end block\n") with open(BASE_TMPL, "w") as fh: fh.write(BASE_SRC) with open(INC_TMPL, "w") as fh: fh.write("included $who\n") val("inh_parent_alone", lambda: str(Template.compile(file=BASE_TMPL)(searchList=SL))) def _child(): # NO `#implements': a template carrying `#extends' compiles its body into # `writeBody' rather than `respond', so the PARENT's respond is inherited # and calls the blocks -- which is what makes the un-overridden `title' # block survive into the child. Writing `#implements respond' here instead # replaces the parent's whole method and silently loses it. src = "#extends base\n#block body\nchild body\n#end block\n" parent = Template.compile(file=BASE_TMPL, moduleName="base", className="base") mod = type(sys)("base") mod.base = parent sys.modules["base"] = mod return str(Template.compile(source=src)(searchList=SL)) val("inh_child_overrides", _child) val("inc_file", lambda: render("before\n#include \"%s\"\nafter\n" % INC_TMPL)) val("inc_raw_file", lambda: render("#include raw \"%s\"" % INC_TMPL)) # --------------------------------------------------------- the failure paths --- # A placeholder naming nothing in the searchList raises, at RENDER time rather # than at compile time -- so a template with a typo in it compiles clean and # fails in production. That is the shape of the failure, and it is what the two # keys either side of this comment say. raised("fail_missing_name", lambda: render("$nope")) raised("fail_missing_at_compile", lambda: Template.compile(source="$nope", returnAClass=False)) raised("fail_missing_attr", lambda: render("$p.nosuch")) raised("fail_missing_key", lambda: render("$d.nosuch")) raised("fail_bad_syntax", lambda: render("#for\n")) raised("fail_unclosed_directive", lambda: render("#if $t\nx\n")) # `getVar' with a default is the sanctioned way to ask without raising, and # `varExists' the way to ask whether it would. val("api_getvar", lambda: Template(searchList=SL).getVar("who")) val("api_getvar_default", lambda: Template(searchList=SL).getVar("nope", 1234)) yn("api_varExists_true", lambda: Template(searchList=SL).varExists("who")) yn("api_varExists_false", lambda: Template(searchList=SL).varExists("nope")) # THE ONE PLACE THE PURE-PYTHON ARM ANSWERS DIFFERENTLY IN KIND. `getVar("$")' # strips the sigil, leaving the empty key. The C NameMapper raises ValueError # for it; the pure-Python valueFromSearchList raises NameMapper.NotFound, which # is a LookupError and NOT a ValueError. Upstream's own GetVar.test6 asserts the # C answer, so it errors here -- four times, once per EOL variant of the class. raised("api_getvar_dollar", lambda: Template(searchList=SL).getVar("$")) yn("api_notfound_is_not_valueerror", lambda: not issubclass(NotFound, ValueError)) # ------------------------------------------------- compile, write, import, run --- # The generated module is real Python written to a file, imported by name, and # rendered -- the path `cheetah-compile' takes and the reason this package is a # code GENERATOR rather than only an engine. The assertion is that it produces # the SAME output as rendering the same source directly, which neither half # proves alone. GEN = os.path.join(WORK, "gen") os.makedirs(GEN, exist_ok=True) GEN_SRC = "generated: $who and $d.k\n" with open(os.path.join(GEN, "genmod.py"), "w") as fh: _gen = Template.compile(source=GEN_SRC, moduleName="genmod", className="genmod", returnAClass=False) fh.write(_gen.decode("utf-8") if isinstance(_gen, bytes) else _gen) sys.path.insert(0, GEN) val("gen_module_is_python", lambda: "class genmod" in open(os.path.join(GEN, "genmod.py")).read() and "ok" or "no-class") def _gen_roundtrip(): import genmod return str(genmod.genmod(searchList=SL)) val("gen_imported_render", _gen_roundtrip) yn("gen_matches_direct", lambda: _gen_roundtrip() == render(GEN_SRC)) # ------------------------------------------------------ NameMapper, directly --- # The module under the placeholder syntax, exercised through its own API so a # red in the render cases above can be told from a red in the resolver. val("nm_valueForKey", lambda: NameMapper.valueForKey({"k": "v"}, "k")) val("nm_valueForName_dotted", lambda: NameMapper.valueForName(PERSON, "inner.deep")) val("nm_valueForName_attr", lambda: NameMapper.valueForName(PERSON, "name")) val("nm_valueFromSearchList", lambda: NameMapper.valueFromSearchList(SL, "who")) yn("nm_hasKey_true", lambda: NameMapper.hasKey({"k": 1}, "k")) yn("nm_hasKey_false", lambda: NameMapper.hasKey({"k": 1}, "j")) yn("nm_hasName_true", lambda: NameMapper.hasName(PERSON, "inner.deep")) yn("nm_hasName_false", lambda: NameMapper.hasName(PERSON, "inner.nope")) raised("nm_notfound", lambda: NameMapper.valueFromSearchList(SL, "nope")) # executeCallables is OFF at this API's front door and ON in a template # placeholder. Both are asserted, because the difference is the reason a # template and a direct call disagree about the same name. yn("nm_no_autocall_by_default", lambda: callable(NameMapper.valueForName(PERSON, "greet"))) val("nm_autocall_when_asked", lambda: NameMapper.valueForName(PERSON, "greet", executeCallables=True)) # ------------------------------------------------------------- the analyzer --- # DirectiveAnalyzer is what `cheetah-analyze' runs, and the only part of this # package that reads a template without compiling it. ANALYZE_SRC = "#for $i in $x\n#if $i\n$i\n#end if\n#end for\n#set $y = 1\n" val("analyze_for", lambda: analyze(ANALYZE_SRC).get("for", 0)) val("analyze_if", lambda: analyze(ANALYZE_SRC).get("if", 0)) val("analyze_set", lambda: analyze(ANALYZE_SRC).get("set", 0)) emit("emitted_count", str(COUNT + 2)) emit("probe_done", "yes")