Conversation
Signed-off-by: Albert Callarisa <albert@diagrid.io>
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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_protocolhelpers for detecting/dumping/coercing “model protocol” objects and resolving annotated input models (includingOptional[...]). - Extend durabletask JSON encoding to serialize model-protocol instances as plain JSON (no
AUTO_SERIALIZEDmarker). - Update
WorkflowRuntimeregistration 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.
Signed-off-by: Albert Callarisa <albert@diagrid.io>
There was a problem hiding this comment.
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.
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): |
There was a problem hiding this comment.
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>
Summary
Closes #858
Users can now pass Pydantic
BaseModelinstances directly toschedule_new_workflow,call_activity,call_child_workflow, andraise_workflow_event, and receive real model instances on the other side just by annotating their workflow/activity input parameter — no manualmodel_dump/model_validatecalls required.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 — noAUTO_SERIALIZEDmarker. 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, andregister_activityinspect the user function's signature once at registration time. If the input parameter is annotated with a model-protocol class (includingOptional[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'sSimpleNamespace/ dict path).Why duck-typing on
model_dump/model_validate, notisinstance(BaseModel):.dict()/.parse_obj(), notmodel_dump/model_validate, so no version-check gymnastics.dump_modelinspects the target class'smodel_dumpsignature once (cached withlru_cache) and passesmode='json'when supported, falling back to baremodel_dump()otherwise. Real Pydantic models always getmode='json'so nested datetimes / enums / UUIDs render as JSON-safe primitives.What this PR deliberately does NOT do:
return_type=kwarg oncall_activity/call_child_workflow. Results arriving throughyieldcome back as dicts; users writeModel.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._durabletaskworker 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— oneifbranch inInternalJSONEncoder.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 themode='json'-fallback path and partial-implementation rejection (behaviors Pydantic v2 can't exercise).ext/dapr-ext-workflow/tests/test_workflow_runtime.py—PydanticInputCoercionTestcovering activity + workflow + versioned-workflow paths,Optional[Model], passthrough of existing instances, pydanticValidationErrorpropagation on invalid payloads, and passthrough when no annotation is present.ext/dapr-ext-workflow/tests/durabletask/test_serialization.py— encoder tests: plain-dict emission (noAUTO_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 realOrderRequest, activity receives a typed instance, workflow reconstructs the activity result into a typedOrderResult, client callsOrderResult.model_validate_json(state.serialized_output)for a typed output.examples/workflow/requirements.txt— addedpydantic>=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.0was already present indev-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 regressionsruff check+ruff formatcleandapr run --app-id wf-pydantic-example -- python3 pydantic_models.py) — please verify in CI / reviewer environment./validate.sh workflowEdge cases covered
Optional[Model]/Model | Noneannotations unwrap correctly.ValidationErrorsurfaces cleanly through the activity error path with instance-id logging.SimpleNamespace/ dict).int,str, etc.) → passthrough, no coercion.AUTO_SERIALIZEDround-tripping viaSimpleNamespace; only the new protocol branch is marker-free.modekwarg onmodel_dumpuse the bare-call fallback.app_id='other-app') and cross-language callers are unaffected — the wire format is plain JSON with no SDK-specific markers.