Skip to content

Commit e94c57d

Browse files
sxlijinclaude
andcommitted
test(sdk_tests): cover interface types (BEP-044) at the SDK boundary
Adds a `ns_interfaces` fixture: an interface with in-body and out-of-body `implements` forms, functions that return and receive interface-typed values, and round trips in bare, list, optional, and class-field positions. Host tests pin the current per-SDK behavior, which diverges: - python: fully working — interface returns and params carry the concrete class across the boundary and the engine dispatches interface methods on host-constructed instances. - nodejs: returns work, but inbound encode is driven by the declared codegen type (`unknown` for interface positions), so class identity is lost — interface-method dispatch panics and round-tripped values come back as plain objects. Both SDKs emit host bindings for interface-impl methods that the engine registers under a synthetic `Iface$for$Class` name, so calling them panics with "Function not found" (also pinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8afbddd commit e94c57d

3 files changed

Lines changed: 414 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Roundtrip coverage for `baml_sdk.interfaces` — interface types (BEP-044).
2+
3+
Interfaces are BAML-side contracts, not serializable host SDK models:
4+
codegen surfaces interface-typed boundary positions as `typing.Any`
5+
(`client_codegen.rs`: `TirTy::Interface(..) => cg::Ty::BuiltinUnknown`).
6+
The *values* crossing the boundary are always concrete class instances.
7+
8+
These tests pin:
9+
10+
- return position: the host receives the concrete implementing class
11+
(for both the in-body `implements` form — `Square`/`Rect` — and the
12+
out-of-body `implements Shape for Circle` form)
13+
- parameter position: the host passes a concrete instance and the
14+
engine dispatches interface methods on it
15+
- round trips preserve the concrete class in bare, list, optional,
16+
and class-field positions
17+
- KNOWN GAPS (pinned so a fix flips these tests and forces an
18+
intentional update): no encode-time conformance check for interface
19+
params, and impl-method host bindings point at unregistered names
20+
21+
The python bridge encodes host instances by their runtime class, so all
22+
of the above works here. The nodejs SDK currently diverges — its bridge
23+
encodes by the declared codegen type, losing class identity in interface
24+
positions; see roundtrip_interfaces.test.ts for the pinned node behavior.
25+
"""
26+
27+
import pytest
28+
29+
import baml_sdk # noqa: F401 — initializes the BAML runtime
30+
from baml_core.errors import BamlPanic
31+
from baml_sdk.interfaces import (
32+
Circle,
33+
Rect,
34+
ShapeBox,
35+
Square,
36+
box_area,
37+
return_circle_as_shape,
38+
return_rect_as_shape,
39+
return_square_as_shape,
40+
round_trip_optional_shape,
41+
round_trip_shape,
42+
round_trip_shape_box,
43+
round_trip_shape_list,
44+
shape_area,
45+
shape_area_async,
46+
sum_areas,
47+
)
48+
49+
50+
# ── return position: host receives the concrete implementing class ──────
51+
52+
53+
def test_return_square_as_shape():
54+
s = return_square_as_shape()
55+
assert isinstance(s, Square)
56+
assert s.side == 5
57+
58+
59+
def test_return_rect_as_shape():
60+
s = return_rect_as_shape()
61+
assert isinstance(s, Rect)
62+
assert s == Rect(width=3, height=4)
63+
64+
65+
def test_return_circle_as_shape():
66+
# Circle implements Shape via the out-of-body `implements ... for` form.
67+
s = return_circle_as_shape()
68+
assert isinstance(s, Circle)
69+
assert s.radius == 2
70+
71+
72+
# ── parameter position: engine dispatches interface methods ─────────────
73+
74+
75+
def test_shape_area_dispatches_on_square():
76+
assert shape_area(s=Square(side=5)) == 25
77+
78+
79+
def test_shape_area_dispatches_on_rect():
80+
assert shape_area(s=Rect(width=3, height=4)) == 12
81+
82+
83+
def test_shape_area_dispatches_on_out_of_body_impl():
84+
assert shape_area(s=Circle(radius=2)) == 12
85+
86+
87+
async def test_shape_area_async_dispatches():
88+
assert await shape_area_async(s=Square(side=6)) == 36
89+
90+
91+
def test_sum_areas_mixes_implementors():
92+
assert sum_areas(a=Square(side=2), b=Rect(width=3, height=4)) == 16
93+
94+
95+
# ── round trips preserve the concrete class ─────────────────────────────
96+
97+
98+
def test_round_trip_shape_preserves_concrete_class():
99+
r = round_trip_shape(s=Rect(width=2, height=3))
100+
assert isinstance(r, Rect)
101+
assert r == Rect(width=2, height=3)
102+
103+
104+
def test_round_trip_shape_list_preserves_each_element():
105+
shapes = [Square(side=1), Rect(width=2, height=3), Circle(radius=4)]
106+
assert round_trip_shape_list(shapes=shapes) == shapes
107+
108+
109+
def test_round_trip_optional_shape_none():
110+
assert round_trip_optional_shape(s=None) is None
111+
112+
113+
def test_round_trip_optional_shape_value():
114+
r = round_trip_optional_shape(s=Circle(radius=1))
115+
assert isinstance(r, Circle)
116+
assert r.radius == 1
117+
118+
119+
def test_round_trip_shape_box_field_position():
120+
r = round_trip_shape_box(b=ShapeBox(shape=Square(side=2)))
121+
assert isinstance(r, ShapeBox)
122+
assert isinstance(r.shape, Square)
123+
assert r.shape.side == 2
124+
125+
126+
def test_box_area_dispatches_through_field():
127+
assert box_area(b=ShapeBox(shape=Rect(width=2, height=5))) == 10
128+
129+
130+
# ── KNOWN GAPS — these pin *current* behavior, not desired behavior ─────
131+
132+
133+
def test_non_implementor_panics_at_dispatch_not_encode():
134+
# There is no encode-time conformance check for interface-typed
135+
# params (the codegen type is `typing.Any`): a value whose class
136+
# does not implement Shape encodes fine and only fails inside the
137+
# VM when the virtual call cannot resolve. A future encode- or
138+
# call-time check would surface a TypeError instead — update this
139+
# pin when that lands. NB: BamlPanic derives BaseException.
140+
with pytest.raises(BamlPanic, match="could not resolve interface method"):
141+
shape_area(s=ShapeBox(shape=Square(side=1)))
142+
143+
144+
def test_primitive_into_interface_param_panics_at_dispatch():
145+
with pytest.raises(BamlPanic, match="could not resolve interface method"):
146+
shape_area(s=42)
147+
148+
149+
def test_impl_method_host_binding_is_not_callable():
150+
# sdkgen emits `area`/`area_async` bindings on implementing classes
151+
# (named `user.interfaces.Square.area`), but the engine registers
152+
# interface-impl methods under a synthetic `Shape$for$Square` name,
153+
# so calling the binding from the host panics with "Function not
154+
# found". Update this pin when the binding and registration agree.
155+
with pytest.raises(BamlPanic, match="Function not found"):
156+
Square(side=3).area()
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Roundtrip coverage for baml_sdk/interfaces — interface types (BEP-044).
2+
// Counterpart of python_pydantic2/.../roundtrip_tests/test_interfaces.py.
3+
//
4+
// Interfaces are BAML-side contracts, not serializable host SDK models:
5+
// codegen surfaces interface-typed boundary positions as `unknown`
6+
// (client_codegen.rs: `TirTy::Interface(..) => cg::Ty::BuiltinUnknown`).
7+
//
8+
// KNOWN GAP (nodejs bridge) — unlike the python SDK, where all of these
9+
// scenarios work: outbound host→engine encoding here is driven by the
10+
// declared codegen type, so a class instance passed in an interface
11+
// position (`unknown`) loses its class identity and encodes as a plain
12+
// object. The engine then cannot dispatch interface methods on it ("VM
13+
// internal error: virtual call could not resolve interface method"), and
14+
// round-tripped interface positions come back as plain objects rather
15+
// than class instances. Interface *returns* are unaffected: the value
16+
// leaves the engine carrying its concrete class, so the host receives a
17+
// real class instance. The tests below pin this *current* behavior, not
18+
// desired behavior — update them when the nodejs bridge encodes by the
19+
// value's runtime class (as python does) or codegen grows a structural
20+
// interface representation.
21+
import "./baml_sdk/index.js"; // initializes the BAML runtime
22+
import { describe, it, expect } from "vitest";
23+
import {
24+
Circle,
25+
Rect,
26+
ShapeBox,
27+
Square,
28+
box_area,
29+
return_circle_as_shape,
30+
return_rect_as_shape,
31+
return_square_as_shape,
32+
round_trip_optional_shape,
33+
round_trip_shape,
34+
round_trip_shape_box,
35+
round_trip_shape_list,
36+
shape_area,
37+
shape_area_async,
38+
sum_areas,
39+
} from "./baml_sdk/interfaces/index.js";
40+
41+
describe("roundtrip interfaces", () => {
42+
// ── return position: host receives the concrete implementing class ──
43+
44+
it("return_square_as_shape", () => {
45+
const s = return_square_as_shape();
46+
expect(s).toBeInstanceOf(Square);
47+
expect((s as Square).side).toBeCloseTo(5);
48+
});
49+
50+
it("return_rect_as_shape", () => {
51+
const s = return_rect_as_shape();
52+
expect(s).toBeInstanceOf(Rect);
53+
expect((s as Rect).width).toBeCloseTo(3);
54+
expect((s as Rect).height).toBeCloseTo(4);
55+
});
56+
57+
it("return_circle_as_shape (out-of-body impl)", () => {
58+
const s = return_circle_as_shape();
59+
expect(s).toBeInstanceOf(Circle);
60+
expect((s as Circle).radius).toBeCloseTo(2);
61+
});
62+
63+
// ── parameter position — KNOWN GAP: class identity lost on encode, so
64+
// the engine cannot dispatch interface methods on host instances ─────
65+
66+
it("shape_area panics on Square (class identity lost)", () =>
67+
expect(() => shape_area(new Square({ side: 5 }))).toThrow(
68+
/could not resolve interface method/,
69+
));
70+
71+
it("shape_area panics on Rect", () =>
72+
expect(() => shape_area(new Rect({ width: 3, height: 4 }))).toThrow(
73+
/could not resolve interface method/,
74+
));
75+
76+
it("shape_area panics on out-of-body impl", () =>
77+
expect(() => shape_area(new Circle({ radius: 2 }))).toThrow(
78+
/could not resolve interface method/,
79+
));
80+
81+
it("shape_area_async rejects", async () =>
82+
await expect(shape_area_async(new Square({ side: 6 }))).rejects.toThrow(
83+
/could not resolve interface method/,
84+
));
85+
86+
it("sum_areas panics", () =>
87+
expect(() =>
88+
sum_areas(new Square({ side: 2 }), new Rect({ width: 3, height: 4 })),
89+
).toThrow(/could not resolve interface method/));
90+
91+
// ── round trips — KNOWN GAP: interface positions come back as plain
92+
// objects (field values survive, class identity does not) ────────────
93+
94+
it("round_trip_shape returns a plain object", () => {
95+
const r = round_trip_shape(new Rect({ width: 2, height: 3 }));
96+
expect(r).not.toBeInstanceOf(Rect);
97+
expect(r).toEqual({ width: 2, height: 3 });
98+
});
99+
100+
it("round_trip_shape_list returns plain objects", () => {
101+
const r = round_trip_shape_list([
102+
new Square({ side: 1 }),
103+
new Rect({ width: 2, height: 3 }),
104+
new Circle({ radius: 4 }),
105+
]);
106+
expect(r[0]).not.toBeInstanceOf(Square);
107+
expect(r).toEqual([{ side: 1 }, { width: 2, height: 3 }, { radius: 4 }]);
108+
});
109+
110+
it("round_trip_optional_shape null", () =>
111+
expect(round_trip_optional_shape(null)).toBeNull());
112+
113+
it("round_trip_optional_shape value returns a plain object", () => {
114+
const r = round_trip_optional_shape(new Circle({ radius: 1 }));
115+
expect(r).not.toBeInstanceOf(Circle);
116+
expect(r).toEqual({ radius: 1 });
117+
});
118+
119+
it("round_trip_shape_box keeps the box, loses the field's class", () => {
120+
// The box itself is a declared class type, so its identity survives;
121+
// its interface-typed `shape` field decays to a plain object.
122+
const r = round_trip_shape_box(new ShapeBox({ shape: new Square({ side: 2 }) }));
123+
expect(r).toBeInstanceOf(ShapeBox);
124+
expect(r.shape).not.toBeInstanceOf(Square);
125+
expect(r.shape).toEqual({ side: 2 });
126+
});
127+
128+
it("box_area panics (field dispatch)", () =>
129+
expect(() =>
130+
box_area(new ShapeBox({ shape: new Rect({ width: 2, height: 5 }) })),
131+
).toThrow(/could not resolve interface method/));
132+
133+
// ── KNOWN GAPS shared with the python SDK ────────────────────────────
134+
135+
it("non-implementor panics at dispatch, not encode", () => {
136+
// No encode-time conformance check for interface-typed params: a
137+
// non-implementing value encodes fine and only fails inside the VM.
138+
expect(() =>
139+
shape_area(new ShapeBox({ shape: new Square({ side: 1 }) })),
140+
).toThrow(/could not resolve interface method/);
141+
expect(() => shape_area(42)).toThrow(/could not resolve interface method/);
142+
});
143+
144+
it("impl-method host binding is not callable", () => {
145+
// sdkgen emits `area`/`area_async` bindings on implementing classes
146+
// (named `user.interfaces.Square.area`), but the engine registers
147+
// interface-impl methods under a synthetic `Shape$for$Square` name,
148+
// so calling the binding from the host panics with "Function not
149+
// found". Update this pin when the binding and registration agree.
150+
expect(() => new Square({ side: 3 }).area()).toThrow(/Function not found/);
151+
});
152+
});

0 commit comments

Comments
 (0)