Skip to content

Commit deca7c7

Browse files
authored
Improve Pydantic model usage (#744)
Allows users to use a Pydantic model as both input and output. No need to provide plain dict.
1 parent b061299 commit deca7c7

5 files changed

Lines changed: 100 additions & 27 deletions

File tree

docs/gallery/howto/autogen/annotate_inputs_outputs.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,27 @@ def DynamicNestedDictGenerator(n: int):
255255
from aiida_workgraph.socket_spec import Leaf
256256

257257

258+
class InputsModel(BaseModel):
259+
x: int
260+
y: int
261+
262+
258263
class OutputsModel(BaseModel):
259264
sum: int
260265
product: int
261266

262267

263268
@task
264-
def add_multiply_pydantic_in_out(x, y) -> OutputsModel:
265-
return {'sum': x + y, 'product': x * y}
269+
def add_multiply_pydantic_in_out(data: InputsModel) -> OutputsModel:
270+
return OutputsModel(sum=data.x + data.y, product=data.x * data.y)
266271

267272

268273
@task.graph
269-
def AddMultiplyPydantic():
270-
# IMPORTANT: pass a plain dict, not OutputsModel(x=3, y=4)
271-
add_multiply_pydantic_in_out(x=3, y=4)
274+
def AddMultiplyPydantic(data: InputsModel) -> OutputsModel:
275+
return add_multiply_pydantic_in_out(data=data)
272276

273277

274-
wg = AddMultiplyPydantic.build()
278+
wg = AddMultiplyPydantic.build(data=InputsModel(x=3, y=4))
275279
wg.run()
276280
wg.generate_provenance_graph()
277281

@@ -292,7 +296,9 @@ class DynamicOut(BaseModel):
292296
@task
293297
def make_dynamic_with_model(n: int) -> DynamicOut:
294298
# fixed field + dynamic keys with int values
295-
return {'header': 100, **{f'k{i}': i * i for i in range(n)}}
299+
payload = {'header': 100}
300+
payload.update({f'k{i}': i * i for i in range(n)})
301+
return DynamicOut(**payload)
296302

297303

298304
@task.graph
@@ -309,6 +315,12 @@ def GraphDynamicOut(n: int):
309315
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
310316
#
311317
# Sometimes you want to **validate** with a Pydantic model but store it as a **single node** instead of expanding fields.
318+
# For leaf models, WorkGraph treats the value as a blob. If a serializer is registered for the class, it is used;
319+
# otherwise ``JsonableData`` stores the model as a JSON-friendly dict.
320+
#
321+
# Note: Pydantic annotations define the *schema* for WorkGraph sockets. Outputs are stored as typed
322+
# AiiDA nodes per field, so runtime results are dicts of nodes (not Pydantic instances). Use those
323+
# nodes for linking/provenance; rebuild a Pydantic model only for convenience.
312324
# There are two ways:
313325
#
314326
# 1) Mark the model: ``model_config = {"leaf": True}``
@@ -325,7 +337,7 @@ class BlobModel(BaseModel):
325337
@task
326338
def consume_blob(m: BlobModel) -> dict:
327339
# 'm' is validated by Pydantic but stored/treated as one leaf node
328-
return {'sum': m['a'] + m['b']}
340+
return {'sum': m.a + m.b}
329341

330342

331343
# Per-use override without modifying the model:
@@ -336,13 +348,13 @@ class AnotherModel(BaseModel):
336348

337349
@task
338350
def consume_blob_per_use(m: Leaf[AnotherModel]) -> dict:
339-
return {'sum': m['a'] + m['b']}
351+
return {'sum': m.a + m.b}
340352

341353

342354
@task.graph
343355
def BlobExamples():
344-
consume_blob(m={'a': 1, 'b': 2})
345-
consume_blob_per_use(m={'a': 3, 'b': 4})
356+
consume_blob(m=BlobModel(a=1, b=2))
357+
consume_blob_per_use(m=AnotherModel(a=3, b=4))
346358

347359

348360
wg = BlobExamples.build()
@@ -402,12 +414,14 @@ def AddMultiplyDataclass():
402414
# %%
403415
# .. important::
404416
#
405-
# Models/dataclasses are annotation-only
406-
# Even when you annotate with BaseModel or @dataclass, do not pass instances of these types to tasks/graphs. Always pass plain dictionaries:
417+
# Structured models (Pydantic or dataclasses) are supported as *runtime* values.
418+
# You may pass instances to tasks/graphs and return them from tasks:
407419
#
408-
# - This lets WorkGraph expand inputs/outputs into individual sockets, so it can wire provenance edges precisely (e.g., data.x --> task.data.x).
409-
# - It allows graph inputs to be collected from task outputs as a dict of AiiDA ORM nodes, preserving AiiDA links between nodes.
410-
# - Validation still happens via the WorkGraph spec (derived from your annotations)--you’re just not constructing runtime model/dataclass objects.
420+
# - Instances are expanded to plain dicts when assigned to namespace sockets, so WorkGraph can
421+
# wire provenance edges precisely (e.g., data.x --> task.data.x).
422+
# - Graph inputs can still be collected from task outputs as a dict of AiiDA ORM nodes,
423+
# preserving AiiDA links between nodes.
424+
# - Validation still happens via the WorkGraph spec (derived from your annotations).
411425
#
412426
# Data linkage
413427
# ------------

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ classifiers = [
1919
dependencies = [
2020
"numpy",
2121
"scipy",
22-
"node-graph~=0.6.0",
22+
"node-graph~=0.6.1",
2323
"node-graph-widget>=0.0.5",
2424
"aiida-core~=2.7.1",
2525
"cloudpickle",
2626
"aiida-shell~=0.8",
27-
"aiida-pythonjob~=0.5.0",
27+
"aiida-pythonjob~=0.5.1",
2828
"jsonschema"
2929
]
3030
description = "Design flexible node-based workflow for AiiDA calculation."

src/aiida_workgraph/tasks/tests.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
from aiida_workgraph import task, Task
22
from aiida.calculations.arithmetic.add import ArithmeticAddCalculation
3+
from pydantic import BaseModel
4+
5+
6+
class BlobModel(BaseModel):
7+
model_config = {'leaf': True} # always a leaf blob
8+
9+
a: int
10+
b: int
11+
12+
13+
class AnotherModel(BaseModel):
14+
a: int
15+
b: int
316

417

518
@task

tests/test_pydantic.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import pytest
2+
3+
from aiida_workgraph import task
4+
5+
try:
6+
from pydantic import BaseModel as _BaseModel
7+
except Exception:
8+
_BaseModel = None
9+
10+
if _BaseModel is not None:
11+
12+
class PydanticInputs(_BaseModel):
13+
x: int
14+
y: int
15+
16+
class PydanticOutputs(_BaseModel):
17+
sum: int
18+
product: int
19+
20+
else:
21+
PydanticInputs = None
22+
PydanticOutputs = None
23+
24+
25+
if _BaseModel is not None:
26+
27+
@task
28+
def add_multiply_pydantic(data: 'PydanticInputs') -> 'PydanticOutputs':
29+
return PydanticOutputs(sum=data.x + data.y, product=data.x * data.y)
30+
else:
31+
add_multiply_pydantic = None
32+
33+
34+
def test_workgraph_pydantic_inputs_outputs():
35+
pytest.importorskip('pydantic')
36+
37+
@task.graph
38+
def add_graph(data: 'PydanticInputs') -> 'PydanticOutputs':
39+
return add_multiply_pydantic(data=data)
40+
41+
result, wg = add_graph.run_get_graph(data=PydanticInputs(x=2, y=3))
42+
43+
assert result['sum'] == 5
44+
assert result['product'] == 6
45+
assert wg.outputs.sum.value == 5
46+
assert wg.outputs.product.value == 6

uv.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)