Skip to content

fix: bind keyword arguments and defaults for module functions - #58

Merged
ylluminate merged 1 commit into
vlang:masterfrom
ylluminate:fix-keyword-arguments
Nov 13, 2026
Merged

ylluminate merged 1 commit into
vlang:masterfrom
ylluminate:fix-keyword-arguments

Conversation

@ylluminate

Copy link
Copy Markdown
Contributor

py2v's ordinary call emission appended keyword values in the order they were written, after the positional arguments and any defaults it filled in, so keyword arguments in a call to one of a file's own top-level functions could end up in the wrong parameters. It also didn't fill in a default left out of a call above the function's def.

def area(w: int, h: int = 2, scale: int = 1) -> int:
    return w * h * scale

print(area(3, scale=10))
print(area(h=5, w=4))

came out as area(3, 2, 1, 10), which V refuses with "expected 3 arguments, but got 4", and area(2, 1, 5, 4). Now the arguments are matched to the parameters by name: area(3, 2, 10) and area(4, 5, 1).

This only applies when a single file is translated, to calls of functions defined at the top level of that file, and only under the conditions below. When a package is translated (--package) nothing changes, because another file can call the function and this pass doesn't see that call. Any other call is emitted as before. A call is left alone when:

  • the function has a decorator, *args, **kwargs, positional-only parameters, a self or cls parameter, is a generator, or changes one of its parameters in place (appends to a list parameter, for example);
  • the function's name is bound anywhere else in the module, in any scope (a second def, an assignment, a parameter, an import), or the module has a star import;
  • the call has a starred argument (*xs or **d), a keyword the function doesn't have, or runs at module level above the end of the function's def (the body of if __name__ == "__main__": counts as module level, and so does a def main() with no parameters and no return annotation, which is how py2v represents that block);
  • a default it would have to fill in isn't a plain literal (a constant other than bytes or ..., or a negative number);
  • the keywords are in another order than the parameters and any argument is more than a constant, a name or a negative constant, since moving such an argument would change the order things are evaluated in.

If any call to one of the file's top-level functions that uses a keyword or omits a default can't be matched this way, no call in that file is changed. The same goes when the file calls anything else by name that isn't one of the builtins py2v translates itself (a class, a nested function, a lambda, an imported name, a function held in a variable, and also a builtin's name that the file binds itself, such as its own class Path or its own def sum), or passes keyword arguments to anything else (a method, a builtin such as print(x, end="") or pow(exp=3.0, base=1.0)). So a file is only changed when every call by name in it uses the name of one of its top-level functions or of one of those builtins, and no other call has keywords. That is stricter than it looks like it needs to be, but on master two wrong calls in one file can cancel each other out, and fixing only one of them makes a program that printed the right thing print the wrong thing. tests/cases/call_binding_alias.py is such a program: difference(b=2, a=5) + runner(b=5, a=2) with runner = difference prints 0 on master and still does.

What this doesn't rule out is the keyword error cancelling something else py2v gets wrong in the same file. Two examples. py2v drops an optional argument of some builtins and string methods (the start of sum and enumerate, the base of int, the third argument of pow, the maxsplit of split), so print(difference(b=5, a=2) + sum([0], 6)) prints 3 on master, as Python does, only because both calls are wrong, and -3 with this change. And print(difference(b=2, a=5) + 2 * (4 % -3)) prints -1 on master only because % with a negative divisor is also wrong there; with this change it prints 5. This PR doesn't try to exclude those, since each one needs its own fix, and the same would be true of a fix to any one wrong translation.

Like #56, this uncovers other things py2v already gets wrong: 6 of the programs below failed in the V build because of the call and now build and print something other than what Python prints (an int passed to a float parameter prints 8.0, / on two ints divides as ints, and so on). For each of them the same call written positionally prints the same wrong output on master.

Test plan

  • tests/cases/call_binding.py with its expected V: keyword-only parameters, a skipped default in the middle, a negative default, a string default with a quote in it, a keyword in a recursive call, a list argument. tests/cases/call_binding_alias.py pins the alias case to what master emits.
  • v tests/run_tests.vsh: 169 passed, 0 failed (167 before, plus the two new cases). No other expected file changes. v test py2v_test.v passes.
  • 482 generated and handwritten programs compared with CPython's output (V ec510e2, Python 3.15.2, macOS arm64): 126 that failed to build or printed the wrong thing now match, and all 152 that matched before still do.
  • A second set of 362 programs, written separately without looking at the first: 185 now match that didn't, and the 58 that matched still do. Of its 40 programs written to hit one of the conditions of an earlier revision, 36 emit the same V as master and none of the other 4 matched before.

@ylluminate
ylluminate merged commit bbac35f into vlang:master Nov 13, 2026
2 checks passed
@ylluminate
ylluminate deleted the fix-keyword-arguments branch November 13, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant