diff --git a/src/pyscriptc/lower/_calls.py b/src/pyscriptc/lower/_calls.py index d714d15d..15a87771 100644 --- a/src/pyscriptc/lower/_calls.py +++ b/src/pyscriptc/lower/_calls.py @@ -1242,22 +1242,34 @@ def _fill_args(self, node: ast.Call, params: list[dict[str, Any]], what: str) -> ) if var_index is not None: return self._fill_varargs(node, params, var_index, what) - if len(node.args) > len(params): - self.fail(node, "PS1003", f"{what} expects {len(params)} argument(s)") + kwonly_start = next((index for index, param in enumerate(params) if param.get("kwonly")), len(params)) + positional = params[:kwonly_start] + if len(node.args) > len(positional): + self.fail(node, "PS1003", f"{what} expects {len(positional)} argument(s)") slots: list[dict[str, Any] | None] = [None] * len(params) names = [param["name"] for param in params] + pos_names = [param["name"] for param in positional] for index, arg_node in enumerate(node.args): slots[index] = self._expr(arg_node, params[index]["type"]) seen: set[str] = set() for kw in node.keywords: assert kw.arg is not None - if kw.arg not in names: + if kw.arg in pos_names: + index = names.index(kw.arg) + if params[index].get("posonly"): + self.fail(kw, "PS2003", f"{what} got {kw.arg} as a keyword for a positional-only parameter") + if index < len(node.args) or kw.arg in seen: + self.fail(kw, "PS2003", f"{kw.arg} given more than once") + seen.add(kw.arg) + slots[index] = self._expr(kw.value, params[index]["type"]) + elif kw.arg in names: + index = names.index(kw.arg) + if kw.arg in seen: + self.fail(kw, "PS2003", f"{kw.arg} given more than once") + seen.add(kw.arg) + slots[index] = self._expr(kw.value, params[index]["type"]) + else: self.fail(kw, "PS2003", f"unexpected keyword argument {kw.arg}") - index = names.index(kw.arg) - if index < len(node.args) or kw.arg in seen: - self.fail(kw, "PS2003", f"{kw.arg} given more than once") - seen.add(kw.arg) - slots[index] = self._expr(kw.value, params[index]["type"]) args: list[dict[str, Any]] = [] for index, param in enumerate(params): got = slots[index] @@ -1282,34 +1294,49 @@ def _fill_varargs( fixed = params[:var_index] varparam = params[var_index] if params[var_index].get("vararg") else None kwparam = params[-1] if params[-1].get("kwarg") else None + tail = params[var_index + 1 :] + # Keyword-only parameters can sit before the `**kwargs` capture, so + # split them out of the fixed prefix rather than assuming a layout. + positional_params = [param for param in fixed if not param.get("kwonly")] + kwonly = [param for param in fixed if param.get("kwonly")] + [param for param in tail if param.get("kwonly")] 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) :]) - slots: list[dict[str, Any] | None] = [None] * len(fixed) - names = [param["name"] for param in fixed] + positional = list(node.args[: len(positional_params)]) + extra = list(node.args[len(positional_params) :]) + slots: list[dict[str, Any] | None] = [None] * len(positional_params) + kw_slots: list[dict[str, Any] | None] = [None] * len(kwonly) + names = [param["name"] for param in positional_params] + kw_names = [param["name"] for param in kwonly] for index, arg_node in enumerate(positional): - slots[index] = self._expr(arg_node, fixed[index]["type"]) + slots[index] = self._expr(arg_node, positional_params[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: - if kwparam is None: - self.fail(kw, "PS2003", f"unexpected keyword argument {kw.arg}") + if kw.arg in names: + index = names.index(kw.arg) + if positional_params[index].get("posonly"): + self.fail(kw, "PS2003", f"{what} got {kw.arg} as a keyword for a positional-only parameter") + if index < len(positional) or kw.arg in seen: + self.fail(kw, "PS2003", f"{kw.arg} given more than once") + seen.add(kw.arg) + slots[index] = self._expr(kw.value, positional_params[index]["type"]) + elif kw.arg in kw_names: + index = kw_names.index(kw.arg) + if kw.arg in seen: + self.fail(kw, "PS2003", f"{kw.arg} given more than once") + seen.add(kw.arg) + kw_slots[index] = self._expr(kw.value, kwonly[index]["type"]) + elif kwparam is not None: 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") - seen.add(kw.arg) - slots[index] = self._expr(kw.value, fixed[index]["type"]) + else: + self.fail(kw, "PS2003", f"unexpected keyword argument {kw.arg}") args: list[dict[str, Any]] = [] - for index, param in enumerate(fixed): + for index, param in enumerate(positional_params): got = slots[index] if got is None: if "default" not in param: - self.fail(node, "PS1003", f"{what} expects {len(fixed)} argument(s)") + self.fail(node, "PS1003", f"{what} expects {len(positional_params)} argument(s)") got = param["default"] if not types_equal(got["type"], param["type"]): self.fail(node, "PS1003", f"argument {param['name']} of {what} has the wrong type") @@ -1325,7 +1352,16 @@ def _fill_varargs( 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)") + self.fail(node, "PS1003", f"{what} expects {len(positional_params)} argument(s)") + for index, param in enumerate(kwonly): + got = kw_slots[index] + if got is None: + if "default" not in param: + self.fail(node, "PS1003", f"{what} expects keyword argument {param['name']}") + got = param["default"] + 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) if kwparam is not None: pairs = [] for kw in leftover: diff --git a/src/pyscriptc/lower/_classes.py b/src/pyscriptc/lower/_classes.py index 4370e493..0c7f58f5 100644 --- a/src/pyscriptc/lower/_classes.py +++ b/src/pyscriptc/lower/_classes.py @@ -658,9 +658,13 @@ 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.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) + # `**kwargs` capture stays lowered (packed into dict[str, T] by + # `_fill_varargs`); `/`-positional and `*`-keyword parameters normalize + # into the named-parameter machinery instead: positional-only + # parameters refuse keywords, keyword-only ones must be passed by + # name. See `_fill_args`/`_fill_varargs` for the binding rules. + pos_marks = [True] * len(fn.args.posonlyargs) + [False] * len(fn.args.args) + args = list(fn.args.posonlyargs) + list(fn.args.args) params: list[dict[str, Any]] = [] if kind == "property": if fn.args.vararg is not None or fn.args.kwarg is not None or len(args) != 1 or args[0].arg != "self": @@ -671,6 +675,7 @@ def _signature( self.fail(fn, "PS1201", "decorators are not lowered") params.append({"name": "self", "type": {"kind": "class", "name": class_name}}) args = [] + pos_marks = [] elif kind == "class": if not args or args[0].arg != "cls": self.fail(fn, "PS2002", "a classmethod's first parameter must be cls") @@ -678,6 +683,7 @@ def _signature( self.fail(args[0], "PS2002", "cls is not annotated") # cls is not a value. Class.method(...) does not pass the class object. args = args[1:] + pos_marks = pos_marks[1:] elif kind == "static": pass elif class_name is not None: @@ -687,28 +693,15 @@ def _signature( self.fail(args[0], "PS2002", "self is not annotated") params.append({"name": "self", "type": {"kind": "class", "name": class_name}}) args = args[1:] + pos_marks = pos_marks[1:] defaults = list(fn.args.defaults) missing = len(args) - len(defaults) for index, arg in enumerate(args): param = _param(self, arg) + if pos_marks[index]: + param["posonly"] = True if index >= missing: - default_node = defaults[index - missing] - if param["type"].get("optional"): - value = self._expr(default_node) - if value["kind"] != "const": - self.fail(default_node, "PS2002", "a default must be a constant") - if value["type"]["kind"] != "none" and not types_equal(value["type"], param["type"]["elem"]): - self.fail( - default_node, "PS1003", f"{arg.arg} is {_show(value['type'])}, not {_show(param['type'])}" - ) - param["default"] = value - param["optdefault"] = True - else: - value = self._expr(default_node, param["type"]) - self._expect(default_node, value["type"], param["type"], arg.arg) - if value["kind"] != "const": - self.fail(default_node, "PS2002", "a default must be a constant") - param["default"] = value + self._param_default(arg, defaults[index - missing], param) params.append(param) if fn.args.vararg is not None: vararg = fn.args.vararg @@ -718,6 +711,12 @@ 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}) + for karg, kdef in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True): + param = _param(self, karg) + param["kwonly"] = True + if kdef is not None: + self._param_default(karg, kdef, param) + params.append(param) if fn.args.kwarg is not None: kwarg = fn.args.kwarg if kwarg.annotation is None: @@ -741,6 +740,22 @@ def _signature( self.fail(fn, "PS1003", "__init__ returns None") return params, ret + def _param_default(self, arg: ast.arg, default_node: ast.expr, param: dict[str, Any]) -> None: + if param["type"].get("optional"): + value = self._expr(default_node) + if value["kind"] != "const": + self.fail(default_node, "PS2002", "a default must be a constant") + if value["type"]["kind"] != "none" and not types_equal(value["type"], param["type"]["elem"]): + self.fail(default_node, "PS1003", f"{arg.arg} is {_show(value['type'])}, not {_show(param['type'])}") + param["default"] = value + param["optdefault"] = True + else: + value = self._expr(default_node, param["type"]) + self._expect(default_node, value["type"], param["type"], arg.arg) + if value["kind"] != "const": + self.fail(default_node, "PS2002", "a default must be a constant") + param["default"] = value + def _construct(self, node: ast.Call, span: dict[str, Any], name: str) -> dict[str, Any]: info = self.classes[name] if info.get("namedtuple"): diff --git a/tests/test_census_self.py b/tests/test_census_self.py index 9acd16b5..84757402 100644 --- a/tests/test_census_self.py +++ b/tests/test_census_self.py @@ -34,7 +34,9 @@ def test_self_census_ballpark() -> None: # First-run fix: node_text/op_text diagnostics helpers and CLI --run entry handling (+7 fences). # Optional widening (Stage U): total stays 1434; 2 typefacts `X | Y | None` fences # move from union-types (11 to 9) into optional-types (0 to 2, PS1102). - assert census["fences"] == 1434 + # T2-3 parameter forms normalize into the named-parameter machinery (+1 + # _classes fence for the kwarg-dict binding path). + assert census["fences"] == 1435 # 22,164 lines after the `--facts` lane landed in typefacts.py. assert 11_000 <= census["lines"] <= 25_000 - assert sum(p["fences"] for p in census["packages"]) == 1434 + assert sum(p["fences"] for p in census["packages"]) == 1435 diff --git a/tests/test_pyjwt_compat.py b/tests/test_pyjwt_compat.py index 8cbd15aa..ad2f3a5e 100644 --- a/tests/test_pyjwt_compat.py +++ b/tests/test_pyjwt_compat.py @@ -37,10 +37,11 @@ def test_real_pyjwt_hmac_tree_still_fences() -> None: proc = run_cli("compile", str(FIXTURE / "jwt"), "--emit", "ir", "-o", os.devnull, "--skip-check") assert proc.returncode == 3 - # 103 fences after R26 (`Optional` / `x | None` annotations lowered). - # Shrinking means a backlog lowering cleared fences here; update the pin - # when that happens. - assert proc.stderr.count("PS") == 103 + # 107 fences after T2-3 (kw-only parameters normalize): the removed + # PS1202 signature fences let those defs lower further and report + # deeper, more accurate fences instead. Shrinking means a backlog + # lowering cleared fences here; update the pin when that happens. + assert proc.stderr.count("PS") == 107 @pytest.mark.linux_container # HEAVY=1 .agents/docker/run.sh diff --git a/tests/test_t2_param_forms.py b/tests/test_t2_param_forms.py new file mode 100644 index 00000000..b168ab4a --- /dev/null +++ b/tests/test_t2_param_forms.py @@ -0,0 +1,241 @@ +"""T2-3 signature-shape normalization: `/` positional-only and `*` +keyword-only parameters lower into the named-parameter machinery. + +Semantics follow CPython at bind time (in `_fill_args`/`_fill_varargs`): +positional-only parameters refuse keywords, keyword-only parameters refuse +positional slots and must be passed by name. `**kwargs` capture stays +fenced (T2-4). Each oracle test diffs compiled stdout against CPython. +""" + +from __future__ import annotations + +from pathlib import Path + +from tests.test_gates import oracle, run_cli, write_case + + +def _oracle_diff(tmp_path: Path, name: str, source: str, func: str = "probe") -> None: + path = write_case(tmp_path, name, source) + out = str(tmp_path / name.removesuffix(".py")) + got = run_cli("compile", str(path), "--run", "--emit", "bin", "--call", func, "-o", out) + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, func) + + +def _fence(tmp_path: Path, name: str, source: str, text: str) -> None: + path = write_case(tmp_path, name, source) + got = run_cli( + "compile", + str(path), + "--skip-check", + "--emit", + "llvm", + "-o", + str(tmp_path / f"{name}.ll"), + ) + assert got.returncode == 3, got.stderr + assert text in got.stderr, got.stderr + + +def test_posonly_positional_call(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_posonly.py", + "\n".join( + [ + "def f(a: int, /, b: int) -> int:", + " return a * 10 + b", + "", + "def probe() -> int:", + " return f(1, 2) + f(3, b=4)", + "", + ] + ), + ) + + +def test_posonly_with_defaults(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_posonly_defaults.py", + "\n".join( + [ + "def f(a: int, /, b: int = 5, c: int = 7) -> int:", + " return a * 100 + b * 10 + c", + "", + "def probe() -> int:", + " return f(1) + f(1, 2) + f(1, 2, 3) + f(1, c=9)", + "", + ] + ), + ) + + +def test_kwonly_required_and_default(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_kwonly.py", + "\n".join( + [ + "def f(a: int, *, k: int, k2: int = 1) -> int:", + " return a * 100 + k * 1000 + k2", + "", + "def probe() -> int:", + " return f(1, k=3) + f(1, k=3, k2=7) + f(2, k2=0, k=4)", + "", + ] + ), + ) + + +def test_mixed_posonly_kwonly(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_mixed.py", + "\n".join( + [ + "def f(a: int, /, b: int = 5, *, k: int, k2: int = 1) -> int:", + " return a * 100 + b * 10 + k * 1000 + k2", + "", + "def probe() -> int:", + " return f(1, k=3) + f(1, 2, k=3, k2=7) + f(1, b=4, k=2)", + "", + ] + ), + ) + + +def test_varargs_then_kwonly(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_varargs_kwonly.py", + "\n".join( + [ + "def h(a: int, *rest: int, k: int = 9) -> int:", + " total: int = a + k", + " for r in rest:", + " total += r", + " return total", + "", + "def probe() -> int:", + " return h(1, 2, 3) + h(1, 2, 3, k=10) + h(0, k=1)", + "", + ] + ), + ) + + +def test_method_self_posonly(tmp_path: Path) -> None: + _oracle_diff( + tmp_path, + "t2_method.py", + "\n".join( + [ + "class P:", + " v: int", + "", + " def __init__(self, v: int) -> None:", + " self.v = v", + "", + " def add(self, /, x: int, *, scale: int = 2) -> int:", + " return self.v + x * scale", + "", + "def probe() -> int:", + " p: P = P(10)", + " return p.add(3) + p.add(1, scale=4)", + "", + ] + ), + ) + + +def test_posonly_refuses_keyword(tmp_path: Path) -> None: + _fence( + tmp_path, + "t2_pos_kw.py", + "\n".join( + [ + "def f(a: int, /, b: int) -> int:", + " return a + b", + "", + "def probe() -> int:", + " return f(a=1, b=2)", + "", + ] + ), + "positional-only", + ) + + +def test_kwonly_refuses_positional(tmp_path: Path) -> None: + _fence( + tmp_path, + "t2_kw_pos.py", + "\n".join( + [ + "def f(a: int, *, k: int) -> int:", + " return a + k", + "", + "def probe() -> int:", + " return f(1, 2)", + "", + ] + ), + "expects 1 argument(s)", + ) + + +def test_kwonly_missing_required(tmp_path: Path) -> None: + _fence( + tmp_path, + "t2_kw_missing.py", + "\n".join( + [ + "def f(a: int, *, k: int) -> int:", + " return a + k", + "", + "def probe() -> int:", + " return f(1)", + "", + ] + ), + "expects", + ) + + +def test_kwargs_with_kwonly_and_posonly_lowers(tmp_path: Path) -> None: + # `**kwargs` capture lowered in T2-4 (#121), so a signature that mixes it + # with `/`-positional and `*`-keyword parameters must compile and match + # CPython instead of staying fenced. + _oracle_diff( + tmp_path, + "t2_kwargs_mixed.py", + "\n".join( + [ + "def f(a: int, /, b: int = 2, *, c: int = 3, **kw: int) -> int:", + " return a + b + c + len(kw)", + "", + "def probe() -> int:", + " return f(1, c=4, d=5)", + "", + ] + ), + ) + + +def test_unannotated_kwargs_still_fenced(tmp_path: Path) -> None: + _fence( + tmp_path, + "t2_kwargs.py", + "\n".join( + [ + "def f(a: int, **kw) -> int:", + " return a", + "", + "def probe() -> int:", + " return f(1)", + "", + ] + ), + "parameter form that is not lowered", + )