From 90ca611d6c568341e495edd6e147b07f62299812 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:18:05 +0000 Subject: [PATCH] feat(lower): **kwargs params lower as dict[str, T] + leftover-keyword packing def f(a, **kwargs: T) appends a dict[str, T] param to the signature, mirroring *args: T -> list[T]. Call sites bind leftover named keywords into a dict literal in declaration order; kwargs is a normal dict in the body. f(**d) call-site unpack stays fenced (a dict type carries no static key set to prove member binding). Unannotated **kwargs keeps the PS1202 parameter-form fence. --- src/pyscriptc/lower/_calls.py | 43 +++++-- src/pyscriptc/lower/_classes.py | 18 ++- tests/test_t2_kwargs.py | 215 ++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 tests/test_t2_kwargs.py diff --git a/src/pyscriptc/lower/_calls.py b/src/pyscriptc/lower/_calls.py index 8ff414f3..d714d15d 100644 --- a/src/pyscriptc/lower/_calls.py +++ b/src/pyscriptc/lower/_calls.py @@ -1237,7 +1237,9 @@ def _fill_args(self, node: ast.Call, params: list[dict[str, Any]], what: str) -> self.fail(node, "PS2002", "**kwargs are not lowered") if any(isinstance(arg, ast.Starred) for arg in node.args): self.fail(node, "PS2002", "starred calls are not lowered") - var_index = next((index for index, param in enumerate(params) if param.get("vararg")), None) + var_index = next( + (index for index, param in enumerate(params) if param.get("vararg") or param.get("kwarg")), None + ) if var_index is not None: return self._fill_varargs(node, params, var_index, what) if len(node.args) > len(params): @@ -1278,8 +1280,9 @@ def _fill_varargs( self, node: ast.Call, params: list[dict[str, Any]], var_index: int, what: str ) -> list[dict[str, Any]]: fixed = params[:var_index] - varparam = params[var_index] - if any(kw.arg == varparam["name"] for kw in node.keywords): + varparam = params[var_index] if params[var_index].get("vararg") else None + kwparam = params[-1] if params[-1].get("kwarg") else None + if varparam is not None and any(kw.arg == varparam["name"] for kw in node.keywords): self.fail(node, "PS2002", "a starred parameter is not passed by keyword") positional = list(node.args[: len(fixed)]) extra = list(node.args[len(fixed) :]) @@ -1288,10 +1291,14 @@ def _fill_varargs( for index, arg_node in enumerate(positional): slots[index] = self._expr(arg_node, fixed[index]["type"]) seen: set[str] = set() + leftover: list[ast.keyword] = [] for kw in node.keywords: assert kw.arg is not None if kw.arg not in names: - self.fail(kw, "PS2003", f"unexpected keyword argument {kw.arg}") + if kwparam is None: + self.fail(kw, "PS2003", f"unexpected keyword argument {kw.arg}") + leftover.append(kw) + continue index = names.index(kw.arg) if index < len(positional) or kw.arg in seen: self.fail(kw, "PS2003", f"{kw.arg} given more than once") @@ -1307,15 +1314,27 @@ def _fill_varargs( if not types_equal(got["type"], param["type"]): self.fail(node, "PS1003", f"argument {param['name']} of {what} has the wrong type") args.append(got) - elem = varparam["type"]["elem"] - elems = [] - for arg_node in extra: - value = self._expr(arg_node, elem) - if not types_equal(value["type"], elem): - self.fail(node, "PS1003", f"argument {varparam['name']} of {what} has the wrong type") - elems.append(value) span = span_of(node, self.filename) - args.append({"kind": "list", "elems": elems, "type": varparam["type"], "span": span}) + if varparam is not None: + elem = varparam["type"]["elem"] + elems = [] + for arg_node in extra: + value = self._expr(arg_node, elem) + if not types_equal(value["type"], elem): + self.fail(node, "PS1003", f"argument {varparam['name']} of {what} has the wrong type") + elems.append(value) + args.append({"kind": "list", "elems": elems, "type": varparam["type"], "span": span}) + elif extra: + self.fail(node, "PS1003", f"{what} expects {len(fixed)} argument(s)") + if kwparam is not None: + pairs = [] + for kw in leftover: + value = self._expr(kw.value, kwparam["type"]["value"]) + if not types_equal(value["type"], kwparam["type"]["value"]): + self.fail(node, "PS1003", f"argument {kw.arg} of {what} has the wrong type") + key = {"kind": "const", "value": kw.arg, "type": {"kind": "str"}, "span": span} + pairs.append({"key": key, "value": value}) + args.append({"kind": "dict", "pairs": pairs, "type": kwparam["type"], "span": span}) return args def _set_call(self, node: ast.Call, span: dict[str, Any], expect: dict[str, Any] | None) -> dict[str, Any]: diff --git a/src/pyscriptc/lower/_classes.py b/src/pyscriptc/lower/_classes.py index ce782774..b97d7bff 100644 --- a/src/pyscriptc/lower/_classes.py +++ b/src/pyscriptc/lower/_classes.py @@ -658,12 +658,12 @@ def _generated_method(self, node: ast.ClassDef, info: dict[str, Any]) -> dict[st def _signature( self, fn: ast.FunctionDef, class_name: str | None, kind: str = "method" ) -> tuple[list[dict[str, Any]], dict[str, Any]]: - if fn.args.kwarg or fn.args.kwonlyargs or fn.args.posonlyargs: + if fn.args.kwonlyargs or fn.args.posonlyargs: self.fail(fn, "PS1202", f"{fn.name} uses a parameter form that is not lowered") args = list(fn.args.args) params: list[dict[str, Any]] = [] if kind == "property": - if fn.args.vararg is not None or len(args) != 1 or args[0].arg != "self": + if fn.args.vararg is not None or fn.args.kwarg is not None or len(args) != 1 or args[0].arg != "self": self.fail(fn, "PS2002", "a property's only parameter is self") if args[0].annotation is not None: self.fail(args[0], "PS2002", "self is not annotated") @@ -718,6 +718,20 @@ def _signature( if elem["kind"] == "bool": self.fail(vararg, "PS2002", "list[bool] is not lowered") params.append({"name": vararg.arg, "type": {"kind": "list", "elem": elem}, "vararg": True}) + if fn.args.kwarg is not None: + kwarg = fn.args.kwarg + if kwarg.annotation is None: + self.fail(fn, "PS1202", f"{fn.name} uses a parameter form that is not lowered") + value = self._annotation(kwarg.annotation) + if value["kind"] == "bool": + self.fail(kwarg, "PS2002", "dict values of bool are not lowered") + params.append( + { + "name": kwarg.arg, + "type": {"kind": "dict", "key": {"kind": "str"}, "value": value}, + "kwarg": True, + } + ) if fn.returns is None: self.fail(fn, "PS1001", f"{fn.name} needs a return annotation") ret = self._annotation(fn.returns) diff --git a/tests/test_t2_kwargs.py b/tests/test_t2_kwargs.py new file mode 100644 index 00000000..e8fc7ed0 --- /dev/null +++ b/tests/test_t2_kwargs.py @@ -0,0 +1,215 @@ +"""T2-4: `**kwargs` normalization. + +A `**kwargs: T` parameter lowers as a trailing `dict[str, T]` param on the +signature (mirroring `*args: T` -> `list[T]`); call sites bind leftover named +keywords into a dict literal in declaration order, so `kwargs` behaves as a +plain dict in the body (`in`, `[]`, `.get`, iteration over keys). + +The dynamic `f(**d)` call-site unpack stays fenced (PS2002): a `dict[str, T]` +carries no static key set, so the lowering cannot prove which declared params +its members bind to nor whether the remainder fits the kwargs dict. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from tests.test_gates import compile_run, oracle, write_case + +from pyscriptc.lower import lower_source + + +def _entry(mod: dict) -> dict | None: + return next((fn for fn in mod["functions"] if fn["name"] == mod["entry"]), None) + + +def test_kwargs_param_appends_a_dict_param() -> None: + mod = lower_source( + """ +def probe(a: int, **kwargs: int) -> int: + return a +""" + ) + assert mod["fences"] == [] + entry = _entry(mod) + assert entry is not None + params = entry["params"] + assert params[-1] == { + "name": "kwargs", + "type": {"kind": "dict", "key": {"kind": "str"}, "value": {"kind": "int"}}, + "kwarg": True, + } + + +def test_call_site_binds_leftover_keywords_in_declaration_order() -> None: + mod = lower_source( + """ +def probe(a: int, **kwargs: int) -> int: + return a +def run() -> int: + return probe(1, x=2, y=3) +""" + ) + assert mod["fences"] == [] + run = next(fn for fn in mod["functions"] if fn["name"] == "run") + call = run["body"][-1]["value"] + packed = call["args"][-1] + assert packed["kind"] == "dict" + assert [pair["key"]["value"] for pair in packed["pairs"]] == ["x", "y"] + assert [pair["value"]["value"] for pair in packed["pairs"]] == ["2", "3"] + + +def test_keyword_matching_a_declared_param_does_not_pack() -> None: + mod = lower_source( + """ +def probe(a: int, b: int, **kwargs: int) -> int: + return a + b +def run() -> int: + return probe(b=2, a=1, c=3) +""" + ) + assert mod["fences"] == [] + run = next(fn for fn in mod["functions"] if fn["name"] == "run") + call = run["body"][-1]["value"] + assert [arg["value"] for arg in call["args"][:2]] == ["1", "2"] + packed = call["args"][-1] + assert [pair["key"]["value"] for pair in packed["pairs"]] == ["c"] + + +def test_empty_kwargs_packs_an_empty_dict() -> None: + mod = lower_source( + """ +def probe(a: int, **kwargs: int) -> int: + return a +def run() -> int: + return probe(1) +""" + ) + assert mod["fences"] == [] + run = next(fn for fn in mod["functions"] if fn["name"] == "run") + call = run["body"][-1]["value"] + assert call["args"][-1] == { + "kind": "dict", + "pairs": [], + "type": {"kind": "dict", "key": {"kind": "str"}, "value": {"kind": "int"}}, + "span": call["args"][-1]["span"], + } + + +def test_kwargs_and_varargs_pack_together() -> None: + mod = lower_source( + """ +def probe(a: int, *xs: int, **kwargs: int) -> int: + return a +def run() -> int: + return probe(1, 2, 3, x=4) +""" + ) + assert mod["fences"] == [] + run = next(fn for fn in mod["functions"] if fn["name"] == "run") + call = run["body"][-1]["value"] + assert call["args"][1]["kind"] == "list" + assert call["args"][2]["kind"] == "dict" + + +def test_starstar_unpack_stays_fenced() -> None: + mod = lower_source( + """ +def probe(**kwargs: int) -> int: + return len(kwargs) +def run() -> int: + d: dict[str, int] = {"x": 1} + return probe(**d) +""" + ) + fences = [fence for fn in mod["functions"] for fence in fn.get("fences", [])] + assert mod["fences"] or fences + codes = {fence["code"] for fence in mod["fences"] + fences} + assert "PS2002" in codes + + +def test_unannotated_kwargs_keeps_the_parameter_form_fence() -> None: + mod = lower_source( + """ +def probe(**kwargs) -> int: + return 0 +""" + ) + assert any(fence["code"] == "PS1202" for fence in mod["fences"]) + + +def test_kwargs_bool_values_fence() -> None: + mod = lower_source( + """ +def probe(**kwargs: bool) -> int: + return 0 +""" + ) + assert any(fence["code"] == "PS2002" for fence in mod["fences"]) + + +def test_extra_positional_without_vararg_fences() -> None: + mod = lower_source( + """ +def probe(a: int, **kwargs: int) -> int: + return a +def run() -> int: + return probe(1, 2) +""" + ) + run = next((fn for fn in mod["functions"] if fn["name"] == "run"), None) + fences = (run or {}).get("fences", []) + mod["fences"] + assert any(fence["code"] == "PS1003" for fence in fences) + + +@pytest.mark.linux_container # HEAVY=1 .agents/docker/run.sh +def test_kwargs_capture_iteration_membership_match_cpython(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "kwargs.py", + "\n".join( + [ + "def total(a: int, **kwargs: int) -> int:", + " acc: int = a", + " for key in kwargs:", + " acc = acc + kwargs[key]", + ' if "x" in kwargs:', + " acc = acc + 100", + ' if "nope" in kwargs:', + " return 0", + ' return acc + kwargs.get("missing", 7)', + "def run() -> int:", + " return total(1, x=2, y=3) + total(5)", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run") + + +@pytest.mark.linux_container # HEAVY=1 .agents/docker/run.sh +def test_kwargs_with_varargs_matches_cpython(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "kwvar.py", + "\n".join( + [ + "def gather(a: int, *xs: int, **kwargs: int) -> int:", + " acc: int = a", + " for v in xs:", + " acc = acc + v", + " for key in kwargs:", + " acc = acc + kwargs[key]", + " return acc", + "def run() -> int:", + " return gather(1, 2, 3, x=4, y=5) + gather(9)", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run")