Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions src/pyscriptc/lower/_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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) :])
Expand All @@ -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")
Expand All @@ -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]:
Expand Down
18 changes: 16 additions & 2 deletions src/pyscriptc/lower/_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
215 changes: 215 additions & 0 deletions tests/test_t2_kwargs.py
Original file line number Diff line number Diff line change
@@ -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")
Loading