Skip to content

Commit 3bd017d

Browse files
authored
Update pydantic samples to use SDK contrib module (#163)
1 parent 81b5098 commit 3bd017d

File tree

16 files changed

+1787
-1501
lines changed

16 files changed

+1787
-1501
lines changed

.github/workflows/ci.yml

+11-3
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,19 @@ jobs:
3232
# Using fixed Poetry version until
3333
# https://github.com/python-poetry/poetry/pull/7694 is fixed
3434
- run: python -m pip install --upgrade wheel "poetry==1.4.0" poethepoet
35-
- run: poetry install --with pydantic --with dsl --with encryption --with trio_async
35+
- run: poetry install --with pydantic_converter --with dsl --with encryption --with trio_async
3636
- run: poe lint
3737
- run: mkdir junit-xml
38-
- run: poe test -s -o log_cli_level=DEBUG --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}.xml
39-
- run: poe test -s -o log_cli_level=DEBUG --workflow-environment time-skipping --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}--time-skipping.xml
38+
- run: poe test -s --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}.xml
39+
- run: poe test -s --workflow-environment time-skipping --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}--time-skipping.xml
40+
# This must remain the last step since it downgrades pydantic
41+
- name: Uninstall pydantic
42+
shell: bash
43+
run: |
44+
echo y | poetry run pip uninstall pydantic
45+
echo y | poetry run pip uninstall pydantic-core
46+
poetry run pip install pydantic==1.10
47+
poe test -s --junit-xml=junit-xml/${{ matrix.python }}--${{ matrix.os }}--pydantic-v1.xml tests/pydantic_converter_v1/workflow_test.py
4048
4149
# On latest, run gevent test
4250
- name: Gevent test

poetry.lock

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

pydantic_converter/README.md

+3-15
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Pydantic Converter Sample
22

3-
This sample shows how to create a custom Pydantic converter to properly serialize Pydantic models.
3+
This sample shows how to use the Pydantic data converter.
44

5-
For this sample, the optional `pydantic` dependency group must be included. To include, run:
5+
For this sample, the optional `pydantic_converter` dependency group must be included. To include, run:
66

7-
poetry install --with pydantic
7+
poetry install --with pydantic_converter
88

99
To run, first see [README.md](../README.md) for prerequisites. Then, run the following from this directory to start the
1010
worker:
@@ -17,15 +17,3 @@ This will start the worker. Then, in another terminal, run the following to exec
1717

1818
In the worker terminal, the workflow and its activity will log that it received the Pydantic models. In the starter
1919
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.

pydantic_converter/starter.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from ipaddress import IPv4Address
55

66
from temporalio.client import Client
7+
from temporalio.contrib.pydantic import pydantic_data_converter
78

8-
from pydantic_converter.converter import pydantic_data_converter
99
from pydantic_converter.worker import MyPydanticModel, MyWorkflow
1010

1111

@@ -29,7 +29,7 @@ async def main():
2929
some_date=datetime(2001, 2, 3, 4, 5, 6),
3030
),
3131
],
32-
id=f"pydantic_converter-workflow-id",
32+
id="pydantic_converter-workflow-id",
3333
task_queue="pydantic_converter-task-queue",
3434
)
3535
logging.info("Got models from client: %s" % result)

pydantic_converter/worker.py

+3-33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import asyncio
2-
import dataclasses
32
import logging
43
from datetime import datetime, timedelta
54
from ipaddress import IPv4Address
@@ -8,17 +7,12 @@
87
from temporalio import activity, workflow
98
from temporalio.client import Client
109
from temporalio.worker import Worker
11-
from temporalio.worker.workflow_sandbox import (
12-
SandboxedWorkflowRunner,
13-
SandboxRestrictions,
14-
)
1510

16-
# We always want to pass through external modules to the sandbox that we know
17-
# are safe for workflow use
11+
# Always pass through external modules to the sandbox that you know are safe for
12+
# workflow use
1813
with workflow.unsafe.imports_passed_through():
1914
from pydantic import BaseModel
20-
21-
from pydantic_converter.converter import pydantic_data_converter
15+
from temporalio.contrib.pydantic import pydantic_data_converter
2216

2317

2418
class MyPydanticModel(BaseModel):
@@ -42,29 +36,6 @@ async def run(self, models: List[MyPydanticModel]) -> List[MyPydanticModel]:
4236
)
4337

4438

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-
6839
interrupt_event = asyncio.Event()
6940

7041

@@ -81,7 +52,6 @@ async def main():
8152
task_queue="pydantic_converter-task-queue",
8253
workflows=[MyWorkflow],
8354
activities=[my_activity],
84-
workflow_runner=new_sandbox_runner(),
8555
):
8656
# Wait until interrupted
8757
print("Worker started, ctrl+c to exit")

