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
1 change: 1 addition & 0 deletions docs/refused-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
2 changes: 1 addition & 1 deletion src/pyscriptc/lower/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/pyscriptc/lower/_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 96 additions & 6 deletions src/pyscriptc/lower/_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}


Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/pyscriptc/lower/_stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/pyscriptc/lower/_stdlib_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions tests/test_census_self.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions tests/test_pyjwt_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading