Skip to content

fix(v2): derive Iterable name from union members instead of "Union"#2406

Open
Sanjays2402 wants to merge 1 commit into
567-labs:mainfrom
Sanjays2402:fix/iterable-union-clean-name
Open

fix(v2): derive Iterable name from union members instead of "Union"#2406
Sanjays2402 wants to merge 1 commit into
567-labs:mainfrom
Sanjays2402:fix/iterable-union-clean-name

Conversation

@Sanjays2402

Copy link
Copy Markdown
Contributor

What this fixes

When building an Iterable response model for a union task type, the generated class name collapses to IterableUnion for both typing.Union[A, B] and PEP 604 A | B, instead of a member-derived name like IterableAOrB.

Root cause

In instructor/v2/dsl/iterable.py, the naming logic was:

# Handle `Union[A, B]` / `A | B` task types.
# `types.UnionType` does not have `__name__`, so fall back to a stable name.
task_name = getattr(subtask_class, "__name__", None)
if task_name is None and get_origin(subtask_class) in _UNION_ORIGINS:
    members = get_args(subtask_class)
    task_name = "Or".join(getattr(m, "__name__", str(m)) for m in members)
if task_name is None:
    task_name = str(subtask_class)

The comment's assumption is incorrect: a union type's __name__ is "Union", not None. (And on modern Python, A | B / types.UnionType normalizes to typing.Union, whose __name__ is also "Union".) So getattr(..., "__name__", None) returns "Union", the if task_name is None guard is never entered, the member-joining branch is skipped, and every union becomes IterableUnion.

This affected both union spellings:

prepare_response_model(list[A | B]).__name__          # -> "IterableUnion"  (want IterableAOrB)
prepare_response_model(List[Union[A, B]]).__name__    # -> "IterableUnion"  (want IterableAOrB)

The fix

Detect a union origin first, and only fall back to __name__/str() for non-union types:

if get_origin(subtask_class) in _UNION_ORIGINS:
    members = get_args(subtask_class)
    task_name = "Or".join(getattr(m, "__name__", str(m)) for m in members)
else:
    task_name = getattr(subtask_class, "__name__", None) or str(subtask_class)

Result:

Input Before After
list[A | B] IterableUnion IterableAOrB
List[Union[A, B]] IterableUnion IterableAOrB
list[A | B | C] IterableUnion IterableAOrBOrC
list[User] (non-union) IterableUser IterableUser (unchanged)

Tests

The existing test_list_of_model_pipe_union_generates_clean_name was failing on main (it asserts IterableAOrB but got IterableUnion) — this fix makes it pass. I also added two regression tests for the typing.Union form and a three-way union.

pytest tests/dsl tests/core -k "not test_patch_completes and not test_apatch_completes"
135 passed, 3 skipped

(The two deselected tests instantiate OpenAI() and require an API key — unrelated to this change; they fail identically on main.)

ruff check passes on both changed files. black reports one pre-existing reformat elsewhere in iterable.py that is present on main too and is left untouched.

Type of change

  • Bug fix (non-breaking) — improves generated class names for union iterables; non-union names are unchanged.

When building an Iterable response model for a union task type, the generated
class name collapsed to `IterableUnion` for both `typing.Union[A, B]` and PEP 604
`A | B`.

The naming logic looked up `getattr(subtask_class, "__name__", None)` first and
only fell back to joining the member names when that returned None. But a union
type's `__name__` is `"Union"` (not None) -- and on modern Python `A | B`
(`types.UnionType`) normalizes to `typing.Union` as well -- so the member-joining
branch was never reached and every union produced `IterableUnion`.

Detect a union origin first and build the name from its members
(`IterableAOrB`), falling back to `__name__`/`str()` only for non-union types.

Adds regression tests for the pipe union, the typing.Union form, and a
three-way union.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant