Skip to content

Support Pydantic models#982

Open
acroca wants to merge 4 commits intodapr:mainfrom
acroca:pydantic
Open

Support Pydantic models#982
acroca wants to merge 4 commits intodapr:mainfrom
acroca:pydantic

Conversation

@acroca
Copy link
Copy Markdown
Member

@acroca acroca commented Apr 17, 2026

Summary

Closes #858

Users can now pass Pydantic BaseModel instances directly to schedule_new_workflow, call_activity, call_child_workflow, and raise_workflow_event, and receive real model instances on the other side just by annotating their workflow/activity input parameter — no manual model_dump / model_validate calls required.

from pydantic import BaseModel
from dapr.ext.workflow import WorkflowRuntime, DaprWorkflowContext, WorkflowActivityContext

class OrderRequest(BaseModel):
    order_id: str
    amount: float

wfr = WorkflowRuntime()

@wfr.workflow
def order_workflow(ctx: DaprWorkflowContext, order: OrderRequest):
    # `order` is a real OrderRequest instance, not a dict
    yield ctx.call_activity(charge, input=order)

@wfr.activity
def charge(ctx: WorkflowActivityContext, order: OrderRequest):
    # Same here — the runtime reads the annotation and reconstructs the model
    return {'charged': order.amount}

Design

Encoding (send side): InternalJSONEncoder.default() gets a new branch that detects objects implementing the Pydantic v2 model protocol (model_dump + model_validate) and emits a plain JSON object — no AUTO_SERIALIZED marker. The wire format stays interop-friendly with non-Python SDKs, and cross-app receivers that don't import the same class still see a readable payload.

