← board

A nested import guard compiles the arm the program excluded

Why this is a correctness bug and not an unsupported shape

The owner's framing, relayed 2026-09-20, and it is the reason this is not an edge case:

"plus, we only do so due to lack of #define and python's design. so, this is by design."

C has a preprocessor. Python decides at import time and expects ImportError to be caught, so try: import X / except ImportError: is Python's #ifdef -- the only conditional-compilation mechanism the language has. NilPy resolving it at COMPILE time honours that intent rather than bending it.

A NESTED guard is therefore nested conditional compilation, and resolving an arm the program excluded is compiling code the source said not to compile. That framing decides the fix's shape: the feature was wrong at every nesting depth, so the fixture asserts three levels rather than the two the report happened to contain.

Where it was filed, and the gap that matters more than the bug

Reported as lekkerzeilen blocker 07, in the lekkerzeilen repo (devdocs/pxx-blockers/07-nested-import-guard-compiles-the-dead-arm/, commit 5a7fc43) -- a pxx compiler bug with no pxx ticket. Invisible to tools/progress.sh ready, to the ranker, and to Track T, and a citation into another repo's tree is not something a pxx seat can resolve. This ticket is the pxx-side home; the fixture below is the reduction's second home, restated against modules a pxx seat can run.

Reproduce

Two 10-line packages differing only in nesting. Flat compiles and prints 7; nested is refused:

try:
    import __pxx__               # absent -> handler
except ImportError:
    try:
        import ctypes           # absent under pxx -> handler
    except ImportError:
        from . import _native as _backend
    else:
        from . import _ct as _backend    # DEAD, and compiled anyway
pascal26:3: error: no member c_uint came of the qualifier ctypes
  in: .../repro_nested/_ct.py

Reproduced at HEAD compiler dc03cd5e78e3, not only at the reported ec905950c45a0bc1.

The boundary, measured before anything was read

Twelve variants, one axis at a time. else present unless noted; every row prints 7 when correct.

where the guard sits before
top level ok
inside if True: ok
inside a def ok
inside a for ok
inside a try BODY REFUSED
inside an except ValueError: handler REFUSED
inside an except ImportError: handler (the report) REFUSED
inside a handler, guard has no else ok

The trigger is an enclosing try, not an enclosing block and not the fallback handler specifically -- except ValueError breaks it too. The no-else row is NOT a control and nearly filed the diagnosis wrong: removing the else removes the dead arm entirely, so there is nothing left to compile wrongly. The row that actually discriminates puts the dead arm in the HANDLER and lets the try body resolve (import math), which fails inside an outer try and passes at top level -- same guard, same arms, only the nesting differs.

THE TWO TRIES NEED NOT BE ADJACENT

The enclosing try can be any distance away, through any intervening construct, because the pre-scan compares INDENT depth against the tracked try's depth and an ordinary block pushes the guard deeper just as a try does. Measured:

try:
    x = 1
    if x == 1:              # an ordinary block BETWEEN the two tries
        try:
            import math
        except ImportError:
            from . import dead as impl
        else:
            from . import live as impl
except ValueError:
    pass
CPython   impl live
pinned    SIDE EFFECT: dead arm ran / impl dead
HEAD      impl live

Both halves fire. So an audit for affected code must ask whether any try lexically CONTAINS another try that guards an import — at any distance. A search for try: directly inside try: answers zero on the block above.

Noted because the first audit of a downstream application asked about "try-depth 2+", and a depth number is a proxy for a lexical-containment question rather than the question itself.

And that table answers only the REFUSAL question, which is the rare half

Every "ok" above means compiled and printed 7. It does NOT mean the program behaved. Measured afterwards, against stable_linux_amd64/default/pinned, with a print at module level in the dead arm:

CPython pinned (pre-fix) HEAD
live arm is an else: impl live side effect RAN, impl dead impl live
no else:, live arm first impl live-try side effect RAN, impl live-try impl live-try

The excluded module's top-level code EXECUTED, in a program where CPython never imports it at all. That half needs only the nesting — no name collision, no else:.

The WRONG VALUE needs more: both arms binding the same name AND the live arm lexically AFTER the dead one. The unit-alias table is first-wins, so in the ordinary no-else idiom the live arm is lexically first and wins by position — the value is right and only the side effect leaks, which is why the second row looks clean and is not.

So the row marked "ok, guard has no else" in the boundary table is ok for the question that table asked and was never a clean bill of health. Stated separately because a reader scanning the table would otherwise carry away that the no-else idiom was unaffected.

