python-durable 0.2.1__tar.gz → 0.2.2__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.

Potentially problematic release.


This version of python-durable might be problematic. Click here for more details.

Files changed (23) hide show
  1. {python_durable-0.2.1 → python_durable-0.2.2}/.github/workflows/publish.yml +3 -0
  2. {python_durable-0.2.1 → python_durable-0.2.2}/PKG-INFO +1 -1
  3. {python_durable-0.2.1 → python_durable-0.2.2}/pyproject.toml +1 -1
  4. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/pydantic_ai.py +8 -3
  5. {python_durable-0.2.1 → python_durable-0.2.2}/tests/test_pydantic_ai.py +70 -0
  6. {python_durable-0.2.1 → python_durable-0.2.2}/.gitignore +0 -0
  7. {python_durable-0.2.1 → python_durable-0.2.2}/LICENSE +0 -0
  8. {python_durable-0.2.1 → python_durable-0.2.2}/README.md +0 -0
  9. {python_durable-0.2.1 → python_durable-0.2.2}/examples/approval.py +0 -0
  10. {python_durable-0.2.1 → python_durable-0.2.2}/examples/examples.py +0 -0
  11. {python_durable-0.2.1 → python_durable-0.2.2}/examples/in_memory_example.py +0 -0
  12. {python_durable-0.2.1 → python_durable-0.2.2}/examples/pydantic_ai_example.py +0 -0
  13. {python_durable-0.2.1 → python_durable-0.2.2}/examples/redis_example.py +0 -0
  14. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/__init__.py +0 -0
  15. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/backoff.py +0 -0
  16. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/context.py +0 -0
  17. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/redis_store.py +0 -0
  18. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/store.py +0 -0
  19. {python_durable-0.2.1 → python_durable-0.2.2}/src/durable/workflow.py +0 -0
  20. {python_durable-0.2.1 → python_durable-0.2.2}/tests/__init__.py +0 -0
  21. {python_durable-0.2.1 → python_durable-0.2.2}/tests/test_durable.py +0 -0
  22. {python_durable-0.2.1 → python_durable-0.2.2}/tests/test_redis_store.py +0 -0
  23. {python_durable-0.2.1 → python_durable-0.2.2}/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.2
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.2"
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
@@ -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