diff --git a/docs/refused-features.md b/docs/refused-features.md index f801467..26c3a3e 100644 --- a/docs/refused-features.md +++ b/docs/refused-features.md @@ -133,6 +133,7 @@ Deliberate divergences that are already lowered and documented; kept because the - `int` overflow and `//` by zero `abort()` instead of raising (`benchmarks/compat2` R10 row). Caveat: a known trap-vs-raise gap, tracked for E2. - `assert` always evaluates — there is no `-O` flag on a compiled binary. Caveat: never put side effects in the condition. - `tuple_N_*` synthetic classes: `repr` and `type()` name differ from CPython (`docs/supported.md:135`). Cosmetic only. +- `ABC`/`ABCMeta`/`Protocol` bases, `metaclass=ABCMeta`, `@abstractmethod`, and `@overload` are tier-2 erasures (`.agents/plans/2026-10-18-tiered-lowering-and-typefacts.md`): they lower as plain classes/methods with no CPython runtime semantics. Caveats: `ABC()` instantiates (CPython raises `TypeError` when abstract methods remain); a declaration-only body (`...`/`pass`) on a Protocol member or `@abstractmethod` is skipped, not emitted; `f(x: SomeProtocol)` accepts any subclass of the erased class — object-ish, not structural — and unrelated classes, scalars, `list`/`dict` still fail the annotation; `isinstance`/`issubclass` on a Protocol and `@runtime_checkable` stay fenced (structural checks are the checker's job); a lone `@overload` with no implementation erases to nothing, so calls to the name fence as unbound. ## Does refusing change the corpus scorer? diff --git a/src/pyscriptc/lower/__init__.py b/src/pyscriptc/lower/__init__.py index 4c0a11e..df817c9 100644 --- a/src/pyscriptc/lower/__init__.py +++ b/src/pyscriptc/lower/__init__.py @@ -776,7 +776,7 @@ def _bool_cond(self, node: ast.expr) -> dict[str, Any]: raise AssertionError("unreachable") def _expect(self, node: ast.AST, got: dict[str, Any], want: dict[str, Any], what: str) -> None: - if not types_equal(got, want): + if not types_equal(got, want) and not self._accepts_protocol(want, got): self.fail(node, "PS1003", f"{what} is {_show(got)}, annotation says {_show(want)}") def _use_status(self, module: str) -> str: diff --git a/src/pyscriptc/lower/_calls.py b/src/pyscriptc/lower/_calls.py index ecf6c70..df8187b 100644 --- a/src/pyscriptc/lower/_calls.py +++ b/src/pyscriptc/lower/_calls.py @@ -1287,7 +1287,7 @@ def _fill_args(self, node: ast.Call, params: list[dict[str, Any]], what: str) -> param["type"], got.get("span", span_of(node, self.filename)), ) - if not types_equal(got["type"], param["type"]): + if not types_equal(got["type"], param["type"]) and not self._accepts_protocol(param["type"], got["type"]): self.fail(node, "PS1003", f"argument {param['name']} of {what} has the wrong type") args.append(got) return args diff --git a/src/pyscriptc/lower/_classes.py b/src/pyscriptc/lower/_classes.py index 0c7f58f..b67deeb 100644 --- a/src/pyscriptc/lower/_classes.py +++ b/src/pyscriptc/lower/_classes.py @@ -35,6 +35,12 @@ _UNWRAP_FORMS = frozenset({"Final", "ClassVar", "Annotated", "Required", "NotRequired"}) _DYNAMIC_ANNOTATIONS = frozenset({"object", "Any", "Protocol", "TypeVar", "Generic", "Callable", "ABC"}) +# Tier-2 erasure (`.agents/plans/2026-10-18-tiered-lowering-and-typefacts.md`): +# static-typing constructs that vanish before emission. `abc` is an erased +# module, so these names arrive through `type_aliases` or an erased +# `_resolve_module` triple. +_ERASED_BASES = frozenset({"ABC", "ABCMeta", "Protocol"}) +_ERASED_DECORATORS = frozenset({"abstractmethod", "overload"}) _IO_HANDLES = {"BinaryIO": "BytesIO", "TextIO": "TextIO"} @@ -100,15 +106,18 @@ def _install_synthetics(self, class_nodes: list[ast.ClassDef], fences: list[Fenc } def _collect_class(self, node: ast.ClassDef) -> None: - if node.keywords: + keywords = [kw for kw in node.keywords if not self._is_abcmeta_keyword(kw)] + if keywords: self.fail(node, "PS1203", "class keywords are not lowered") if node.name in self.bindings or node.name in self.sigs or node.name in self.declared: self.fail(node, "PS2002", f"{node.name} is already bound") deco = self._class_decorator(node) # class C(object) and class C(object,) are the same class as class C. - # object.__init__ is not emitted. + # object.__init__ is not emitted. `ABC`/`ABCMeta`/`Protocol` bases + # erase the same way: the class lowers as a plain class. bases: list[dict[str, str]] = [] type_params: list[str] = [] + protocol = False for base_node in node.bases: if self._is_builtin_object(base_node): continue @@ -119,6 +128,10 @@ def _collect_class(self, node: ast.ClassDef) -> None: # the class's annotations for call-site instantiation. type_params = generic_params continue + erased = self._erased_base_name(base_node) + if erased is not None: + protocol = protocol or erased == "Protocol" + continue resolved = self._resolve_base(base_node) if resolved["kind"] != "class" and len(node.bases) > 1: self.fail(node, "PS2002", "multiple inheritance needs lowered class bases") @@ -184,6 +197,17 @@ def _collect_class(self, node: ast.ClassDef) -> None: fields.append(field) continue if isinstance(stmt, ast.FunctionDef): + decos = {self._erased_deco_name(d) for d in stmt.decorator_list} + decos.discard(None) + if "overload" in decos: + # typing.overload declares the shape; only the final + # undecorated implementation lowers. + continue + if (protocol or "abstractmethod" in decos) and self._declaration_only(stmt): + # `def f(self) -> T: ...` on a Protocol or an + # abstractmethod declares the method; there is no + # body to emit and no runtime check to honor. + continue kind = self._method_decorator(stmt) if stmt.name == "__init__" and kind != "method": self.fail(stmt, "PS1201", "decorators are not lowered") @@ -301,6 +325,8 @@ def _collect_class(self, node: ast.ClassDef) -> None: self.exc_n += 1 if type_params: info["typevars"] = type_params + if protocol: + info["protocol"] = True self.classes[node.name] = info def _mro_chain(self, node: ast.ClassDef, bases: list[dict[str, str]]) -> tuple[list[str], set[str]]: @@ -412,11 +438,14 @@ def _is_dataclass_deco(self, dec: ast.expr) -> bool: return False def _method_decorator(self, fn: ast.FunctionDef) -> str: - if not fn.decorator_list: + # Erased typing decorators (`@abstractmethod`) drop out first, so + # `@property @abstractmethod` still resolves to a property. + decos = [d for d in fn.decorator_list if self._erased_deco_name(d) is None] + if not decos: return "method" - if len(fn.decorator_list) != 1: + if len(decos) != 1: self.fail(fn, "PS1201", "decorators are not lowered") - dec = fn.decorator_list[0] + dec = decos[0] target = dec if isinstance(dec, ast.Call): if dec.args or dec.keywords: @@ -436,6 +465,64 @@ def _is_builtin_object(self, node: ast.expr) -> bool: return False return True + def _erased_base_name(self, node: ast.expr) -> str | None: + """`ABC`/`ABCMeta`/`Protocol` base through an erased import, or None.""" + member: str | None = None + if isinstance(node, ast.Name): + member = self.type_aliases.get(node.id) + elif isinstance(node, ast.Attribute): + resolved = self._resolve_module(node) + if resolved is not None and resolved[3] == "erased": + member = resolved[2] + return member if member in _ERASED_BASES else None + + def _is_abcmeta_keyword(self, kw: ast.keyword) -> bool: + """`metaclass=ABCMeta` — erased like the `ABC` base it is sugar for.""" + if kw.arg != "metaclass": + return False + value = kw.value + if isinstance(value, ast.Name): + return self.type_aliases.get(value.id) == "ABCMeta" + if isinstance(value, ast.Attribute): + resolved = self._resolve_module(value) + return resolved is not None and resolved[3] == "erased" and resolved[2] == "ABCMeta" + return False + + def _erased_deco_name(self, dec: ast.expr) -> str | None: + """`abstractmethod`/`overload` through an erased import, or None.""" + member: str | None = None + if isinstance(dec, ast.Name): + member = self.type_aliases.get(dec.id) + elif isinstance(dec, ast.Attribute): + resolved = self._resolve_module(dec) + if resolved is not None and resolved[3] == "erased": + member = resolved[2] + return member if member in _ERASED_DECORATORS else None + + def _declaration_only(self, fn: ast.FunctionDef) -> bool: + """The body is only a docstring, `pass`, or `...` — a declaration, not an implementation.""" + for stmt in fn.body: + if _is_docstring(stmt) or isinstance(stmt, ast.Pass): + continue + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and stmt.value.value is Ellipsis: + continue + return False + return True + + def _accepts_protocol(self, want: dict[str, Any], got: dict[str, Any]) -> bool: + """A Protocol-annotated slot accepts any subclass of the erased class: + the marker carries no fields, so every record below it already + satisfies its layout.""" + if want["kind"] != "class" or got["kind"] != "class": + return False + want_name = want.get("name") + got_name = got.get("name") + if not isinstance(want_name, str) or not isinstance(got_name, str): + return False + if not self.classes.get(want_name, {}).get("protocol"): + return False + return self._is_subclass_name(got_name, want_name) + def _resolve_base(self, node: ast.expr) -> dict[str, str]: enum = self._enum_base_name(node) if enum is not None: @@ -1232,7 +1319,10 @@ def _isinstance_type(self, node: ast.expr) -> dict[str, Any]: if isinstance(node, ast.Name) and ( node.id in self.classes or node.id in self.class_aliases or node.id in EXCEPTIONS ): - return {"kind": "class", "name": self.class_aliases.get(node.id, node.id)} + name = self.class_aliases.get(node.id, node.id) + if self.classes.get(name, {}).get("protocol"): + self.fail(node, "PS2001", "isinstance on a Protocol is not lowered") + return {"kind": "class", "name": name} if isinstance(node, ast.Name): binding = self.bindings.get(node.id) if binding is not None: diff --git a/src/pyscriptc/lower/_stdlib.py b/src/pyscriptc/lower/_stdlib.py index 33f325b..5a995e4 100644 --- a/src/pyscriptc/lower/_stdlib.py +++ b/src/pyscriptc/lower/_stdlib.py @@ -283,7 +283,12 @@ def _accept_module_function(self, fn: ast.FunctionDef, fences: list[Fence]) -> b """Module `@staticmethod` is the function the class decorator already emits. The call passes no instance. Other module decorators stay fenced. + typing's `@overload` stubs erase (only the final undecorated + implementation lowers); `@abstractmethod` erases off the list. """ + if any(self._erased_deco_name(d) == "overload" for d in fn.decorator_list): + return False + fn.decorator_list = [d for d in fn.decorator_list if self._erased_deco_name(d) is None] message = self._module_decorator_fence(fn) if message is None: if fn.decorator_list and not self._is_module_staticmethod(fn): diff --git a/src/pyscriptc/lower/_stdlib_shared.py b/src/pyscriptc/lower/_stdlib_shared.py index 59888cd..df52660 100644 --- a/src/pyscriptc/lower/_stdlib_shared.py +++ b/src/pyscriptc/lower/_stdlib_shared.py @@ -10,7 +10,7 @@ import ast from typing import Any -_ERASED_MODULES = frozenset({"typing", "typing_extensions"}) +_ERASED_MODULES = frozenset({"typing", "typing_extensions", "abc"}) def _release_erased_import(stmt: ast.Import | ast.ImportFrom, rebound: set[str]) -> None: diff --git a/tests/test_census_self.py b/tests/test_census_self.py index 017e088..776203a 100644 --- a/tests/test_census_self.py +++ b/tests/test_census_self.py @@ -40,7 +40,9 @@ def test_self_census_ballpark() -> None: # _classes fence for the kwarg-dict binding path). # T2-5 static folds: restlower/_folds.py adds +15 fences (fold-boundary # docstrings and helpers, self-host only). - assert census["fences"] == 1450 + # T2-2 ABC/Protocol erasure: +5 fences in _classes.py (the erased-base + # and decorator spellings are self-host fenced branches). + assert census["fences"] == 1455 # 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"]) == 1450 + assert sum(p["fences"] for p in census["packages"]) == 1455 diff --git a/tests/test_pyjwt_compat.py b/tests/test_pyjwt_compat.py index ad2f3a5..b5c28fd 100644 --- a/tests/test_pyjwt_compat.py +++ b/tests/test_pyjwt_compat.py @@ -37,11 +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 - # 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 + # 105 fences after T2-2 (`abc` import, `ABC` base, and `@abstractmethod` + # erase, -2 from the 107 left by T2-3 kw-only normalization). Shrinking + # means a backlog lowering cleared fences here; update the pin when that + # happens. + assert proc.stderr.count("PS") == 105 @pytest.mark.linux_container # HEAVY=1 .agents/docker/run.sh diff --git a/tests/test_t2_abc_protocol.py b/tests/test_t2_abc_protocol.py new file mode 100644 index 0000000..d8f77d4 --- /dev/null +++ b/tests/test_t2_abc_protocol.py @@ -0,0 +1,274 @@ +"""T2-2 ABC/Protocol erasure (tier-2 typed compile-time lowering). + +`class C(ABC)` / `metaclass=ABCMeta`, `@abstractmethod`, `class P(Protocol)` +and `@overload` erase before LLVM emission: the class lowers as a plain +class, the decorator drops off the list, an overload-declared signature +disappears so only the final implementation lowers. Static constructs only — +there is no runtime enforcement: `ABC()` instantiates, declaration-only +(`...`/`pass`) bodies are skipped rather than emitted, and a protocol-typed +parameter accepts any class value (the erased class is an empty marker). +`@runtime_checkable` and `isinstance` on a Protocol stay fenced. Each probe +diffs stdout against the CPython oracle; fence cases assert the diagnostic. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from tests.test_gates import compile_run, oracle, run_cli, write_case + + +@pytest.mark.linux_container +def test_t2_abc_base_and_abstractmethod(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "abc_base.py", + "\n".join( + [ + "from abc import ABC, abstractmethod", + "", + "class Shape(ABC):", + " @abstractmethod", + " def area(self) -> int: ...", + "", + " def describe(self) -> str:", + " return 'shape'", + "", + "class Square(Shape):", + " side: int", + "", + " def __init__(self, side: int) -> None:", + " self.side = side", + "", + " def area(self) -> int:", + " return self.side * self.side", + "", + "def run() -> int:", + " s: Square = Square(3)", + " print(s.area())", + " print(s.describe())", + " return 0", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run") + + +@pytest.mark.linux_container +def test_t2_abcmeta_keyword_and_abstract_property(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "abc_meta.py", + "\n".join( + [ + "from abc import ABCMeta, abstractmethod", + "", + "class Base(metaclass=ABCMeta):", + " v: int", + "", + " def __init__(self, v: int) -> None:", + " self.v = v", + "", + " @property", + " @abstractmethod", + " def doubled(self) -> int: ...", + "", + "class Impl(Base):", + " @property", + " def doubled(self) -> int:", + " return self.v * 2", + "", + "def run() -> int:", + " i: Impl = Impl(4)", + " print(i.doubled)", + " return 0", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run") + + +@pytest.mark.linux_container +def test_t2_protocol_annotation_is_permissive(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "proto.py", + "\n".join( + [ + "from typing import Protocol", + "", + "class Named(Protocol):", + " def name(self) -> str: ...", + "", + "def greet(n: Named) -> str:", + " return 'hi'", + "", + "class User(Named):", + " n: str", + "", + " def __init__(self, n: str) -> None:", + " self.n = n", + "", + " def name(self) -> str:", + " return self.n", + "", + "class Alias(Named):", + " tag: str", + "", + " def __init__(self, tag: str) -> None:", + " self.tag = tag", + "", + " def name(self) -> str:", + " return self.tag", + "", + "def run() -> int:", + " u: User = User('q')", + " a: Alias = Alias('z')", + " print(greet(u))", + " print(greet(a))", + " print(u.name())", + " return 0", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run") + + +@pytest.mark.linux_container +def test_t2_overload_stubs_erase(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "ovl.py", + "\n".join( + [ + "from typing import overload", + "", + "@overload", + "def pick(x: int) -> int: ...", + "", + "@overload", + "def pick(x: int, y: int) -> int: ...", + "", + "def pick(x: int, y: int = 0) -> int:", + " return x + y + 1", + "", + "def run() -> int:", + " print(pick(1))", + " print(pick(1, 2))", + " return 0", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run") + + +@pytest.mark.linux_container +def test_t2_dotted_spellings(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "dotted.py", + "\n".join( + [ + "import abc", + "import typing", + "", + "class Base(abc.ABC):", + " @abc.abstractmethod", + " def go(self) -> int: ...", + "", + "class Named(typing.Protocol):", + " def name(self) -> str: ...", + "", + "class Impl(Base, Named):", + " def go(self) -> int:", + " return 5", + "", + " def name(self) -> str:", + " return 'i'", + "", + "def pick(n: Named) -> int:", + " return 1", + "", + "def run() -> int:", + " print(Impl().go())", + " print(pick(Impl()))", + " return 0", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "run") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "run") + + +def test_t2_protocol_fences_stay(tmp_path: Path) -> None: + cases = [ + ( + "isinstance_proto.py", + "\n".join( + [ + "from typing import Protocol", + "", + "class P(Protocol):", + " def go(self) -> int: ...", + "", + "class Box:", + " v: int", + "", + " def __init__(self, v: int) -> None:", + " self.v = v", + "", + "def run() -> int:", + " b: Box = Box(1)", + " print(isinstance(b, P))", + " return 0", + "", + ] + ), + "isinstance on a Protocol is not lowered", + ), + ( + "runtime_checkable.py", + "\n".join( + [ + "from typing import Protocol, runtime_checkable", + "", + "@runtime_checkable", + "class Sized(Protocol):", + " def size(self) -> int: ...", + "", + "def run() -> int:", + " return 0", + "", + ] + ), + "decorators are not lowered", + ), + ] + for name, source, text in cases: + 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, f"{name}\n{got.stderr}" + assert text in got.stderr, f"{name}\n{got.stderr}"