Mechanism

PyPreScanImports walks every import token ahead of the parse and resolves it. It tracked one try in scalars, and tryAt holds a DEPTH, not a token index.

The two lines below are the PRE-FIX source and are no longer in the tree — do not grep for them expecting a hit; the current shape is the stack described under Fix:

else if (Tokens[i].Kind = tkTry) and (tryAt < 0) then   { nested try IGNORED }
  tryAt := depth;
...
if (tryAt >= 0) and (depth <= tryAt) then                { clause logic }

An inner try never became current, so at its clause boundaries depth was greater than the OUTER try's tryAt and the arm-deadness test was skipped entirely. Measured with the tag added by this commit:

vL (top level, works)     DEDENT to depth 0 tryAt=0 -> clause logic 1
vK (inside a try, broken) DEDENT to depth 1 tryAt=0 -> clause logic 0

and the inner try produced no TRACKED line at all.

Why the flat case was always right: at top level the single tracked try IS the guard, so the depth key is correct there. The suite covered the one arrangement in which the broken key gives the right answer.

Why the parser layer was innocent, and how that was established rather than assumed: a tag on PyIsFallbackImportTry printed NOTHING for the broken shape -- not even for the outer try. A predicate that is never called cannot be the layer at fault, and the silence named the pre-scan in one run.

Fix

A stack, one entry per open try, pushed on every tkTry and popped when the try statement ends. Two things that are not obvious:

A miss now marks the INNERMOST open try rather than the outermost, which is the correct reading: an inner guard catches its own ImportError.

Fixture

test/test_nilpy_a_nested_import_guard_does_not_compile_the_dead_arm.npy with package test/nilpy_nestguard/, wired into test-nilpy beside the flat sibling it complements.

THREE deep, not two -- a fix restoring only the second level passes a two-deep fixture.

No row guards on ctypes, for the reason nilpy_tryelse's header already records: CPython resolves it and pxx does not, so oracle and subject would run DIFFERENT arms and the differential could never fail. Two modules absent under both runtimes, plus math, present under both. The reduction uses ctypes correctly -- it is about a real backend -- and that is exactly what stops it being a differential test.

POSITIVE CONTROL, and it is SILENT, which is why the fixture asserts a value and not a compile. Against stable_linux_amd64/default/pinned (v412, predating the fix), exit 0 and no diagnostic:

WHICH unit alias symbol floor
correct deep-else deep-hit sym-deep-hit 2
pinned deep-else deep-miss sym-mid-else 2

WHICH is right -- the parser selected the correct arm -- and both member reads are wrong, by different dead arms: the unit alias answers deep_miss (first-wins, and the live arm is lexically last on purpose), the symbol answers mid_else through flat unit scope. The reported reduction fails loudly only because its dead arm imports ctypes; where a dead arm is ordinary Python the whole thing is a plausible wrong value far from the cause.

What the fixture does NOT assert, and why it was not added

The SIDE-EFFECT half has no row. The fixture catches the wrong-value half only: its arm modules declare constants, so a regression that resolved a dead arm again would be caught by the alias answering deep-miss, but a regression that ran a dead arm's top-level code WITHOUT changing any binding would pass. Given the no-else measurement above — value correct, side effect leaked — that combination is real and not hypothetical.

Not added because the measurement arrived while the tier was already running on this fixture, and editing a test file mid-run means the suite grades a version nobody ran locally first. The row is one print in a dead arm plus the matching oracle line; it belongs in the next sitting, with its own tier. Recorded here rather than remembered, because a fixture that covers one of two halves reads as covering the defect.

What this unblocks

lekkerzeilen's backend block, on both compilers that exist today, with no dependency on the marker-module name. Trace, with the nested guard working:

compiler path result
today's pxx marker absent -> ctypes absent -> _native correct
CPython marker absent -> ctypes present -> _ct correct
a future pxx shipping the marker first arm correct

So the marker module is a SIMPLIFICATION and this was the unblocker -- the reverse of how the dependency was described while it was open. Measured here for the two compilers that exist; the third row is untestable until a marker ships and is labelled so rather than claimed.

Instruments left behind

Two PXXDBG tags, because this area is a known two-layer trap -- the compiler's own comment says "THE PARSER NEEDS THE SAME SKIP AND HAS ITS OWN ... fixing either alone changes nothing":

n.impscan's open= count was printed BEFORE the push for an hour and read one too few for every nested try -- an instrument understating exactly the quantity the fix is about. Corrected before landing.

Log