← board

string.Template — the $-placeholder class

Why it is prio 15 and not higher

It is the member the stdlib actually uses — logging/__init__.py is the only one of the ten string-importing stdlib modules that reaches past the constants, and it reaches for Template. But import logging does not resolve at all today:

error: import: no unit named logging and no shim mimic_logging

So nothing can reach Template until a logging shim exists, and writing it first is building the second storey. Sequence it behind that; raise the prio the moment logging lands.

Directly-written string.Template in application code is rare — it is the $name templating class most people skip in favour of f-strings or .format().

Surface

t = string.Template('$who likes $what')
t.substitute(who='tim', what='kung pao')      # raises KeyError if missing
t.safe_substitute(who='tim')                  # leaves '$what' in place

Note for whoever takes it

Derive the behaviour from a CPython diff, not from the docs — the feature-lib-mimic-string close is a worked example of why: capwords(s, sep) looked like a variation of capwords(s) and is a different function, and three of five edge cases would have shipped wrong from the plausible reading.

Gate

A .npy diffed against CPython 3.12: substitute and safe_substitute, $$, $name, ${name}, ${name} adjacent to text, a missing key on both methods, a malformed placeholder on both, and a non-identifier after $. Build with $(PXX_STABLE); do not rebuild the compiler.

Investigation note 2026-08-30 (frankB) — the kwargs problem, before anyone starts coding

Looked at, not claimed; reprioritised away before implementation. What was established is recorded here so the next person does not re-derive it.

string is a Pascal unit (lib/rtl/mimic_string.pas), and that decides the shape of Template. Thirteen of the seventeen shims in lib/rtl are .py and four are .pas; a module resolves to exactly one shim, so Template cannot be added as Python beside a Pascal string. It has to be a Pascal class. Precedent exists — mimic_codecs.pas, mimic_urllib_request.pas and mimic_urllib_error.pas all declare classes that NilPy constructs and calls, including with keyword arguments (codecs.IncrementalEncoder(errors='strict')constructor Create(const errors: AnsiString = 'strict')).

But the keyword form of substitute cannot work that way. CPython's signature is

substitute(self, mapping={}, /, **kws)

and NilPy's keyword-call support binds a keyword to a declared parameter name — which is why the tkinter façade works, its names being fixed and declared. Template.substitute's keyword names are the caller's placeholders, unknown at compile time, so t.substitute(who='tim') has no declared parameter to bind to and will be rejected with no parameter named 'who'.

The mapping form is expressible and is what the stdlib consumer needs. t.substitute({'who': 'tim'}) is valid CPython and equals the keyword form. logging.StringTemplateStyle calls self._tpl.substitute(**values) — dict unpacking, i.e. the mapping path semantically, not a literal keyword list.

So the shape to build is substitute(mapping: Variant) / safe_substitute(mapping: Variant), with the literal-keyword form failing loudly — which is what devdocs/dev/python-compat-tiers.md requires anyway: a shim states its subset in its own header and fails loudly outside it, never approximating. Say so in the header rather than leaving a caller to discover it.

Open question that stops this being a pure library job: whether Pascal can index a Python dict held in a Variant (m[key], m.__contains__(key)). No helper for it exists in lib/rtl or compiler/builtin. A probe was written and did not get to answer the question, because it tripped a different thing first:

error: import: probe_dict is the Pascal unit .../probe_dict.pas,
       not a Python module

A .pas unit is not importable from .npy by its own name — it is reached only through the mimic_ shim resolution (import stringmimic_string.pas). So the probe must be named mimic_<something> and imported without the prefix. Answer that dict question first; if a Variant dict cannot be read from Pascal, this ticket needs an IR/frontend change and is no longer Track B alone.

The ticket's own sequencing note still holds: import logging does not resolve at all today, so nothing in the stdlib can reach Template regardless.

2026-08-30 (frankB) — IMPLEMENTED. The blocking question was answered YES.

The open question, answered by running it

The previous note ended: "whether Pascal can index a Python dict held in a Variant … Answer that dict question first; if a Variant dict cannot be read from Pascal, this ticket needs an IR/frontend change and is no longer Track B alone."

It can, and the recipe already existed in the treemimic_urllib_request.pas:560 does exactly this for a headers dict. The probe that answers it (named mimic_dprobe, since a .pas unit is reachable from .npy only through shim resolution — the trap the last session hit):

