A dict cannot be unpacked into a call
Found 2026-08-16 by a tools/pydiff.py sweep, not by a report.
Measured — the boundary is "is it the enclosing def's own star pair"
def f(a=1, b=2): return (a, b)
d = {"a": 5, "b": 6}
f(**d) # pascal26: error: expected expression near: f >>> d
def g(**kw): return kw
g(**d) # same
class C:
def __init__(self, **kw): self.n = len(kw)
C(**d) # same
C().m(**d) # same
def fwd(*args, **kwargs):
return f(*args, **kwargs) # WORKS — the one supported shape
So the star pair is only understood when it names the enclosing def's own
collectors, which is what PyStarForwardCall was built for. An ordinary dict
is not accepted anywhere.
Why it matters
f(**opts) is one of Python's core call forms — config dicts, argparse
namespaces, dict(**base, extra=1), every wrapper that passes options
through. And the failure is a PARSE error naming neither the star nor the
callee, so it reads as a syntax problem in the user's code.
Design — the callee's parameters are known, so this can be a desugar
The machinery is nearly all there. PyStarForwardCall already desugars
f(*args) into a dispatch over the arities the callee accepts, and refuses
keyword forwarding with the comment "binding a runtime dict onto named
parameters needs a call protocol NilPy does not have". That is true of the
GENERAL case and not of this one: at a call site the callee is known, so its
parameter names are known, and f(**d) can lower to
pystar_check_kwnames(d, "a", "b", ...) (hoisted; refuses an unknown key
exactly as CPython's TypeError does)
f(a := d.get("a", <default a>), b := d.get("b", <default b>), ...)
with a required parameter's missing key raising rather than defaulting. That is the same shape the positional forwarder uses, one level simpler because there is no arity dispatch — the slot count is fixed.
Mixed f(x, **d) and f(**d, y=1) fall out of the same lowering: the
explicitly-written arguments win their slots and the dict fills the rest.
The genuinely hard case stays refused: a callee that is a VALUE (a variable
holding a function) has no known parameter list, so cb(**d) there needs the
runtime protocol and should say so by name.
Gate
A .npy diffed against CPython covering: a plain def, a defaulted def with a
partial dict, a **kwargs def, a method, a constructor, the mixed forms, an
unknown key (TypeError), and a missing required key (TypeError). Plus the
per-fix loop.
MEASURED 2026-08-17 (frank2, Track N) — the filed diagnosis is wrong in three places, and the real defect is ~5 lines in a file Track N may not edit
Measured against compiler/pascal26 at HEAD, CPython as the oracle. Do not
build the desugar this ticket designs — almost all of it already exists and
works.
Correction 1: ** is NOT a parse error for every callee
dict(**d) compiles and runs, including the mixed forms:
| pxx | CPython | |
|---|---|---|
dict(**d) |
{'a': 5} |
{'a': 5} |
dict(d, **{"b":6}) |
{'a': 5, 'b': 6} |
same |
dict(**d, b=6) |
{'a': 5, 'b': 6} |
same |
Correction 2: the boundary is NOT "the enclosing def's own collectors"
f(*lst) and f(*t) with an ordinary list/tuple — nothing to do with any
enclosing def — both work and match CPython. Positional unpacking is general
already.
Correction 3: the keyword machinery is BUILT, and correct
PyStarForwardCall(procIdx, listNode, dictNode) already takes a dict, and it
already binds keys onto named slots while preserving defaults. Proof, today, on
the unmodified compiler:
def f(a=1, b=2): return a + b * 10
d = {"a": 5, "b": 6}
print(f(*[], **d)) # pxx 65 CPython 65
print(f(*[], **{"b": 9})) # pxx 91 CPython 91 <- default for `a` preserved
So the desugar this ticket proposes designing — pystar_check_kwnames plus
per-slot d.get(name, default) — is already implemented. Writing it again
would be a second mechanism for one concept
(devdocs/dev/normalise-dont-special-case.md).
The actual defect: a leading ** consumes one star and then parses *d
compiler/parser.inc:15874-15878:
else if isNilPy and (CurTok.Kind = tkStar) and (ProcPyStarIdx[procIdx] < 0) and
not PyStarIsIterableForm(name) then
begin
Next; { the '*' }
ParseArgExpr; { <-- CurTok is the SECOND '*' of a `**` }
** is two tkStar — the trailing branch twelve lines below says exactly that
(if CurTok.Kind = tkStar then Next; { '**' is two tkStar }) — but the entry
test only checks for one and never looks ahead. So f(**d) enters the branch,
eats one star, and ParseArgExpr fails on *d with "expected expression",
which is the reported symptom and names neither the star nor the callee.
The fix is to give the leading position the look-ahead the trailing position
already has: on entry, if the next token is also tkStar, consume both, parse
the dict into fwdDict, and synthesise an empty list literal for fwdList —
i.e. lower f(**d) to the f(*[], **d) that already works. No new runtime, no
new protocol, no change to PyStarForwardCall.
THIS IS A TRACK A CHANGE — filed, not fixed
compiler/parser.inc is shared (A/P). Per CLAUDE.md, Track N files and hands
off; the coordinator session holds the A/P slot. Filed as
[[bug-a-nilpy-leading-double-star-in-a-call-is-not-detected]]. This ticket is
blocked-by it and moves to unfinished/.
Three FURTHER gaps this exposed — separate tickets, not part of the above
Fixing the parse only reaches what f(*[], **d) reaches today, which is
free functions with ordinary parameters. Measured with the working proxy:
| callee | result today | |
|---|---|---|
def f(a=1, b=2) |
works, defaults preserved | ✅ |
def g(**kw) |
run-time TypeError: forwarded call got 2 arguments, expected 1 to 1 |
✗ |
C.__init__(self, **kw) |
compile error: *"C(xs) where the constructor itself takes *args or **kwargs is not supported yet" | ✗ |
C.m(self, a=0, b=0) |
compile error: "*unpacking into C.m is not supported — it has parameters with defaults" | ✗ |
The last two already refuse by name with their own diagnostics, so they are
known gaps rather than discoveries; the **kwargs callee failing at RUN TIME
with a misleading arity message is the one worth its own ticket. All three are
independent of the parse defect and none of them should be bundled into it —
the parse fix is small and shippable on its own, and stapling them together is
what would turn a five-line change into a project.
Method note
The ticket's design section is careful and plausible and was written without
running f(*[], **d). One command would have shown the machinery already
existed. Same failure as frank2-search-done-before-designing, one level in:
grep the tree for the capability before designing it, and when a ticket says
"the machinery is nearly all there", check whether it is in fact all there.
2026-08-17 — the headline case is FIXED (a057789bc); the MIXED forms are not
Verified at HEAD after the handoff landed, make compiler/pascal26 converged,
CPython as oracle:
| shape | before | after a057789bc |
|---|---|---|
f(**d) plain def |
parse error | 65 ✅ (CPython 65) |
f(*lst) |
43 | 43 ✅ no regression |
dict(**d) |
{'a': 5} |
{'a': 5} ✅ no regression |
f(3, **d) |
parse error | error: expected expression ✗ |
f(**d, b=7) |
— | error: unexpected token ✗ |
f(**d, **e) |
— | error: unexpected token ✗ |
Why the mixed forms are a SEPARATE defect, not an incomplete fix
The fix gave the leading position the look-ahead the trailing one had, which is exactly what was scoped. The mixed forms fail somewhere else:
f(3, **d)never enters the star-forwarding branch at all — that branch is guarded onCurTok.Kind = tkStarat the start of the argument list, and here the first token is3. So it goes down the ordinary argument loop, which has no**element handling. Fixing it means teaching that loop, not extending the branch.f(**d, b=7)/f(**d, **e)do enter the branch, but its trailing handling accepts only a single*/**follower.
This ticket's own design section predicted "Mixed f(x, **d) and f(**d, y=1)
fall out of the same lowering" — measured, they do not. They are the ordinary
argument loop's problem.
Filed separately: [[bug-a-nilpy-double-star-in-a-mixed-argument-list]] (Track A,
parser.inc again — not N's file).
Status
The ticket's title claim is now false for the simple form and true for the mixed one. Resolving it on the simple form, with the residual carried by the new A ticket and the three callee gaps below carried separately. Keeping it open would hide that the common case works.
The three callee gaps (unchanged, still out of scope here)
Re-measured at HEAD: def g(**kw) called as g(**d) now gets past the parse
and fails at run time —
TypeError: forwarded call has no value for parameter 'kw' —
an unexpected keyword argument was passed
(the message changed with the fix; it was "got 2 arguments, expected 1 to 1").
Still wrong, still misleading — a **kwargs callee should simply receive the
dict. PyStarForwardCall is in pyparser.inc, which IS Track N's file, so
unlike the parse defects this one is mine to fix. Taking it next.
Constructors-with-**kwargs and defaulted-methods stay refused by name.
Log
- 2026-08-17 — resolved, commit 34e4bfe86.