import string in a .npy resolves to crtl's string.h
- Type: bug (a wrong resolution that reads as success) — Track N, with a Track C surface (the crtl include path)
- Found: 2026-08-13, compiling html5lib for
[[feature-nilpy-thirdparty-libraries-as-targets]] —
constants.pyopens withimport string.
Measured
$ cat st.npy
import string
print(string.ascii_lowercase[:5])
$ pxx st.npy st
warning: #include <features.h> resolved from the host system (/usr/include),
not pxx's own headers — ABI/macro mismatches ... may silently misbehave
error: undefined variable (ascii_lowercase)
The warning is the tell: a Python program has no business including
features.h. import string found lib/crtl/include/string.h, and the C
preprocessor then pulled the host system's features.h.
It is not special to string — any crtl header name imports:
$ printf 'import stdio\nprint(1)\n' > p4.npy && pxx p4.npy p4
warning: #include <features.h> ... <- and it COMPILES
import stdio is not a Python module and must be no module named stdio.
import math takes the same route (same warning) and happens to work anyway,
which is the dangerous half: the wrong resolution is invisible when the names
coincidentally line up.
Why it matters beyond the noise
- A missing module reads as present. The real error surfaces much later and
names something else —
undefined variable (ascii_lowercase)sends the reader to the attribute, not to the import. - Host headers enter a NilPy build. The warning is about exactly the ABI
hazard the crtl exists to avoid; a Python program should not be able to reach
/usr/includeat all. --no-shimscannot see it. That flag makes amimic_*substitution an error, and this is not a shim substitution — it is a header masquerading as a module, so a "compiled with no shims" claim would survive it.
Fix shape
The import resolver's candidate list should be language-scoped: a .npy
import may resolve to a NilPy module, a mimic_* unit or a Pascal unit, and NOT
to a .h in the C include path. The C include path belongs to #include, not
to import. Worth checking the same question for the Rust and Zig frontends
while in there.
Then import string correctly reports no module — at which point it becomes a
mimic_string request (ascii_lowercase, ascii_uppercase, digits,
punctuation, whitespace, capwords), which is small and is what
html5lib/constants.py actually wants.
Gate
import stdio in a .npy is a compile error naming the module; import string
either resolves to a real shim or errors, and neither pulls a host header; a
.npy build emits no features.h warning; the existing NilPy import tests stay
green.
REVERTED 2026-08-13 — the premise above is WRONG, and the ticket has to change
The fix (gating the /usr/include arms on not isNilPy) was landed and then
reverted the same day: Track T's watcher turned four tests red at that sha —
test_nilpy_c_pointer, test_nilpy_c_define_const (test-core) and
test_nilpy_import_sqlite, test_nilpy_sqlite_crud (test-nilpy).
Reading them says why, in their own first lines:
# Nil Python `import` maps onto the Pascal unit resolver, so a C header in
# /usr/include is imported and dynamically linked.
import sqlite3
print(sqlite3_libversion_number())
import stdlib
p = malloc(64)
free(p)
import <c-header> is a DESIGNED NilPy feature — the wrapper-free
nilpy↔C arc — not an accident. import stdio compiling is correct behaviour.
So the ticket's headline claim ("must be no module named stdio") is wrong and
the blanket gate was the wrong shape.
What is still a real problem
Only the collision: a Python stdlib name that also names a C header. import string finds /usr/include/string.h instead of the Python string module, and
the failure surfaces later as undefined variable (ascii_lowercase). The host
features.h warning riding along is the second half.
The fix shape, corrected
Do NOT remove the C-header route. Instead order the candidates: for a
.npy, a NilPy module / mimic_* unit must be tried BEFORE a C header, so
mimic_string wins over string.h while import sqlite3 still reaches the
header it means. That also makes the whole thing land as "write mimic_string",
which is the actual need, rather than as a resolver amputation.
Worth adding while there: a diagnostic when a .npy import resolves to a host
/usr/include header, since that is the case that is silently surprising
(sqlite3 is deliberate, string is not).
Two process lessons, recorded because they cost the red
make test-nilpydoes not cover the NilPy C-interop tests —test_nilpy_c_pointerandtest_nilpy_c_define_constlive in test-core. A change to the IMPORT RESOLVER is a test-core change too, whatever the frontend it looks like it belongs to.- Read the tests that name the thing you are "fixing" before you fix it.
ls test | grep importwould have showntest_nilpy_import_sqlite.npy, whose first comment is the design statement this ticket contradicted.
Log
- 2026-08-14 — resolved, commit 1707175de.
RESOLVED 2026-08-14 — the shim was the missing half
The resolver ordering (a mimic_ shim is tried BEFORE a host header for a
.npy import) landed 2026-08-13 and was inert, because no mimic_string
existed to win. Writing it completes the fix:
import string→note: string -> mimic_string (shim, subset), nofeatures.hwarning, every constant byte-identical to CPython's.import sqlite3/import stdlibstill reach their C headers — the designed NilPy↔C route, which the first attempt at this ticket wrongly amputated.capwordsis included because it is the one part of the module that is not data, and it is NOTs.title():capwords("don't")isDon't,"don't".title()isDon'T.
It also found a real bug one layer down. string.whitespace is six
characters and repr() rendered four — the VALUE was right, the renderer was
not: repr emitted control bytes raw where CPython escapes them \xNN. Fixed in
the same commit and tested separately
(test/test_nilpy_repr_escapes_non_printables), because a repr containing a
raw NUL truncates whatever consumes it downstream.
Tests test/test_nilpy_import_string_module and
test/test_nilpy_repr_escapes_non_printables, both wired into test-nilpy.
The two test_uses_order_pylib_exception_* canaries and the NilPy↔C interop
tests (import_sqlite, c_pointer) re-run by name and unchanged.