if pyvar_is_objtag(m) then
  if TObject(pyvarobj(m)) is TPyDict then
    d := TPyDict(pyvarobj(m));

d.count, d.indexof(k) >= 0, d.fetch(k), d.keylist all work, and the probe matched CPython exactly on values, presence, count and key order. Note the presence test is indexof(k) >= 0; there is no haskey. So this stayed Track B alone and needed no IR change.

The keyword-argument limit is real, and confirmed by measurement

t.substitute(who='tim') is rejected at compile time:

error: Nil Python: Template.substitute has no parameter named 'who'

which is the loud failure python-compat-tiers.md requires. The shim's header states the subset and quotes that exact diagnostic — verified verbatim, not paraphrased from memory.

A SECOND limit was found, and it is not this class's fault

The previous note assumed substitute(**values) — what logging.StringTemplateStyle actually calls — would work through the mapping path. It does not, for an unrelated reason: ** unpacking is a parse error at any method call in this dialect, pure-Python classes included.

call shape v395
f(**d) plain function ok
c.m(**d) method, pure Python error: expected expression
c.m(*[1,2]) method without defaults ok
c.m(*[1,2]) method with defaults explicit refusal naming defaults as the reason

Filed as [[bug-n-double-star-unpacking-is-rejected-at-a-method-call]] (Track N, p45) with the single-star measurement included, because an early draft of that ticket guessed * and ** were one gap and the measurement showed they are two with different shapes — * is implemented with a stated limit, ** is not parsed at all. This class needs no change when that closes, which is why mapping is a Variant rather than something narrower.

Implementation

lib/rtl/mimic_string.pasTemplate with template, Create, substitute, safe_substitute, and one shared Render(mapping, safe) behind both. Written once deliberately: the two methods differ solely in what they do on a missing key and a malformed placeholder, and two scanners would be two places for the $$/brace/greedy-identifier rules to drift apart — the double-case shape normalise-dont-special-case.md warns about.

Gate: 43-check CPython differential, byte-identical

test/lib_mimic_string_template.npy runs unmodified under CPython 3.12 and both outputs are byte-identical, 43/43, MIMIC-STRING-TEMPLATE OK. Wired into lib-test after the lib_mimic_weakref entry.

Derived by diffing CPython, per this ticket's own instruction — and it earned its keep. Two behaviours are not in the prose docs and would have shipped wrong from the plausible reading:

  1. safe_substitute keeps the braces on an unresolved braced placeholder. The result is the full braced form, not the bare dollar-name form. The two spellings are not normalised to each other on the way out.
  2. An unterminated braced placeholder is a ValueError from substitute even when its name is present in the mapping — the placeholder is malformed and CPython never reaches the lookup.

Also pinned: $$ consumed whole (so $$who is text, never a lookup — the test passes a mapping that would resolve it if the rule were wrong), greedy identifiers ($x_y beats $x), identifiers stopping at -/., and str() coercion of non-string values including None.

Cost note, recorded because it is repeatable

Four compiles were lost to one dialect property: { } comments NEST here, and quotes do not protect a brace inside one. Writing ${who} in a doc comment opens a nested comment at { and closes the outer one at }. The diagnostic is poor in the way that matters — the open-brace case reported unterminated comment at the comment's start, 48 lines above the offending character, and one intermediate state reported unexpected character on a line reading only begin. Not filed: no program behaves wrong, and CLAUDE.md ranks diagnostic quality low by explicit ruling. Recorded here because the next person writing a mimic_ shim about a brace-using syntax will hit it, and the fix is to spell braces in words inside comments.

Sequencing unchanged

import logging still does not resolve, so nothing in the stdlib reaches Template yet. This closes on its own differential — the gate this ticket named — not on a stdlib consumer.

Gate run

make lib-test against pin v395: ok, exit 0, with lib_mimic_string_template.1 (43) and .2 (MIMIC-STRING-TEMPLATE OK) both passing inside the real run, not just standalone. 142 units still compile. The two pre-existing consumers of this unit — test_nilpy_import_string_module and test_nilpy_repr_escapes_non_printables — were checked against CPython separately and still match, since adding uses pylib, sysutils to the implementation changes what every import string program pulls in.

Log