← board

nilpy: unannotated return <variant> + <str> infers a number

Repro

def mk(k, t, reason="r"):        # no return annotation
    return k + reason

print(mk("a", 9, reason="X"))    # pxx: a large integer (or TypeError at run time)
                                 # CPython: aX

Add -> str and it is correct. The parameter k is unannotated, so it is Any (a variant); reason is a defaulted str. k + reason is therefore a variant+string concatenation, but PyInferDefRetType types the def's result as a number, and the caller then reads the returned string handle as an integer.

Two shapes were seen from the same cause, depending on what the value met next: printing it gave a garbage integer, and using it in another concatenation raised TypeError: expected a number, got str at run time.

Where

PyInferDefRetType (pyparser.inc) walks the body's return expressions. For a binary + it needs the same rule the expression parser already applies: if either operand is string-ish the result is a string, and if the operands are a variant and a string the honest answer is a string too (concatenation is what pyadd_v does at run time). When it cannot decide, tyVariant is the safe answer — boxing costs a little, guessing costs correctness.

Why it matters

Unannotated helpers are ordinary Python, and this is the most common shape there is: build a label from a couple of values and return it. songformatter's key_analysis.py is full of them.

Gate

make test-nilpy green with a .npy case covering an unannotated def returning a variant+str concatenation, the result printed AND fed into another concatenation, diffed against CPython, + tools/gate.sh quick.

Log

Resolution

Fixed by dcbca98c1 ("fix(nilpy): return inference must agree between passes"), which is the same PyInferDefRetType work that closed [[bug-nilpy-comparison-return-type-from-operands]]. The ticket was left in backlog/ by that commit.

Re-verified 2026-07-28: this ticket's repro prints aX, matching CPython.