python-durable 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {python_durable-0.2.1 → python_durable-0.2.3}/.github/workflows/publish.yml +3 -0
  2. {python_durable-0.2.1 → python_durable-0.2.3}/PKG-INFO +1 -1
  3. {python_durable-0.2.1 → python_durable-0.2.3}/pyproject.toml +1 -1
  4. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/pydantic_ai.py +8 -3
  5. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/redis_store.py +4 -0
  6. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/store.py +2 -0
  7. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/workflow.py +19 -2
  8. {python_durable-0.2.1 → python_durable-0.2.3}/tests/test_durable.py +125 -0
  9. {python_durable-0.2.1 → python_durable-0.2.3}/tests/test_pydantic_ai.py +70 -0
  10. {python_durable-0.2.1 → python_durable-0.2.3}/.gitignore +0 -0
  11. {python_durable-0.2.1 → python_durable-0.2.3}/LICENSE +0 -0
  12. {python_durable-0.2.1 → python_durable-0.2.3}/README.md +0 -0
  13. {python_durable-0.2.1 → python_durable-0.2.3}/examples/approval.py +0 -0
  14. {python_durable-0.2.1 → python_durable-0.2.3}/examples/examples.py +0 -0
  15. {python_durable-0.2.1 → python_durable-0.2.3}/examples/in_memory_example.py +0 -0
  16. {python_durable-0.2.1 → python_durable-0.2.3}/examples/pydantic_ai_example.py +0 -0
  17. {python_durable-0.2.1 → python_durable-0.2.3}/examples/redis_example.py +0 -0
  18. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/__init__.py +0 -0
  19. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/backoff.py +0 -0
  20. {python_durable-0.2.1 → python_durable-0.2.3}/src/durable/context.py +0 -0
  21. {python_durable-0.2.1 → python_durable-0.2.3}/tests/__init__.py +0 -0
  22. {python_durable-0.2.1 → python_durable-0.2.3}/tests/test_redis_store.py +0 -0
  23. {python_durable-0.2.1 → python_durable-0.2.3}/tests/test_signals.py +0 -0
@@ -4,6 +4,9 @@ on:
4
4
  release:
5
5
  types: [published]
6
6
 
7
+ env:
8
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
9
+
7
10
  jobs:
8
11
  publish:
9
12
  runs-on: ubuntu-latest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-durable
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Lightweight workflow durability for Python — make any async workflow resumable after crashes with just a decorator.
5
5
  Project-URL: Repository, https://github.com/WillemDeGroef/python-durable
6
6
  Author: Willem
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-durable"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Lightweight workflow durability for Python — make any async workflow resumable after crashes with just a decorator."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -221,7 +221,8 @@ class DurableAgent(Generic[AgentDepsT, OutputT]):
221
221
  step_id="agent-run",
222
222
  **kwargs,
223
223
  )
224
- return _AgentRunResult(result)
224
+ output_type = getattr(self.agent, "_output_type", None)
225
+ return _AgentRunResult(result, output_type=output_type)
225
226
 
