Skip to content

Commit 636af75

Browse files
authored
Pydantic converter sample (#44)
Fixes #25
1 parent 1196d28 commit 636af75

File tree

11 files changed

+331
-3
lines changed

11 files changed

+331
-3
lines changed

Diff for: .github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
with:
2626
python-version: ${{ matrix.python }}
2727
- run: python -m pip install --upgrade wheel poetry poethepoet
28-
- run: poetry install
28+
- run: poetry install --with pydantic
2929
- run: poe lint
3030
- run: poe test -s -o log_cli_level=DEBUG
3131
- run: poe test -s -o log_cli_level=DEBUG --workflow-environment time-skipping

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Some examples require extra dependencies. See each sample's directory for specif
5454
* [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity.
5555
* [encryption](encryption) - Apply end-to-end encryption for all input/output.
5656
* [open_telemetry](open_telemetry) - Trace workflows with OpenTelemetry.
57+
* [pydantic_converter](pydantic_converter) - Data converter for using Pydantic models.
5758
* [sentry](sentry) - Report errors to Sentry.
5859

5960
## Test

Diff for: poetry.lock

+54-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: pydantic_converter/README.md

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Pydantic Converter Sample
2+
3+
This sample shows how to create a custom Pydantic converter to properly serialize Pydantic models.
4+
5+
For this sample, the optional `pydantic` dependency group must be included. To include, run:
6+
7+
poetry install --with pydantic
8+
9+
To run, first see [README.md](../README.md) for prerequisites. Then, run the following from this directory to start the
10+
worker:
11+
12+
poetry run python worker.py
13+
14+
This will start the worker. Then, in another terminal, run the following to execute the workflow:
15+
16+
poetry run python starter.py
17+
18+
In the worker terminal, the workflow and its activity will log that it received the Pydantic models. In the starter
19+
terminal, the Pydantic models in the workflow result will be logged.
20+
21+
### Notes
22+
23+
This is the preferred way to use Pydantic models with Temporal Python SDK. The converter code is small and meant to
24+
embed into other projects.
25+
26+
This sample also demonstrates use of `datetime` inside of Pydantic models. Due to a known issue with the Temporal
27+
sandbox, this class is seen by Pydantic as `date` instead of `datetime` upon deserialization. This is due to a
28+
[known Python issue](https://github.com/python/cpython/issues/89010) where, when we proxy the `datetime` class in the
29+
sandbox to prevent non-deterministic calls like `now()`, `issubclass` fails for the proxy type causing Pydantic to think
30+
it's a `date` instead. In `worker.py`, we have shown a workaround of disabling restrictions on `datetime` which solves
31+
this issue but no longer protects against workflow developers making non-deterministic calls in that module.

Diff for: pydantic_converter/__init__.py

Whitespace-only changes.

Diff for: pydantic_converter/converter.py

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import json
2+
from typing import Any, Optional
3+
4+
from pydantic.json import pydantic_encoder
5+
from temporalio.api.common.v1 import Payload
6+
from temporalio.converter import (
7+
CompositePayloadConverter,
8+
DataConverter,
9+
DefaultPayloadConverter,
10+
JSONPlainPayloadConverter,
11+
)
12+
13+
14+
class PydanticJSONPayloadConverter(JSONPlainPayloadConverter):
15+
"""Pydantic JSON payload converter.
16+
17+
This extends the :py:class:`JSONPlainPayloadConverter` to override
18+
:py:meth:`to_payload` using the Pydantic encoder.
19+
"""
20+
21+
def to_payload(self, value: Any) -> Optional[Payload]:
22+
"""Convert all values with Pydantic encoder or fail.
23+
24+
Like the base class, we fail if we cannot convert. This payload
25+
converter is expected to be the last in the chain, so it can fail if
26+
unable to convert.
27+
"""
28+
# We let JSON conversion errors be thrown to caller
29+
return Payload(
30+
metadata={"encoding": self.encoding.encode()},
31+
data=json.dumps(
32+
value, separators=(",", ":"), sort_keys=True, default=pydantic_encoder
33+
).encode(),
34+
)
35+
36+
37+
class PydanticPayloadConverter(CompositePayloadConverter):
38+
"""Payload converter that replaces Temporal JSON conversion with Pydantic
39+
JSON conversion.
40+
"""
41+
42+
def __init__(self) -> None:
43+
super().__init__(
44+
*(
45+
c
46+
if not isinstance(c, JSONPlainPayloadConverter)
47+
else PydanticJSONPayloadConverter()
48+
for c in DefaultPayloadConverter.default_encoding_payload_converters
49+
)
50+
)
51+
52+
53+
pydantic_data_converter = DataConverter(
54+
payload_converter_class=PydanticPayloadConverter
55+
)
56+
"""Data converter using Pydantic JSON conversion."""

Diff for: pydantic_converter/starter.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import asyncio
2+
import logging
3+
from datetime import datetime
4+
from ipaddress import IPv4Address
5+
6+
from temporalio.client import Client
7+
8+
from pydantic_converter.converter import pydantic_data_converter
9+
from pydantic_converter.worker import MyPydanticModel, MyWorkflow
10+
11+
12+
async def main():
13+
logging.basicConfig(level=logging.INFO)
14+
# Connect client using the Pydantic converter
15+
client = await Client.connect(
16+
"localhost:7233", data_converter=pydantic_data_converter
17+
)
18+
19+
# Run workflow
20+
result = await client.execute_workflow(
21+
MyWorkflow.run,
22+
[
23+
MyPydanticModel(
24+
some_ip=IPv4Address("127.0.0.1"),
25+
some_date=datetime(2000, 1, 2, 3, 4, 5),
26+
),
27+
MyPydanticModel(
28+
some_ip=IPv4Address("127.0.0.2"),
29+
some_date=datetime(2001, 2, 3, 4, 5, 6),
30+
),
31+
],
32+
id=f"pydantic_converter-workflow-id",
33+
task_queue="pydantic_converter-task-queue",
34+
)
35+
logging.info("Got models from client: %s" % result)
36+
37+
38+
if __name__ == "__main__":
39+
asyncio.run(main())

Diff for: pydantic_converter/worker.py

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import asyncio
2+
import dataclasses
3+
import logging
4+
from datetime import datetime, timedelta
5+
from ipaddress import IPv4Address
6+
from typing import List
7+
8+
from temporalio import activity, workflow
9+
from temporalio.client import Client
10+
from temporalio.worker import Worker
11+
from temporalio.worker.workflow_sandbox import (
12+
SandboxedWorkflowRunner,
13+
SandboxRestrictions,
14+
)
15+
16+
# We always want to pass through external modules to the sandbox that we know
17+
# are safe for workflow use
18+
with workflow.unsafe.imports_passed_through():
19+
from pydantic import BaseModel
20+
21+
from pydantic_converter.converter import pydantic_data_converter
22+
23+
24+
class MyPydanticModel(BaseModel):
25+
some_ip: IPv4Address
26+
some_date: datetime
27+
28+
29+
@activity.defn
30+
async def my_activity(models: List[MyPydanticModel]) -> List[MyPydanticModel]:
31+
activity.logger.info("Got models in activity: %s" % models)
32+
return models
33+
34+
35+
@workflow.defn
36+
class MyWorkflow:
37+
@workflow.run
38+
async def run(self, models: List[MyPydanticModel]) -> List[MyPydanticModel]:
39+
workflow.logger.info("Got models in workflow: %s" % models)
40+
return await workflow.execute_activity(
41+
my_activity, models, start_to_close_timeout=timedelta(minutes=1)
42+
)
43+
44+
45+
# Due to known issues with Pydantic's use of issubclass and our inability to
46+
# override the check in sandbox, Pydantic will think datetime is actually date
47+
# in the sandbox. At the expense of protecting against datetime.now() use in
48+
# workflows, we're going to remove datetime module restrictions. See sdk-python
49+
# README's discussion of known sandbox issues for more details.
50+
def new_sandbox_runner() -> SandboxedWorkflowRunner:
51+
# TODO(cretz): Use with_child_unrestricted when https://github.com/temporalio/sdk-python/issues/254
52+
# is fixed and released
53+
invalid_module_member_children = dict(
54+
SandboxRestrictions.invalid_module_members_default.children
55+
)
56+
del invalid_module_member_children["datetime"]
57+
return SandboxedWorkflowRunner(
58+
restrictions=dataclasses.replace(
59+
SandboxRestrictions.default,
60+
invalid_module_members=dataclasses.replace(
61+
SandboxRestrictions.invalid_module_members_default,
62+
children=invalid_module_member_children,
63+
),
64+
)
65+
)
66+
67+
68+
interrupt_event = asyncio.Event()
69+
70+
71+
async def main():
72+
logging.basicConfig(level=logging.INFO)
73+
# Connect client using the Pydantic converter
74+
client = await Client.connect(
75+
"localhost:7233", data_converter=pydantic_data_converter
76+
)
77+
78+
# Run a worker for the workflow
79+
async with Worker(
80+
client,
81+
task_queue="pydantic_converter-task-queue",
82+
workflows=[MyWorkflow],
83+
activities=[my_activity],
84+
workflow_runner=new_sandbox_runner(),
85+
):
86+
# Wait until interrupted
87+
print("Worker started, ctrl+c to exit")
88+
await interrupt_event.wait()
89+
print("Shutting down")
90+
91+
92+
if __name__ == "__main__":
93+
loop = asyncio.new_event_loop()
94+
try:
95+
loop.run_until_complete(main())
96+
except KeyboardInterrupt:
97+
interrupt_event.set()
98+
loop.run_until_complete(loop.shutdown_asyncgens())

Diff for: pyproject.toml

+5-1
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,18 @@ optional = true
3939
temporalio = { version = "*", extras = ["opentelemetry"] }
4040
opentelemetry-exporter-jaeger-thrift = "^1.13.0"
4141

42+
[tool.poetry.group.pydantic]
43+
optional = true
44+
dependencies = { pydantic = "^1.10.4" }
45+
4246
[tool.poetry.group.sentry]
4347
optional = true
4448
dependencies = { sentry-sdk = "^1.11.0" }
4549

4650
[tool.poe.tasks]
4751
format = [{cmd = "black ."}, {cmd = "isort ."}]
4852
lint = [{cmd = "black --check ."}, {cmd = "isort --check-only ."}, {ref = "lint-types" }]
49-
lint-types = "mypy --check-untyped-defs ."
53+
lint-types = "mypy --check-untyped-defs --namespace-packages ."
5054
test = "pytest"
5155

5256
[build-system]

Diff for: tests/pydantic_converter/__init__.py

Whitespace-only changes.

Diff for: tests/pydantic_converter/workflow_test.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import uuid
2+
from datetime import datetime
3+
from ipaddress import IPv4Address
4+
5+
from temporalio.client import Client
6+
from temporalio.worker import Worker
7+
8+
from pydantic_converter.converter import pydantic_data_converter
9+
from pydantic_converter.worker import (
10+
MyPydanticModel,
11+
MyWorkflow,
12+
my_activity,
13+
new_sandbox_runner,
14+
)
15+
16+
17+
async def test_workflow_with_pydantic_model(client: Client):
18+
# Replace data converter in client
19+
new_config = client.config()
20+
new_config["data_converter"] = pydantic_data_converter
21+
client = Client(**new_config)
22+
task_queue_name = str(uuid.uuid4())
23+
24+
orig_models = [
25+
MyPydanticModel(
26+
some_ip=IPv4Address("127.0.0.1"), some_date=datetime(2000, 1, 2, 3, 4, 5)
27+
),
28+
MyPydanticModel(
29+
some_ip=IPv4Address("127.0.0.2"), some_date=datetime(2001, 2, 3, 4, 5, 6)
30+
),
31+
]
32+
33+
async with Worker(
34+
client,
35+
task_queue=task_queue_name,
36+
workflows=[MyWorkflow],
37+
activities=[my_activity],
38+
workflow_runner=new_sandbox_runner(),
39+
):
40+
result = await client.execute_workflow(
41+
MyWorkflow.run,
42+
orig_models,
43+
id=str(uuid.uuid4()),
44+
task_queue=task_queue_name,
45+
)
46+
assert orig_models == result

0 commit comments

Comments
 (0)