Skip to content

fix: round-trip serialization of typing.Annotated - #12339

Draft
Harsh23Kashyap wants to merge 2 commits into
deepset-ai:mainfrom
Harsh23Kashyap:fix/annotated-type-serialization
Draft

fix: round-trip serialization of typing.Annotated#12339
Harsh23Kashyap wants to merge 2 commits into
deepset-ai:mainfrom
Harsh23Kashyap:fix/annotated-type-serialization

Conversation

@Harsh23Kashyap

Copy link
Copy Markdown

Related Issues

Proposed Changes:

serialize_type had no handling for typing.Annotated, so it fell through to the generic-path logic and rendered the metadata values as if they were type names:

serialize_type(Annotated[int, "doc"])   # -> "typing.Annotated[int, doc]"

deserialize_type then tried to resolve doc as a type and raised DeserializationError. A value that happened to look like a real type name silently corrupted instead of raising: Annotated[str, "int"] round-tripped to Annotated[str, int] (the string was read as the int type).

The fix special-cases typing.Annotated on both sides, mirroring the recent Literal fix (PR #12286, commit 1f460e6):

  • serialize: render literal metadata (str / int / bool / None / bytes) with repr() so strings keep their quotes; render type-like metadata (classes, typing forms) through serialize_type so the full module path is preserved.
  • deserialize: split the type from the metadata on the first top-level comma — a quote-aware split, since a string metadata value can contain a comma (Annotated[int, "a, b"]). The type is resolved through deserialize_type; the metadata is parsed in bulk with ast.literal_eval (quote-aware, limited to safe literals) and falls back to deserialize_type per-arg when a type metadata is present.

Scope note: this covers the metadata kinds Python's own Annotated accepts and that survive a text round-trip (str, bytes, int, bool, None — same set as Literal). Type metadata (a class as a metadata value, e.g. Annotated[int, MyValidator]) is also handled via the serialize_type / deserialize_type fallback path. Enum members and arbitrary callables are out of scope — they cannot be reconstructed from text alone.

How did you test it?

  • Added serialization, deserialization, and round_trip tests for Annotated in test/utils/test_type_serialization.py, mirroring the existing Literal and Callable trios. Covers: failing case (Annotated[int, "doc"]), silent-corruption case (Annotated[str, "int"]), multiple metadata values, non-string literal metadata (int / bool / None / bytes), a comma inside a string metadata value (Annotated[int, "a, b"]), Ellipsis metadata, type-metadata fallback (Annotated[int, str]), typing-form metadata (Annotated[int, List[str]]), Literal as metadata, nested in List / Optional, and the wrapped type being a Callable with a top-level comma in its parameter list.
  • Added a direct unit test for the new _split_annotated_args helper (quote-aware split, no comma / no metadata / nested brackets).
  • Added an error test for the malformed typing.Annotated[int] form (no metadata) — DeserializationError rather than a silent misshape.
  • Full file: 200 passed (195 existing + 5 new), no existing tests broken. The broader serialization surface (test/core/test_serialization.py, test/core/test_serialization_security.py, test/core/test_type_utils.py, test/core/pipeline/, test/components/converters/, test/components/routers/) also passes — 1245 + 825 tests across the related modules, no regressions.

Checklist

  • I have read the contributors guidelines and the code of conduct
  • I have updated the release note (reno note in releasenotes/notes/)
  • I have run hatch run fmt and hatch run test:types locally — both clean
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@Harsh23Kashyap
Harsh23Kashyap requested a review from a team as a code owner August 13, 2026 15:32
@Harsh23Kashyap
Harsh23Kashyap requested review from bogdankostic and removed request for a team August 13, 2026 15:32
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the deepset Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant

CLAassistant commented Aug 13, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

`serialize_type` had no handling for `typing.Annotated`, so it fell through
to the generic-path logic and rendered the metadata values as if they were
type names:

```python
serialize_type(Annotated[int, 'doc'])   # -> 'typing.Annotated[int, doc]'
```

`deserialize_type` then tried to resolve `doc` as a type and raised
`DeserializationError`. A value that happened to look like a real type name
silently corrupted instead of raising: `Annotated[str, 'int']` round-tripped
to `Annotated[str, int]` (the string was read as the `int` type).

The fix special-cases `typing.Annotated` on both sides, mirroring the recent
`Literal` fix (PR deepset-ai#12286, commit 1f460e6):

- **serialize:** render literal metadata (str/int/bool/None/bytes) with
  `repr()` so strings keep their quotes; render type-like metadata (classes,
  typing forms) through `serialize_type` so the full module path is preserved.
- **deserialize:** split the type from the metadata on the first top-level
  comma — a quote-aware split, since a string metadata value can contain a
  comma (`Annotated[int, 'a, b']`). The type is resolved through
  `deserialize_type`; the metadata is parsed in bulk with
  `ast.literal_eval` (quote-aware, limited to safe literals) and falls back
  to `deserialize_type` per-arg when a type metadata is present.

An `Annotated` type used by a component (e.g. an `OutputAdapter` whose output
type is `Annotated[Result, 'computed']`, or any tool whose schema uses
Pydantic `Field` metadata) now round-trips through `Pipeline.dumps()` /
`Pipeline.loads()`. All other types are unaffected.

Fixes deepset-ai#12338
@Harsh23Kashyap
Harsh23Kashyap force-pushed the fix/annotated-type-serialization branch from 4cc0902 to f34cb88 Compare August 13, 2026 15:33
@HaystackBot

Copy link
Copy Markdown
Contributor

Hi @Harsh23Kashyap, thanks a lot for your contribution! 🙏

We noticed that the Contributor License Agreement (CLA) check (license/cla) hasn't passed yet, so we've temporarily moved this PR to draft and paused the review assignment.

To get your PR reviewed, please sign the CLA via the link in the license/cla check below (or in the CLA bot comment). As soon as the check turns green, this PR will automatically be marked ready for review again and a reviewer will be re-assigned.

@HaystackBot
HaystackBot removed the request for review from bogdankostic August 13, 2026 16:36
@HaystackBot HaystackBot added the cla-pending PR is in draft until the contributor signs the CLA label Aug 13, 2026
@HaystackBot
HaystackBot marked this pull request as draft August 13, 2026 16:36
`_safe_get_origin` and `_strict_types_are_compatible` in
`haystack.core.type_utils` did not unwrap `typing.Annotated`, and
`_unwrap_all` in `haystack.core.super_component.utils` had the same
gap. A component socket declared as `Annotated[int, "doc"]` was treated
as a different type from `int` and could not be connected, even though
`Annotated[T, m1, m2, ...]` is the same type as `T` for type-checking
purposes — the metadata is just an annotation, not a type modifier.

This made the previous fix in `f34cb882b` (round-trip serialization of
`Annotated`) only half-useful: a `Pipeline` could now serialize a
component with `Annotated` socket types, but the loaded pipeline would
refuse to connect those sockets to anything else.

Both checks now unwrap `Annotated` to the underlying type. A
`GreedyVariadic` / `Variadic` (which is expressed as
`Annotated[T, HAYSTACK_VARIADIC_ANNOTATION]`) is still detected by the
existing `_is_variadic_type` check before the new unwrap fires, so
Haystack's variadic semantics are unchanged. Added direct unit tests for
both checkers (`test_annotated_same_as_wrapped_type_strict`,
`test_safe_get_origin_unwraps_annotated`, and
`test_annotated_type_compatibility` in super_component) covering the
common case, nested `Annotated`, generic wrapped types, and
`Annotated[Optional[int]]`. Updated the reno note to mention both gaps.

Same shape as the `Literal` fix (PR deepset-ai#12286) and the
`Annotated` serialization fix in `f34cb882b`: small, focused,
existing-pattern reuse (`_unwrap_annotated` helper mirrors
`_safe_get_origin`'s existing shape).
@Harsh23Kashyap

Copy link
Copy Markdown
Author

Additional changes (review-time follow-up)

A paranoid maintainer review turned up a second gap that the original PR's "round-trip through Pipeline" claim needed but didn't cover: the Pipeline type checker rejected Annotated socket types, so a pipeline that serialized a component with Annotated[int, "doc"] could not connect that socket to one declared as int.

The same Annotated unwrap is added in two places:

  • haystack/core/type_utils.py: _safe_get_origin and _strict_types_are_compatible now unwrap Annotated[T, m1, m2, ...] to T (with a new _unwrap_annotated helper). Annotated is just annotation, not a type modifier.
  • haystack/core/super_component/utils.py: _unwrap_all now unwraps Annotated to the underlying type before the variadic/optional unwrap runs. Haystack Variadic / GreedyVariadic (which use Annotated[T, HAYSTACK_VARIADIC_ANNOTATION] as a marker) are still detected by the existing _is_variadic_type check before the new unwrap fires, so variadic semantics are unchanged.

New tests in test/core/test_type_utils.py (test_annotated_same_as_wrapped_type_strict, test_safe_get_origin_unwraps_annotated) and test/core/super_component/test_utils.py (test_annotated_type_compatibility). 1768 tests pass across the related modules; no regressions. The pre-existing flaky test_from_dict_with_callbacks fails on a clean main too (confirmed via git stash), so it's unrelated.

Reno note updated to mention both gaps.

fix: treat typing.Annotated as the wrapped type in Pipeline type checks
7805a4a34 — 5 files, +143 / -17

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-pending PR is in draft until the contributor signs the CLA topic:core topic:tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: serialize_type / deserialize_type do not support typing.Annotated

3 participants