← board

nilpy: *args / **kwargs parameters

Repro

def f(a, b=2, *args, **kw):
    return a + b + len(args) + len(kw)

-> error: Nil Python: expected parameter name near: a b *args

Keyword arguments at the CALL site already work (f(1, b=5) returns 6), and print(..., sep=)-style kwargs are handled ([[feature-nilpy-print-kwargs]], done) — this is the callee side.

Why it matters

songformatter's settings helpers forward through getF(*args, **kwargs) / getI(*args, **kwargs) to get(), which is the standard thin-wrapper idiom. A GUI façade over tk.pas will want it too: tkinter's whole API is kwargs.

Shape

*args as a tuple/list parameter, **kwargs as a dict; forwarding a *args/**kwargs on to another call is the case songformatter actually needs, so the unpacking side at the call site belongs here as well.

Gate

make test-nilpy green with a .npy case covering collection AND forwarding, diffed against CPython, + --tier quick + self-host byte-identical.

Update (2026-07-26) — this is what blocks the tkinter façade, measured

The façade in [[feature-nilpy-tkinter-facade]] cannot be written without it, and the reason is sharper than "tkinter uses kwargs". Two measurements:

  1. Keyword arguments must fill a contiguous PREFIX of the parameters. Skipping an optional fails:
class W:
    def __init__(self, master: str, width: int = 0, text: str = "") -> None: ...
W("root", width=7, text="hi")     # works
W("root", text="skipped-width")   # error: W() needs a value for every field
                                  #        before the last one given

So a façade CANNOT just declare the ~40 tkinter option names as optional parameters and let callers pick a few — which was the obvious way to avoid needing **kwargs at all. Every real call skips most options (tk.Canvas(self, highlightthickness=0), tk.Label(self.content, text=k, anchor="e")).

  1. __init__ must be annotated -> None or the class is rejected. Unrelated to kwargs, and a fine rule, but worth knowing when writing shim classes.

Fixing the prefix restriction alone (bind keyword arguments by NAME, leaving unmentioned optionals at their defaults) would unblock a declared-parameter façade without needing **kwargs at all, and is a smaller change. Real **kwargs is still wanted for forwarding wrappers (songformatter's getF(*args, **kwargs)), but the tkinter path only needs by-name binding.

Recommended split: (a) keyword arguments bind by name, any subset — small, unblocks the façade; (b) *args/**kwargs collection and forwarding — the original scope.

Update (2026-07-26, later) — now blocks TWO modules, and splits into three rungs

After the field-inference and method-default fixes, convertrawtext.py cleared its whole 1600-line FormatText class and stops at line 59; settings.py stops at line 102. Both on this. It is the single highest-value item left on [[feature-demo-songformatter-pxx-target]] because it is the only one that pays off twice.

The two real use sites need DIFFERENT machinery, and the difference is the whole design question:

def debug(*args):            # convertrawtext.py:59
    ...
    print(*args)             # unpack into PRINT

def getF(*args, **kwargs):   # settings.py:102
    return float(get(*args, **kwargs))   # forward into FIXED parameters

Rung 1 — collection (easy). *args is one TPyList parameter; each call site collects its surplus positional arguments into a list literal, which is machinery that already exists (PyMakeTupleFrom does exactly this shape). **kwargs is one TPyDict built the same way from the keyword arguments.

Rung 2 — print(*args) (easy, special case). print already takes a variable argument list; unpacking a TPyList into it means printing the elements space-separated, which the print path can do directly without a general mechanism. That alone unblocks convertrawtext.py.

Rung 3 — forwarding into FIXED parameters (the hard one). get(*args, **kwargs) where get(section, option, default=None) has ordinary parameters requires binding a RUNTIME-length list and dict onto a compile-time parameter list. There is no static answer: the arity is only known at run time. Options, in increasing cost — (a) accept it only when the callee ALSO takes *args/**kwargs, i.e. pass the containers straight through, which does NOT cover settings.py; (b) emit a small dispatch that switches on len(args) and calls the fixed-arity callee for each supported count, which does cover it and is bounded by the callee's parameter count; (c) a general dynamic-call path, which overlaps [[feature-lib-pyexec]].

Option (b) is the pragmatic one: getF(*args, **kwargs) becomes a switch over the argument count against get's 2-or-3 parameters. Worth writing down that it is a DESUGARING, not a runtime feature, so it fails loudly when the callee's arity is unknown rather than guessing.

Rungs 1 and 2 LANDED (2026-07-27)

test/test_nilpy_star_args.npy, every expectation diffed against CPython's own output for the same file.

Rung 1 — collection. *args is one TPyList parameter, **kwargs one TPyDict, so the body indexes, slices, iterates and len()s them with the machinery lists and dicts already have. The call site packs: surplus positional arguments become append calls on a hidden list temp, keyword arguments that match no declared parameter become setitem on a hidden dict temp, and both containers are ALWAYS passed (empty when nothing was collected), which is what keeps the callee's arity fixed. Pieces: PyHdrStarIdx/PyHdrKwIdx in the one header grammar, ProcPyStarIdx/ProcPyKwIdx per proc, PyPackStarArgs run just before PyBindKwArgs at the two function-call sites. An unmatched keyword name is carried from PyKwArgIndex to the packer as a NEGATIVE ASTIVal encoding the key literal's node index, because the key must survive until the whole list is parsed.

Rung 2 — print(*args). pylib's pyprint_star(list, leadSep) renders the unpacked run as one string, separators included. The separator lives in the helper rather than being injected at the call site so that print("a", *[]) prints a and not a — an empty list must contribute no space. A positional argument AFTER a starred one is REFUSED, not approximated: whether a separator belongs between them is a run-time fact, and printing a stray one is silently wrong output.

Not covered, deliberately:

Walls moved. convertrawtext.py cleared def debug(*args) + print(*args) and now stops at import tempfile (line 64). settings.py cleared getF/getI and now stops at import tkinter as tk (line 2, the module body — the def-shell pre-pass used to fail before the body was ever reached), i.e. import X as Y aliasing is its next wall, ahead of the façade itself.