Skip to content

Commit bb53ddb

Browse files
committed
fix(types): make ComputedModelOutputThunk.parsed generic over the format type
`.parsed` previously returned `pydantic.BaseModel | None`, so callers still needed `cast(MyModel, result.parsed)` for static narrowing — the gap @ajbozarth flagged on PR generative-computing#1282. Thread the format type through the thunk's existing type parameter `S`: `_format` is now `type[S] | None` and `.parsed` returns `S | None`. Reusing `S` (rather than a second TypeVar) composes with the companion `format=` overloads on generative-computing#1274, which bind `S` to the supplied model so `m.act(action, format=MyModel)` yields `ComputedModelOutputThunk[MyModel]` and `.parsed` is typed `MyModel | None`. The `.parsed` body narrows `_format` to a pydantic type to call `model_validate_json`, then re-asserts the result as `S` — `S` is unbounded (it is `str` for plain instructions) so neither cast can be elided. Add `test/typing/check_parsed.py` asserting `.parsed` tracks the type parameter for both a model-parameterized and a `str` thunk. Assisted-by: Claude Code
1 parent 4857a32 commit bb53ddb

2 files changed

Lines changed: 44 additions & 8 deletions

File tree

mellea/core/base.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ParamSpec,
3030
Protocol,
3131
TypeVar,
32+
cast,
3233
runtime_checkable,
3334
)
3435

@@ -402,7 +403,7 @@ def __init__(
402403
# Mellea-side hook correlation ID; distinct from the provider-assigned
403404
# `GenerationMetadata.response_id`.
404405
self._generation_id: str | None = None
405-
self._format: type[pydantic.BaseModel] | None = None
406+
self._format: type[S] | None = None
406407

407408
def _record_ttfb(self) -> None:
408409
"""Record time-to-first-byte if streaming and not yet recorded."""
@@ -893,15 +894,18 @@ def value(self, v: str):
893894
self._underlying_value = v
894895

895896
@property
896-
def parsed(self) -> pydantic.BaseModel | None:
897+
def parsed(self) -> S | None:
897898
"""Returns the result as a validated Pydantic instance when ``format=`` was set.
898899
899-
Returns ``None`` when no ``format=`` type was provided to the originating
900-
``act()`` / ``instruct()`` call. Use this instead of casting ``.value``
900+
The return type tracks the format type supplied at the originating call
901+
site: ``m.act(action, format=MyModel)`` yields a
902+
``ComputedModelOutputThunk[MyModel]`` whose ``.parsed`` is typed
903+
``MyModel | None`` — no ``cast()`` required. Returns ``None`` when no
904+
``format=`` type was provided. Use this instead of casting ``.value``
901905
manually::
902906
903907
result = m.act(Instruction("Say yes or no"), format=MyModel)
904-
obj = result.parsed # no manual model_validate_json needed
908+
obj = result.parsed # typed MyModel | None, no model_validate_json needed
905909
906910
Note:
907911
This property relies on the originating backend storing the format
@@ -911,16 +915,20 @@ def parsed(self) -> pydantic.BaseModel | None:
911915
``format=`` was supplied.
912916
913917
Returns:
914-
A ``pydantic.BaseModel`` instance produced by ``model_validate_json``,
915-
or ``None`` if no format type was set.
918+
An instance of the format type (``S``) produced by
919+
``model_validate_json``, or ``None`` if no format type was set.
916920
917921
Raises:
918922
pydantic.ValidationError: If the raw JSON value does not conform to
919923
the format model (e.g. the model returned malformed structured output).
920924
"""
921925
if self._format is None:
922926
return None
923-
return self._format.model_validate_json(self.value)
927+
# `_format` is a pydantic model type in every code path that sets it (the
928+
# `format=` overloads bind `S` to that model), but `S` itself is unbounded,
929+
# so we narrow to call `model_validate_json` and re-assert the result as `S`.
930+
fmt = cast("type[pydantic.BaseModel]", self._format)
931+
return cast("S", fmt.model_validate_json(self.value))
924932

925933
def is_computed(self) -> Literal[True]:
926934
"""Returns `True` since thunk is always computed.

test/typing/check_parsed.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Mypy checks that `ComputedModelOutputThunk.parsed` tracks the type parameter.
2+
3+
`.parsed` is typed `S | None`, so a thunk parameterized with a Pydantic model
4+
(`ComputedModelOutputThunk[MyModel]`) exposes `.parsed` as `MyModel | None` —
5+
callers need no `cast()`. The `format=` overloads in `session.py` /
6+
`functional.py` bind `S` to the format model (companion issue #1274), at which
7+
point these checks hold end-to-end from the call site.
8+
"""
9+
10+
from typing import assert_type, cast
11+
12+
import pydantic
13+
14+
from mellea.core import ComputedModelOutputThunk
15+
16+
17+
class _Person(pydantic.BaseModel):
18+
name: str
19+
20+
21+
def check_parsed_tracks_format_model() -> None:
22+
thunk = cast(ComputedModelOutputThunk[_Person], None)
23+
assert_type(thunk.parsed, _Person | None)
24+
25+
26+
def check_parsed_is_str_for_str_thunk() -> None:
27+
thunk = cast(ComputedModelOutputThunk[str], None)
28+
assert_type(thunk.parsed, str | None)

0 commit comments

Comments
 (0)