diff --git a/baml_language/sdk_tests/crates/python_pydantic2/type_shapes/customizable/roundtrip_tests/test_interfaces.py b/baml_language/sdk_tests/crates/python_pydantic2/type_shapes/customizable/roundtrip_tests/test_interfaces.py new file mode 100644 index 0000000000..138340184e --- /dev/null +++ b/baml_language/sdk_tests/crates/python_pydantic2/type_shapes/customizable/roundtrip_tests/test_interfaces.py @@ -0,0 +1,156 @@ +"""Roundtrip coverage for `baml_sdk.interfaces` — interface types (BEP-044). + +Interfaces are BAML-side contracts, not serializable host SDK models: +codegen surfaces interface-typed boundary positions as `typing.Any` +(`client_codegen.rs`: `TirTy::Interface(..) => cg::Ty::BuiltinUnknown`). +The *values* crossing the boundary are always concrete class instances. + +These tests pin: + + - return position: the host receives the concrete implementing class + (for both the in-body `implements` form — `Square`/`Rect` — and the + out-of-body `implements Shape for Circle` form) + - parameter position: the host passes a concrete instance and the + engine dispatches interface methods on it + - round trips preserve the concrete class in bare, list, optional, + and class-field positions + - KNOWN GAPS (pinned so a fix flips these tests and forces an + intentional update): no encode-time conformance check for interface + params, and impl-method host bindings point at unregistered names + +The python bridge encodes host instances by their runtime class, so all +of the above works here. The nodejs SDK currently diverges — its bridge +encodes by the declared codegen type, losing class identity in interface +positions; see roundtrip_interfaces.test.ts for the pinned node behavior. +""" + +import pytest + +import baml_sdk # noqa: F401 — initializes the BAML runtime +from baml_bridge.errors import BamlPanic +from baml_sdk.interfaces import ( + Circle, + Rect, + ShapeBox, + Square, + box_area, + return_circle_as_shape, + return_rect_as_shape, + return_square_as_shape, + round_trip_optional_shape, + round_trip_shape, + round_trip_shape_box, + round_trip_shape_list, + shape_area, + shape_area_async, + sum_areas, +) + + +# ── return position: host receives the concrete implementing class ────── + + +def test_return_square_as_shape(): + s = return_square_as_shape() + assert isinstance(s, Square) + assert s.side == 5 + + +def test_return_rect_as_shape(): + s = return_rect_as_shape() + assert isinstance(s, Rect) + assert s == Rect(width=3, height=4) + + +def test_return_circle_as_shape(): + # Circle implements Shape via the out-of-body `implements ... for` form. + s = return_circle_as_shape() + assert isinstance(s, Circle) + assert s.radius == 2 + + +# ── parameter position: engine dispatches interface methods ───────────── + + +def test_shape_area_dispatches_on_square(): + assert shape_area(s=Square(side=5)) == 25 + + +def test_shape_area_dispatches_on_rect(): + assert shape_area(s=Rect(width=3, height=4)) == 12 + + +def test_shape_area_dispatches_on_out_of_body_impl(): + assert shape_area(s=Circle(radius=2)) == 12 + + +async def test_shape_area_async_dispatches(): + assert await shape_area_async(s=Square(side=6)) == 36 + + +def test_sum_areas_mixes_implementors(): + assert sum_areas(a=Square(side=2), b=Rect(width=3, height=4)) == 16 + + +# ── round trips preserve the concrete class ───────────────────────────── + + +def test_round_trip_shape_preserves_concrete_class(): + r = round_trip_shape(s=Rect(width=2, height=3)) + assert isinstance(r, Rect) + assert r == Rect(width=2, height=3) + + +def test_round_trip_shape_list_preserves_each_element(): + shapes = [Square(side=1), Rect(width=2, height=3), Circle(radius=4)] + assert round_trip_shape_list(shapes=shapes) == shapes + + +def test_round_trip_optional_shape_none(): + assert round_trip_optional_shape(s=None) is None + + +def test_round_trip_optional_shape_value(): + r = round_trip_optional_shape(s=Circle(radius=1)) + assert isinstance(r, Circle) + assert r.radius == 1 + + +def test_round_trip_shape_box_field_position(): + r = round_trip_shape_box(b=ShapeBox(shape=Square(side=2))) + assert isinstance(r, ShapeBox) + assert isinstance(r.shape, Square) + assert r.shape.side == 2 + + +def test_box_area_dispatches_through_field(): + assert box_area(b=ShapeBox(shape=Rect(width=2, height=5))) == 10 + + +# ── KNOWN GAPS — these pin *current* behavior, not desired behavior ───── + + +def test_non_implementor_panics_at_dispatch_not_encode(): + # There is no encode-time conformance check for interface-typed + # params (the codegen type is `typing.Any`): a value whose class + # does not implement Shape encodes fine and only fails inside the + # VM when the virtual call cannot resolve. A future encode- or + # call-time check would surface a TypeError instead — update this + # pin when that lands. NB: BamlPanic derives BaseException. + with pytest.raises(BamlPanic, match="could not resolve interface method"): + shape_area(s=ShapeBox(shape=Square(side=1))) + + +def test_primitive_into_interface_param_panics_at_dispatch(): + with pytest.raises(BamlPanic, match="could not resolve interface method"): + shape_area(s=42) + + +def test_impl_method_host_binding_is_not_callable(): + # sdkgen emits `area`/`area_async` bindings on implementing classes + # (named `user.interfaces.Square.area`), but the engine registers + # interface-impl methods under a synthetic `Shape$for$Square` name, + # so calling the binding from the host panics with "Function not + # found". Update this pin when the binding and registration agree. + with pytest.raises(BamlPanic, match="Function not found"): + Square(side=3).area() diff --git a/baml_language/sdk_tests/crates/typescript_node/type_shapes/customizable/roundtrip_interfaces.test.ts b/baml_language/sdk_tests/crates/typescript_node/type_shapes/customizable/roundtrip_interfaces.test.ts new file mode 100644 index 0000000000..72aa9ef69e --- /dev/null +++ b/baml_language/sdk_tests/crates/typescript_node/type_shapes/customizable/roundtrip_interfaces.test.ts @@ -0,0 +1,152 @@ +// Roundtrip coverage for baml_sdk/interfaces — interface types (BEP-044). +// Counterpart of python_pydantic2/.../roundtrip_tests/test_interfaces.py. +// +// Interfaces are BAML-side contracts, not serializable host SDK models: +// codegen surfaces interface-typed boundary positions as `unknown` +// (client_codegen.rs: `TirTy::Interface(..) => cg::Ty::BuiltinUnknown`). +// +// KNOWN GAP (nodejs bridge) — unlike the python SDK, where all of these +// scenarios work: outbound host→engine encoding here is driven by the +// declared codegen type, so a class instance passed in an interface +// position (`unknown`) loses its class identity and encodes as a plain +// object. The engine then cannot dispatch interface methods on it ("VM +// internal error: virtual call could not resolve interface method"), and +// round-tripped interface positions come back as plain objects rather +// than class instances. Interface *returns* are unaffected: the value +// leaves the engine carrying its concrete class, so the host receives a +// real class instance. The tests below pin this *current* behavior, not +// desired behavior — update them when the nodejs bridge encodes by the +// value's runtime class (as python does) or codegen grows a structural +// interface representation. +import "./baml_sdk/index.js"; // initializes the BAML runtime +import { describe, it, expect } from "vitest"; +import { + Circle, + Rect, + ShapeBox, + Square, + box_area, + return_circle_as_shape, + return_rect_as_shape, + return_square_as_shape, + round_trip_optional_shape, + round_trip_shape, + round_trip_shape_box, + round_trip_shape_list, + shape_area, + shape_area_async, + sum_areas, +} from "./baml_sdk/interfaces/index.js"; + +describe("roundtrip interfaces", () => { + // ── return position: host receives the concrete implementing class ── + + it("return_square_as_shape", () => { + const s = return_square_as_shape(); + expect(s).toBeInstanceOf(Square); + expect((s as Square).side).toBeCloseTo(5); + }); + + it("return_rect_as_shape", () => { + const s = return_rect_as_shape(); + expect(s).toBeInstanceOf(Rect); + expect((s as Rect).width).toBeCloseTo(3); + expect((s as Rect).height).toBeCloseTo(4); + }); + + it("return_circle_as_shape (out-of-body impl)", () => { + const s = return_circle_as_shape(); + expect(s).toBeInstanceOf(Circle); + expect((s as Circle).radius).toBeCloseTo(2); + }); + + // ── parameter position — KNOWN GAP: class identity lost on encode, so + // the engine cannot dispatch interface methods on host instances ───── + + it("shape_area panics on Square (class identity lost)", () => + expect(() => shape_area(new Square({ side: 5 }))).toThrow( + /could not resolve interface method/, + )); + + it("shape_area panics on Rect", () => + expect(() => shape_area(new Rect({ width: 3, height: 4 }))).toThrow( + /could not resolve interface method/, + )); + + it("shape_area panics on out-of-body impl", () => + expect(() => shape_area(new Circle({ radius: 2 }))).toThrow( + /could not resolve interface method/, + )); + + it("shape_area_async rejects", async () => + await expect(shape_area_async(new Square({ side: 6 }))).rejects.toThrow( + /could not resolve interface method/, + )); + + it("sum_areas panics", () => + expect(() => + sum_areas(new Square({ side: 2 }), new Rect({ width: 3, height: 4 })), + ).toThrow(/could not resolve interface method/)); + + // ── round trips — KNOWN GAP: interface positions come back as plain + // objects (field values survive, class identity does not) ──────────── + + it("round_trip_shape returns a plain object", () => { + const r = round_trip_shape(new Rect({ width: 2, height: 3 })); + expect(r).not.toBeInstanceOf(Rect); + expect(r).toEqual({ width: 2, height: 3 }); + }); + + it("round_trip_shape_list returns plain objects", () => { + const r = round_trip_shape_list([ + new Square({ side: 1 }), + new Rect({ width: 2, height: 3 }), + new Circle({ radius: 4 }), + ]); + expect(r[0]).not.toBeInstanceOf(Square); + expect(r).toEqual([{ side: 1 }, { width: 2, height: 3 }, { radius: 4 }]); + }); + + it("round_trip_optional_shape null", () => + expect(round_trip_optional_shape(null)).toBeNull()); + + it("round_trip_optional_shape value returns a plain object", () => { + const r = round_trip_optional_shape(new Circle({ radius: 1 })); + expect(r).not.toBeInstanceOf(Circle); + expect(r).toEqual({ radius: 1 }); + }); + + it("round_trip_shape_box keeps the box, loses the field's class", () => { + // The box itself is a declared class type, so its identity survives; + // its interface-typed `shape` field decays to a plain object. + const r = round_trip_shape_box(new ShapeBox({ shape: new Square({ side: 2 }) })); + expect(r).toBeInstanceOf(ShapeBox); + expect(r.shape).not.toBeInstanceOf(Square); + expect(r.shape).toEqual({ side: 2 }); + }); + + it("box_area panics (field dispatch)", () => + expect(() => + box_area(new ShapeBox({ shape: new Rect({ width: 2, height: 5 }) })), + ).toThrow(/could not resolve interface method/)); + + // ── KNOWN GAPS shared with the python SDK ──────────────────────────── + + it("non-implementor panics at dispatch, not encode", () => { + // No encode-time conformance check for interface-typed params: a + // non-implementing value encodes fine and only fails inside the VM. + expect(() => + shape_area(new ShapeBox({ shape: new Square({ side: 1 }) })), + ).toThrow(/could not resolve interface method/); + expect(() => shape_area(42)).toThrow(/could not resolve interface method/); + }); + + it("impl-method host binding is not callable", () => { + // sdkgen emits `area`/`area_async` bindings on implementing classes + // (named `user.interfaces.Square.area`), but the engine registers + // interface-impl methods under a synthetic `Shape$for$Square` name, + // so calling the binding from the host panics with "Function not + // found". Update this pin when the binding and registration agree. + expect(() => new Square({ side: 3 }).area()).toThrow(/Function not found/); + }); +}); diff --git a/baml_language/sdk_tests/fixtures/type_shapes/baml_src/ns_interfaces/types.baml b/baml_language/sdk_tests/fixtures/type_shapes/baml_src/ns_interfaces/types.baml new file mode 100644 index 0000000000..0a680ffef7 --- /dev/null +++ b/baml_language/sdk_tests/fixtures/type_shapes/baml_src/ns_interfaces/types.baml @@ -0,0 +1,106 @@ +// Coverage for interface types (BEP-044) at the host SDK boundary. +// +// Interfaces are BAML-side contracts, not serializable host SDK models: +// codegen surfaces interface-typed boundary positions as opaque values +// (`typing.Any` / `unknown` — see `client_codegen.rs` +// `TirTy::Interface(..) => cg::Ty::BuiltinUnknown`). The *values* crossing +// the boundary are always concrete class instances, so what these fixtures +// pin is: +// +// - return position: the host receives the concrete implementing class +// - parameter position: the host passes a concrete instance and the +// engine dispatches interface methods on it +// - encode → FFI → decode round trips preserve the concrete class, in +// bare, list, optional, and class-field positions +// +// The two SDKs currently diverge on the inbound direction: the python +// bridge encodes host instances by their runtime class (all of the above +// works), while the nodejs bridge encodes by the declared codegen type +// (`unknown`), losing class identity in interface positions. Each SDK's +// test file pins its own current behavior. + +interface Shape { + function area(self) -> int +} + +// In-body `implements` form. +class Square { + side int + implements Shape { + function area(self) -> int { + self.side * self.side + } + } +} + +class Rect { + width int + height int + implements Shape { + function area(self) -> int { + self.width * self.height + } + } +} + +// Out-of-body `implements ... for ...` form. +class Circle { + radius int +} + +implements Shape for Circle { + function area(self) -> int { + 3 * self.radius * self.radius + } +} + +// Interface in a class-field position. +class ShapeBox { + shape Shape +} + +// ── interface in return position ──────────────────────────────────────── + +function return_square_as_shape() -> Shape { + Square { side: 5 } +} + +function return_rect_as_shape() -> Shape { + Rect { width: 3, height: 4 } +} + +function return_circle_as_shape() -> Shape { + Circle { radius: 2 } +} + +// ── interface in parameter position (engine-side method dispatch) ─────── + +function shape_area(s: Shape) -> int { + s.area() +} + +function sum_areas(a: Shape, b: Shape) -> int { + a.area() + b.area() +} + +// ── encode → FFI → decode round trips ─────────────────────────────────── + +function round_trip_shape(s: Shape) -> Shape { + s +} + +function round_trip_shape_list(shapes: Shape[]) -> Shape[] { + shapes +} + +function round_trip_optional_shape(s: Shape?) -> Shape? { + s +} + +function round_trip_shape_box(b: ShapeBox) -> ShapeBox { + b +} + +function box_area(b: ShapeBox) -> int { + b.shape.area() +}