pydantic_converter_v1/README.md

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Pydantic v1 Converter Sample
2+
3+
**This sample shows how to use Pydantic v1 with Temporal. This is not recommended: use Pydantic v2 if possible, and use the
4+
main [pydantic_converter](../pydantic_converter/README.md) sample.**
5+
6+
To install, run:
7+
8+
poetry install --with pydantic_converter
9+
poetry run pip uninstall pydantic pydantic-core
10+
poetry run pip install pydantic==1.10
11+
12+
To run, first see the root [README.md](../README.md) for prerequisites. Then, run the following from this directory to start the
13+
worker:
14+
15+
poetry run python worker.py
16+
17+
This will start the worker. Then, in another terminal, run the following to execute the workflow:
18+
19+
poetry run python starter.py
20+
21+
In the worker terminal, the workflow and its activity will log that it received the Pydantic models. In the starter
22+
terminal, the Pydantic models in the workflow result will be logged.
23+
24+
### Notes
25+
26+
This sample also demonstrates use of `datetime` inside of Pydantic v1 models. Due to a known issue with the Temporal
27+
sandbox, this class is seen by Pydantic v1 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 v1 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.

pydantic_converter_v1/__init__.py

Whitespace-only changes.
File renamed without changes.

pydantic_converter_v1/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_v1.converter import pydantic_data_converter
9+
from pydantic_converter_v1.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="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())

pydantic_converter_v1/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_v1.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())

pyproject.toml

+6-4
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ packages = [
1717

1818
[tool.poetry.dependencies]
1919
python = "^3.9"
20-
temporalio = "^1.9.0"
20+
temporalio = "^1.10.0"
2121

2222
[tool.poetry.dev-dependencies]
2323
black = "^22.3.0"
2424
isort = "^5.10.1"
25-
mypy = "^0.981"
25+
mypy = "^1.4.1"
2626
pytest = "^7.1.2"
2727
pytest-asyncio = "^0.18.3"
2828
frozenlist = "^1.4.0"
29+
types-pyyaml = "^6.0.12.20241230"
30+
2931

3032
# All sample-specific dependencies are in optional groups below, named after the
3133
# sample they apply to
@@ -63,9 +65,9 @@ optional = true
6365
temporalio = { version = "*", extras = ["opentelemetry"] }
6466
opentelemetry-exporter-otlp-proto-grpc = "1.18.0"
6567

66-
[tool.poetry.group.pydantic]
68+
[tool.poetry.group.pydantic_converter]
6769
optional = true
68-
dependencies = { pydantic = "^1.10.4" }
70+
dependencies = { pydantic = "^2.10.6" }
6971

7072
[tool.poetry.group.sentry]
7173
optional = true

sentry/interceptor.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ async def execute_activity(self, input: ExecuteActivityInput) -> Any:
3737
try:
3838
return await super().execute_activity(input)
3939
except Exception as e:
40-
if len(input.args) == 1 and is_dataclass(input.args[0]):
41-
set_context("temporal.activity.input", asdict(input.args[0]))
40+
if len(input.args) == 1:
41+
[arg] = input.args
42+
if is_dataclass(arg) and not isinstance(arg, type):
43+
set_context("temporal.activity.input", asdict(arg))
4244
set_context("temporal.activity.info", activity.info().__dict__)
4345
capture_exception()
4446
raise e
@@ -58,8 +60,10 @@ async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any:
5860
try:
5961
return await super().execute_workflow(input)
6062
except Exception as e:
61-
if len(input.args) == 1 and is_dataclass(input.args[0]):
62-
set_context("temporal.workflow.input", asdict(input.args[0]))
63+
if len(input.args) == 1:
64+
[arg] = input.args
65+
if is_dataclass(arg) and not isinstance(arg, type):
66+
set_context("temporal.workflow.input", asdict(arg))
6367
set_context("temporal.workflow.info", workflow.info().__dict__)
6468

6569
if not workflow.unsafe.is_replaying():

tests/message_passing/safe_message_handlers/workflow_test.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ async def test_safe_message_handlers(client: Client, env: WorkflowEnvironment):
7979

8080
await cluster_manager_handle.signal(ClusterManagerWorkflow.shutdown_cluster)
8181

82-
result = await cluster_manager_handle.result()
83-
assert result.num_currently_assigned_nodes == 0
82+
cluster_manager_result = await cluster_manager_handle.result()
83+
assert cluster_manager_result.num_currently_assigned_nodes == 0
8484

8585

8686
async def test_update_idempotency(client: Client, env: WorkflowEnvironment):

0 commit comments

Comments
 (0)