226
227
  async def _do_model_request(
227
228
  self,
@@ -282,15 +283,19 @@ class _AgentRunResult:
282
283
  """Wrapper that holds an agent RunResult and handles both live and
283
284
  deserialized (dict) results transparently."""
284
285
 
285
- def __init__(self, result: Any) -> None:
286
+ def __init__(self, result: Any, output_type: type | None = None) -> None:
286
287
  self._result = result
288
+ self._output_type = output_type
287
289
 
288
290
  @property
289
291
  def output(self) -> Any:
290
292
  if hasattr(self._result, "output"):
291
293
  return self._result.output
292
294
  if isinstance(self._result, dict):
293
- return self._result.get("output")
295
+ raw = self._result.get("output")
296
+ if isinstance(raw, dict) and self._output_type and hasattr(self._output_type, "model_validate"):
297
+ return self._output_type.model_validate(raw)
298
+ return raw
294
299
  return self._result
295
300
 
296
301
  @property
@@ -18,6 +18,8 @@ _WRAP_KEY = "v"
18
18
 
19
19
 
20
20
  def _wrap(value: Any) -> str:
21
+ if hasattr(value, "model_dump"):
22
+ value = value.model_dump(mode="json")
21
23
  return json.dumps({_WRAP_KEY: value})
22
24
 
23
25
 
@@ -71,6 +73,8 @@ class RedisStore(Store):
71
73
  async def set_step(
72
74
  self, run_id: str, step_id: str, result: Any, attempt: int = 1
73
75
  ) -> None:
76
+ if hasattr(result, "model_dump"):
77
+ result = result.model_dump(mode="json")
74
78
  payload = json.dumps({"v": result, "attempt": attempt})
75
79
  key = _step_key(self._prefix, run_id, step_id)
76
80
  client = self._client()
@@ -23,6 +23,8 @@ _WRAP_KEY = "v"
23
23
 
24
24
 
25
25
  def _wrap(value: Any) -> str:
26
+ if hasattr(value, "model_dump"):
27
+ value = value.model_dump(mode="json")
26
28
  return json.dumps({_WRAP_KEY: value})
27
29
 
28
30
 
@@ -26,7 +26,7 @@ import functools
26
26
  import inspect
27
27
  import logging
28
28
  import re
29
- from typing import Any, Callable, ParamSpec, TypeVar, overload
29
+ from typing import Any, Callable, ParamSpec, TypeVar, get_type_hints, overload
30
30
 
31
31
  from .backoff import BackoffStrategy, exponential
32
32
  from .context import RunContext, _active_run
@@ -74,6 +74,12 @@ class _TaskWrapper:
74
74
  self._step_name = step_name
75
75
  self._retries = retries
76
76
  self._backoff = backoff
77
+ # Extract return type hint for Pydantic model rehydration on replay
78
+ try:
79
+ hints = get_type_hints(fn)
80
+ except Exception:
81
+ hints = {}
82
+ self._return_type = hints.get("return")
77
83
  # Preserve the original function's metadata for IDE / tooling support
78
84
  functools.update_wrapper(self, fn)
79
85
 
@@ -97,7 +103,7 @@ class _TaskWrapper:
97
103
  log.debug(
98
104
  "[durable] ↩ %s (step=%s) — replayed from store", self._step_name, sid
99
105
  )
100
- return cached
106
+ return self._rehydrate(cached)
101
107
 
102
108
  return await self._execute_with_retry(ctx, sid, args, kwargs)
103
109
 
@@ -137,6 +143,17 @@ class _TaskWrapper:
137
143
 
138
144
  raise last_exc # type: ignore[misc]
139
145
 
146
+ def _rehydrate(self, value: Any) -> Any:
147
+ """Re-inflate a cached dict into a Pydantic model if the return type hint says so."""
148
+ if (
149
+ isinstance(value, dict)
150
+ and self._return_type is not None
151
+ and isinstance(self._return_type, type)
152
+ and hasattr(self._return_type, "model_validate")
153
+ ):
154
+ return self._return_type.model_validate(value)
155
+ return value
156
+
140
157
  def __repr__(self) -> str:
141
158
  return f"<DurableTask '{self._step_name}' retries={self._retries}>"
142
159
 
@@ -240,3 +240,128 @@ async def test_task_reuse_across_workflows():
240
240
  assert call_log == []
241
241
 
242
242
  print(" ✓ Shared tasks are isolated per workflow run")
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # 7. Pydantic model serialization/deserialization in @wf.task
247
+ # ---------------------------------------------------------------------------
248
+
249
+ from pydantic import BaseModel
250
+
251
+
252
+ class UserModel(BaseModel):
253
+ id: int
254
+ name: str
255
+ email: str
256
+
257
+
258
+ async def test_pydantic_model_serializes_from_task():
259
+ """Pydantic model returned from @wf.task serializes without error on first run."""
260
+ with tempfile.TemporaryDirectory() as tmp:
261
+ wf = make_wf(tmp)
262
+
263
+ @wf.task
264
+ async def fetch_user() -> UserModel:
265
+ return UserModel(id=1, name="Alice", email="alice@example.com")
266
+
267
+ @wf.workflow(id="test-pydantic-serialize")
268
+ async def my_workflow() -> UserModel:
269
+ return await fetch_user()
270
+
271
+ result = await my_workflow()
272
+ assert isinstance(result, UserModel)
273
+ assert result.id == 1
274
+ assert result.name == "Alice"
275
+
276
+
277
+ async def test_pydantic_model_rehydrated_on_replay():
278
+ """Pydantic model is correctly rehydrated on replay (not returned as dict)."""
279
+ with tempfile.TemporaryDirectory() as tmp:
280
+ wf = make_wf(tmp)
281
+ call_log = []
282
+
283
+ @wf.task
284
+ async def fetch_user() -> UserModel:
285
+ call_log.append("fetch")
286
+ return UserModel(id=2, name="Bob", email="bob@example.com")
287
+
288
+ @wf.workflow(id="test-pydantic-replay")
289
+ async def my_workflow() -> UserModel:
290
+ return await fetch_user()
291
+
292
+ # First run
293
+ result = await my_workflow()
294
+ assert isinstance(result, UserModel)
295
+ assert call_log == ["fetch"]
296
+
297
+ # Second run — replayed from store
298
+ call_log.clear()
299
+ result = await my_workflow()
300
+ assert call_log == [], "Task was re-executed but should have been replayed!"
301
+ assert isinstance(result, UserModel), f"Expected UserModel, got {type(result)}"
302
+ assert result.id == 2
303
+ assert result.name == "Bob"
304
+
305
+
306
+ async def test_plain_types_still_work():
307
+ """Plain dict/string/int returns still work (no regression)."""
308
+ with tempfile.TemporaryDirectory() as tmp:
309
+ wf = make_wf(tmp)
310
+
311
+ @wf.task
312
+ async def return_dict() -> dict:
313
+ return {"key": "value"}
314
+
315
+ @wf.task
316
+ async def return_str() -> str:
317
+ return "hello"
318
+
319
+ @wf.task
320
+ async def return_int() -> int:
321
+ return 42
322
+
323
+ @wf.workflow(id="test-plain-types")
324
+ async def my_workflow():
325
+ d = await return_dict()
326
+ s = await return_str()
327
+ i = await return_int()
328
+ return d, s, i
329
+
330
+ d, s, i = await my_workflow()
331
+ assert d == {"key": "value"}
332
+ assert s == "hello"
333
+ assert i == 42
334
+
335
+ # Replay
336
+ d, s, i = await my_workflow()
337
+ assert d == {"key": "value"}
338
+ assert s == "hello"
339
+ assert i == 42
340
+
341
+
342
+ async def test_pydantic_without_return_type_hint():
343
+ """Task without return type hint still serializes Pydantic models (but no rehydration)."""
344
+ with tempfile.TemporaryDirectory() as tmp:
345
+ wf = make_wf(tmp)
346
+ call_log = []
347
+
348
+ @wf.task
349
+ async def fetch_user():
350
+ call_log.append("fetch")
351
+ return UserModel(id=3, name="Charlie", email="charlie@example.com")
352
+
353
+ @wf.workflow(id="test-pydantic-no-hint")
354
+ async def my_workflow():
355
+ return await fetch_user()
356
+
357
+ # First run — should serialize without error
358
+ result = await my_workflow()
359
+ assert result.id == 3
360
+ assert call_log == ["fetch"]
361
+
362
+ # Replay — no type hint, so it comes back as dict (no rehydration)
363
+ call_log.clear()
364
+ result = await my_workflow()
365
+ assert call_log == []
366
+ assert isinstance(result, dict), f"Expected dict without type hint, got {type(result)}"
367
+ assert result["id"] == 3
@@ -342,3 +342,73 @@ class TestIntegration:
342
342
  r2 = await durable.run("Test prompt", run_id="sqlite-1")
343
343
  assert r2.output == "serialized ok"
344
344
  assert agent.run.call_count == 1
345
+
346
+ async def test_pydantic_model_output_preserved_on_replay(self, wf):
347
+ """Pydantic model output should be re-hydrated on replay, not returned as dict."""
348
+ from pydantic import BaseModel
349
+
350
+ class CityInfo(BaseModel):
351
+ name: str
352
+ population: int
353
+
354
+ city = CityInfo(name="Paris", population=2_161_000)
355
+
356
+ agent, run_result = _mock_agent()
357
+ run_result.output = city
358
+ agent._output_type = CityInfo
359
+
360
+ durable = DurableAgent(agent, wf, name="city-agent")
361
+
362
+ r1 = await durable.run("Tell me about Paris", run_id="pydantic-1")
363
+ assert isinstance(r1.output, CityInfo)
364
+ assert r1.output.name == "Paris"
365
+ assert agent.run.call_count == 1
366
+
367
+ # Replay — should still return a CityInfo, not a dict
368
+ r2 = await durable.run("Tell me about Paris", run_id="pydantic-1")
369
+ assert isinstance(r2.output, CityInfo)
370
+ assert r2.output.name == "Paris"
371
+ assert r2.output.population == 2_161_000
372
+ assert agent.run.call_count == 1
373
+
374
+ async def test_plain_string_output_no_regression(self, wf):
375
+ """Plain string outputs should still work after the output_type change."""
376
+ agent, _ = _mock_agent(output="just a string")
377
+ agent._output_type = None
378
+
379
+ durable = DurableAgent(agent, wf, name="string-agent")
380
+
381
+ r1 = await durable.run("Say hello", run_id="string-1")
382
+ assert r1.output == "just a string"
383
+
384
+ r2 = await durable.run("Say hello", run_id="string-1")
385
+ assert r2.output == "just a string"
386
+
387
+
388
+ class TestAgentRunResultRehydration:
389
+ def test_rehydrate_dict_with_output_type(self):
390
+ from pydantic import BaseModel
391
+
392
+ class Item(BaseModel):
393
+ id: int
394
+ label: str
395
+
396
+ wrapper = _AgentRunResult({"output": {"id": 1, "label": "test"}}, output_type=Item)
397
+ result = wrapper.output
398
+ assert isinstance(result, Item)
399
+ assert result.id == 1
400
+ assert result.label == "test"
401
+
402
+ def test_no_rehydrate_without_output_type(self):
403
+ wrapper = _AgentRunResult({"output": {"id": 1, "label": "test"}})
404
+ result = wrapper.output
405
+ assert isinstance(result, dict)
406
+
407
+ def test_no_rehydrate_non_dict_output(self):
408
+ from pydantic import BaseModel
409
+
410
+ class Item(BaseModel):
411
+ id: int
412
+
413
+ wrapper = _AgentRunResult({"output": "plain string"}, output_type=Item)
414
+ assert wrapper.output == "plain string"
File without changes
File without changes