python-durable 0.2.0__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.0 → python_durable-0.2.2}/.github/workflows/publish.yml +3 -0
  2. {python_durable-0.2.0 → python_durable-0.2.2}/PKG-INFO +1 -1
  3. {python_durable-0.2.0 → python_durable-0.2.2}/pyproject.toml +1 -1
  4. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/pydantic_ai.py +23 -4
  5. {python_durable-0.2.0 → python_durable-0.2.2}/tests/test_pydantic_ai.py +86 -0
  6. {python_durable-0.2.0 → python_durable-0.2.2}/.gitignore +0 -0
  7. {python_durable-0.2.0 → python_durable-0.2.2}/LICENSE +0 -0
  8. {python_durable-0.2.0 → python_durable-0.2.2}/README.md +0 -0
  9. {python_durable-0.2.0 → python_durable-0.2.2}/examples/approval.py +0 -0
  10. {python_durable-0.2.0 → python_durable-0.2.2}/examples/examples.py +0 -0
  11. {python_durable-0.2.0 → python_durable-0.2.2}/examples/in_memory_example.py +0 -0
  12. {python_durable-0.2.0 → python_durable-0.2.2}/examples/pydantic_ai_example.py +0 -0
  13. {python_durable-0.2.0 → python_durable-0.2.2}/examples/redis_example.py +0 -0
  14. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/__init__.py +0 -0
  15. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/backoff.py +0 -0
  16. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/context.py +0 -0
  17. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/redis_store.py +0 -0
  18. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/store.py +0 -0
  19. {python_durable-0.2.0 → python_durable-0.2.2}/src/durable/workflow.py +0 -0
  20. {python_durable-0.2.0 → python_durable-0.2.2}/tests/__init__.py +0 -0
  21. {python_durable-0.2.0 → python_durable-0.2.2}/tests/test_durable.py +0 -0
  22. {python_durable-0.2.0 → python_durable-0.2.2}/tests/test_redis_store.py +0 -0
  23. {python_durable-0.2.0 → 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.0
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.0"
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"
@@ -69,6 +69,20 @@ def _deserialize_messages(data: list[dict]) -> list[Any]:
69
69
  return result
70
70
 
71
71
 
72
+ def _serialize_run_result(result: Any) -> dict:
73
+ """Convert an agent RunResult to a JSON-serializable dict."""
74
+ output = result.output
75
+ # If output is a pydantic model, convert to dict
76
+ if hasattr(output, "model_dump"):
77
+ output = output.model_dump(mode="json")
78
+ data: dict[str, Any] = {"output": output}
79
+ try:
80
+ data["all_messages"] = _serialize_messages(result.all_messages())
81
+ except Exception:
82
+ data["all_messages"] = []
83
+ return data
84
+
85
+
72
86
  def _run_id_for_agent(agent_name: str, prompt: str, run_id: str | None) -> str:
73
87
  """Generate a deterministic run ID from the agent name and prompt."""
74
88
  if run_id:
@@ -207,7 +221,8 @@ class DurableAgent(Generic[AgentDepsT, OutputT]):
207
221
  step_id="agent-run",
208
222
  **kwargs,
209
223
  )
210
- return result
224
+ output_type = getattr(self.agent, "_output_type", None)
225
+ return _AgentRunResult(result, output_type=output_type)
211
226
 
212
227
  async def _do_model_request(
213
228
  self,
@@ -225,7 +240,7 @@ class DurableAgent(Generic[AgentDepsT, OutputT]):
225
240
  run_kwargs.update(kwargs)
226
241
 
227
242
  result = await self.agent.run(prompt, **run_kwargs)
228
- return _AgentRunResult(result)
243
+ return _serialize_run_result(result)
229
244
 
230
245
  async def _do_tool_call(
231
246
  self,
@@ -268,15 +283,19 @@ class _AgentRunResult:
268
283
  """Wrapper that holds an agent RunResult and handles both live and
269
284
  deserialized (dict) results transparently."""
270
285
 
271
- def __init__(self, result: Any) -> None:
286
+ def __init__(self, result: Any, output_type: type | None = None) -> None:
272
287
  self._result = result
288
+ self._output_type = output_type
273
289
 
274
290
  @property
275
291
  def output(self) -> Any:
276
292
  if hasattr(self._result, "output"):
277
293
  return self._result.output
278
294
  if isinstance(self._result, dict):
279
- 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
280
299
  return self._result
281
300
 
282
301
  @property
@@ -326,3 +326,89 @@ class TestIntegration:
326
326
  r2 = await durable.run("What is 6*7?", run_id="calc-1")
327
327
  assert r2.output == "42"
328
328
  assert agent.run.call_count == 1
329
+
330
+ async def test_durable_agent_with_sqlite_store(self, tmp_path):
331
+ """Ensure agent results are JSON-serializable for SQLiteStore."""
332
+ db_path = str(tmp_path / "test.db")
333
+ wf = Workflow("test-sqlite", db=db_path)
334
+
335
+ agent, _ = _mock_agent(output="serialized ok")
336
+ durable = DurableAgent(agent, wf, name="sqlite-agent")
337
+
338
+ r1 = await durable.run("Test prompt", run_id="sqlite-1")
339
+ assert r1.output == "serialized ok"
340
+ assert agent.run.call_count == 1
341
+
342
+ r2 = await durable.run("Test prompt", run_id="sqlite-1")
343
+ assert r2.output == "serialized ok"
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