Decoding (receive side): WorkflowRuntime.register_workflow, register_versioned_workflow, and register_activity inspect the user function's signature once at registration time. If the input parameter is annotated with a model-protocol class (including Optional[Model] / Model | None), the wrapper auto-coerces the incoming dict into a real instance before calling the user's function. No annotation → unchanged behavior (today's SimpleNamespace / dict path).

Why duck-typing on model_dump / model_validate, not isinstance(BaseModel):

  • No hard dependency on pydantic — the SDK never imports it. Users who don't use pydantic are unaffected.
  • Works with anything mirroring the protocol — Pydantic v2, SQLModel, custom classes, future libraries.
  • Pydantic v1 is naturally excluded — v1 uses .dict() / .parse_obj(), not model_dump / model_validate, so no version-check gymnastics.

dump_model inspects the target class's model_dump signature once (cached with lru_cache) and passes mode='json' when supported, falling back to bare model_dump() otherwise. Real Pydantic models always get mode='json' so nested datetimes / enums / UUIDs render as JSON-safe primitives.

What this PR deliberately does NOT do:

  • No wire-format marker or FQN-based class lookup. Security and cross-language portability win out over full automatic round-tripping of yielded results.
  • No return_type= kwarg on call_activity / call_child_workflow. Results arriving through yield come back as dicts; users write Model.model_validate(result) in one line when they want a typed instance. This avoids a new public API surface and a non-trivial task-proxy implementation in the workflow context.
  • No changes to the vendored _durabletask worker machinery beyond the single encoder branch. Future upstream pulls stay clean.

Changes

Source (3 files):

  • ext/dapr-ext-workflow/dapr/ext/workflow/_model_protocol.py — new. Duck-typed helpers: is_model, is_model_class, dump_model, coerce_to_model, resolve_input_model. ~130 lines.
  • ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py — one if branch in InternalJSONEncoder.default(), clearly marked # Dapr-specific. ~10 lines.
  • ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py — resolve the input model once per registration and coerce in each wrapper. ~9 lines across three sites.

Tests (3 files, all Pydantic-primary):

  • ext/dapr-ext-workflow/tests/test_model_protocol.py — new. Model-protocol helpers exercised with real Pydantic models; two small duck-typed tests cover the mode='json'-fallback path and partial-implementation rejection (behaviors Pydantic v2 can't exercise).
  • ext/dapr-ext-workflow/tests/test_workflow_runtime.pyPydanticInputCoercionTest covering activity + workflow + versioned-workflow paths, Optional[Model], passthrough of existing instances, pydantic ValidationError propagation on invalid payloads, and passthrough when no annotation is present.
  • ext/dapr-ext-workflow/tests/durabletask/test_serialization.py — encoder tests: plain-dict emission (no AUTO_SERIALIZED), nested list of models, and Pydantic + dataclass coexistence (dataclass still marked, Pydantic not).

Example (3 files):

  • examples/workflow/pydantic_models.py — new. End-to-end demo: schedule with a real OrderRequest, activity receives a typed instance, workflow reconstructs the activity result into a typed OrderResult, client calls OrderResult.model_validate_json(state.serialized_output) for a typed output.
  • examples/workflow/requirements.txt — added pydantic>=2.0.
  • examples/workflow/README.md — new "Pydantic models as workflow/activity inputs" section with a <!--STEP--> block so mechanical-markdown validates it in CI.

Dev dependencies: pydantic>=2.0.0 was already present in dev-requirements.txt; comment updated to note workflow tests now rely on it.

No changes to: setup.cfg (no runtime dep, no extras), async client (parity is automatic because the encoder and receive-side introspection live upstream of the sync/async split), public API exports, protobuf.

Test plan

  • python -m pytest ext/dapr-ext-workflow/tests/ — 79 passing, no regressions
  • ruff check + ruff format clean
  • Example imports, registers workflow and activity, no runtime errors
  • Run example end-to-end with a Dapr sidecar (dapr run --app-id wf-pydantic-example -- python3 pydantic_models.py) — please verify in CI / reviewer environment
  • Verify mechanical-markdown picks up the new STEP block under ./validate.sh workflow

Edge cases covered

  • Optional[Model] / Model | None annotations unwrap correctly.
  • User passes an already-constructed model instance → passed through, no redundant re-validation.
  • Invalid payload → Pydantic ValidationError surfaces cleanly through the activity error path with instance-id logging.
  • No annotation → unchanged behavior (SimpleNamespace / dict).
  • Primitive annotations (int, str, etc.) → passthrough, no coercion.
  • Dataclasses and namedtuples continue to use AUTO_SERIALIZED round-tripping via SimpleNamespace; only the new protocol branch is marker-free.
  • Duck-typed classes without a mode kwarg on model_dump use the bare-call fallback.
  • Cross-app activities (app_id='other-app') and cross-language callers are unaffected — the wire format is plain JSON with no SDK-specific markers.

Signed-off-by: Albert Callarisa <albert@diagrid.io>
@acroca acroca requested review from a team as code owners April 17, 2026 07:16
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 17, 2026

Codecov Report

❌ Patch coverage is 93.22034% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.32%. Comparing base (bffb749) to head (e77c2b1).
⚠️ Report is 103 commits behind head on main.

Files with missing lines Patch % Lines
...-ext-workflow/dapr/ext/workflow/_model_protocol.py 84.05% 11 Missing ⚠️
ext/dapr-ext-workflow/tests/test_model_protocol.py 91.48% 8 Missing ⚠️
...t/dapr-ext-workflow/tests/test_workflow_runtime.py 98.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #982      +/-   ##
==========================================
- Coverage   86.63%   81.32%   -5.31%     
==========================================
  Files          84      139      +55     
  Lines        4473    13475    +9002     
==========================================
+ Hits         3875    10958    +7083     
- Misses        598     2517    +1919     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds duck-typed support for passing Pydantic v2-style models (model_dump / model_validate) directly as workflow/activity inputs, keeping the wire format as plain JSON and reconstructing typed inputs based on user function annotations.

Changes:

  • Add _model_protocol helpers for detecting/dumping/coercing “model protocol” objects and resolving annotated input models (including Optional[...]).
  • Extend durabletask JSON encoding to serialize model-protocol instances as plain JSON (no AUTO_SERIALIZED marker).
  • Update WorkflowRuntime registration wrappers to coerce inbound payloads into annotated model instances; add tests + example + docs.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
ext/dapr-ext-workflow/dapr/ext/workflow/_model_protocol.py New helper module implementing duck-typed model detection, dumping, and input coercion based on annotations.
ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py Add JSON encoder branch to emit model-protocol instances as plain JSON without AUTO_SERIALIZED.
ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py Resolve annotated input model at registration time and coerce incoming activity/workflow inputs before invoking user code.
ext/dapr-ext-workflow/tests/test_model_protocol.py New unit tests covering model-protocol helpers (Pydantic + duck-typed cases).
ext/dapr-ext-workflow/tests/test_workflow_runtime.py New tests validating wrapper-level coercion for workflows/activities/versioned workflows and optional annotations.
ext/dapr-ext-workflow/tests/durabletask/test_serialization.py New serialization tests ensuring Pydantic models encode as plain dicts and coexist with dataclass marker behavior.
examples/workflow/pydantic_models.py New end-to-end example showing typed inputs and manual reconstruction of activity results/output.
examples/workflow/requirements.txt Add pydantic>=2.0 to example dependencies.
examples/workflow/README.md Document the new example with a mechanical-markdown STEP block.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py Outdated
Comment thread ext/dapr-ext-workflow/tests/test_model_protocol.py Outdated
Comment thread examples/workflow/pydantic_models.py
Comment thread examples/workflow/README.md Outdated
Comment thread ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py Outdated
Signed-off-by: Albert Callarisa <albert@diagrid.io>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py Outdated
Comment thread ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py Outdated
Comment thread ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py Outdated
Signed-off-by: Albert Callarisa <albert@diagrid.io>
if inp is None:
if not accepts_input:
return fn(daprWfContext)
if inp is not None and input_model is not None and not isinstance(inp, input_model):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I find it a bit hard to read chained conditions like these. Even if the priority is evident, I'd write this (and any other long conditions) as

if (inp is not None) and (input_model is not None) and not isinstance(inp, input_model):

Signed-off-by: Albert Callarisa <albert@diagrid.io>
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.

[WORKFLOW SDK FEATURE REQUEST] Support pydantic models

3 participants