python-durable 0.2.0__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.
- {python_durable-0.2.0 → python_durable-0.2.3}/.github/workflows/publish.yml +3 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/PKG-INFO +1 -1
- {python_durable-0.2.0 → python_durable-0.2.3}/pyproject.toml +1 -1
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/pydantic_ai.py +23 -4
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/redis_store.py +4 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/store.py +2 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/workflow.py +19 -2
- {python_durable-0.2.0 → python_durable-0.2.3}/tests/test_durable.py +125 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/tests/test_pydantic_ai.py +86 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/.gitignore +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/LICENSE +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/README.md +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/examples/approval.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/examples/examples.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/examples/in_memory_example.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/examples/pydantic_ai_example.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/examples/redis_example.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/__init__.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/backoff.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/src/durable/context.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/tests/__init__.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/tests/test_redis_store.py +0 -0
- {python_durable-0.2.0 → python_durable-0.2.3}/tests/test_signals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-durable
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|