horsies 0.1.0a1__py3-none-any.whl
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.
- horsies/__init__.py +115 -0
- horsies/core/__init__.py +0 -0
- horsies/core/app.py +552 -0
- horsies/core/banner.py +144 -0
- horsies/core/brokers/__init__.py +5 -0
- horsies/core/brokers/listener.py +444 -0
- horsies/core/brokers/postgres.py +864 -0
- horsies/core/cli.py +624 -0
- horsies/core/codec/serde.py +575 -0
- horsies/core/errors.py +535 -0
- horsies/core/logging.py +90 -0
- horsies/core/models/__init__.py +0 -0
- horsies/core/models/app.py +268 -0
- horsies/core/models/broker.py +79 -0
- horsies/core/models/queues.py +23 -0
- horsies/core/models/recovery.py +101 -0
- horsies/core/models/schedule.py +229 -0
- horsies/core/models/task_pg.py +307 -0
- horsies/core/models/tasks.py +332 -0
- horsies/core/models/workflow.py +1988 -0
- horsies/core/models/workflow_pg.py +245 -0
- horsies/core/registry/tasks.py +101 -0
- horsies/core/scheduler/__init__.py +26 -0
- horsies/core/scheduler/calculator.py +267 -0
- horsies/core/scheduler/service.py +569 -0
- horsies/core/scheduler/state.py +260 -0
- horsies/core/task_decorator.py +615 -0
- horsies/core/types/status.py +38 -0
- horsies/core/utils/imports.py +203 -0
- horsies/core/utils/loop_runner.py +44 -0
- horsies/core/worker/current.py +17 -0
- horsies/core/worker/worker.py +1967 -0
- horsies/core/workflows/__init__.py +23 -0
- horsies/core/workflows/engine.py +2344 -0
- horsies/core/workflows/recovery.py +501 -0
- horsies/core/workflows/registry.py +97 -0
- horsies/py.typed +0 -0
- horsies-0.1.0a1.dist-info/METADATA +31 -0
- horsies-0.1.0a1.dist-info/RECORD +42 -0
- horsies-0.1.0a1.dist-info/WHEEL +5 -0
- horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
- horsies-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
# app/core/codec/serde.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
Dict,
|
|
6
|
+
List,
|
|
7
|
+
Optional,
|
|
8
|
+
Type,
|
|
9
|
+
Union,
|
|
10
|
+
Mapping,
|
|
11
|
+
Sequence,
|
|
12
|
+
TypeGuard,
|
|
13
|
+
cast,
|
|
14
|
+
)
|
|
15
|
+
import json
|
|
16
|
+
import traceback as tb
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
import dataclasses
|
|
19
|
+
from horsies.core.models.tasks import (
|
|
20
|
+
TaskOptions,
|
|
21
|
+
TaskResult,
|
|
22
|
+
TaskError,
|
|
23
|
+
LibraryErrorCode,
|
|
24
|
+
)
|
|
25
|
+
from importlib import import_module
|
|
26
|
+
from horsies.core.logging import get_logger
|
|
27
|
+
|
|
28
|
+
logger = get_logger('serde')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
Json = Union[None, bool, int, float, str, List['Json'], Dict[str, 'Json']]
|
|
32
|
+
"""
|
|
33
|
+
Union type for JSON-serializable values.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SerializationError(Exception):
|
|
38
|
+
"""
|
|
39
|
+
Raised when a value cannot be serialized to JSON.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_json_native(x: object) -> TypeGuard[Json]:
|
|
46
|
+
"""
|
|
47
|
+
Check if a value is a JSON-native type (by our stricter definition: dict keys must be str).
|
|
48
|
+
"""
|
|
49
|
+
if x is None or isinstance(x, (bool, int, float, str)):
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
if isinstance(x, list):
|
|
53
|
+
items = cast(List[object], x)
|
|
54
|
+
return all(_is_json_native(item) for item in items)
|
|
55
|
+
|
|
56
|
+
if isinstance(x, dict):
|
|
57
|
+
_dict = cast(Dict[object, object], x)
|
|
58
|
+
for key, value in _dict.items():
|
|
59
|
+
if not isinstance(key, str) or not _is_json_native(value):
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _exception_to_json(ex: BaseException) -> Dict[str, Json]:
|
|
67
|
+
"""
|
|
68
|
+
Convert a BaseException to a JSON-serializable dictionary.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A dict with following key-value pairs:
|
|
72
|
+
- "type": str
|
|
73
|
+
- "message": str
|
|
74
|
+
- "traceback": str
|
|
75
|
+
"""
|
|
76
|
+
return {
|
|
77
|
+
'type': type(ex).__name__,
|
|
78
|
+
'message': str(ex),
|
|
79
|
+
'traceback': ''.join(tb.format_exception(type(ex), ex, ex.__traceback__)),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _task_error_to_json(err: TaskError) -> Dict[str, Json]:
|
|
84
|
+
"""
|
|
85
|
+
Convert a `TaskError` BaseModel to a JSON-serializable dictionary.
|
|
86
|
+
After converting to JSON, if the `exception` field is a `BaseException`,
|
|
87
|
+
it will be converted to a JSON-serializable dictionary.
|
|
88
|
+
If the `exception` field is already a JSON-serializable dictionary, it will be returned as is.
|
|
89
|
+
If the `exception` field is not a `BaseException` or a JSON-serializable dictionary,
|
|
90
|
+
it will be coerced to a simple shape of string.
|
|
91
|
+
For more information, see `TaskError` model definition.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
err: The `TaskError` BaseModel to convert to JSON.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A dict with following key-value pairs:
|
|
98
|
+
- "__task_error__": bool
|
|
99
|
+
- "error_code": str | LibraryErrorCode
|
|
100
|
+
- "message": str
|
|
101
|
+
- "data": dict[str, Json]
|
|
102
|
+
"""
|
|
103
|
+
# data = err.model_dump(mode="json")
|
|
104
|
+
# ex = data.pop("exception", None)
|
|
105
|
+
|
|
106
|
+
# Avoid pydantic trying to serialize Exception; handle it manually
|
|
107
|
+
ex = err.exception
|
|
108
|
+
data = err.model_dump(mode='json', exclude={'exception'})
|
|
109
|
+
|
|
110
|
+
if isinstance(ex, BaseException):
|
|
111
|
+
ex_json: Optional[Dict[str, Json]] = _exception_to_json(ex)
|
|
112
|
+
elif isinstance(ex, dict) or ex is None:
|
|
113
|
+
ex_json = ex # already JSON-like or absent (e.g. None)
|
|
114
|
+
else:
|
|
115
|
+
# Unknown type: coerce to a simple shape of string
|
|
116
|
+
ex_json = {'type': type(ex).__name__, 'message': str(ex)}
|
|
117
|
+
|
|
118
|
+
if ex_json is not None:
|
|
119
|
+
data['exception'] = ex_json
|
|
120
|
+
|
|
121
|
+
return {'__task_error__': True, **data}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_task_result(value: Any) -> TypeGuard[TaskResult[Any, TaskError]]:
|
|
125
|
+
"""Type guard to properly narrow TaskResult types."""
|
|
126
|
+
return isinstance(value, TaskResult)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_CLASS_CACHE: Dict[
|
|
130
|
+
str, Type[BaseModel]
|
|
131
|
+
] = {} # cache of resolved Pydantic classes by module name and qualname
|
|
132
|
+
|
|
133
|
+
_DATACLASS_CACHE: Dict[
|
|
134
|
+
str, type
|
|
135
|
+
] = {} # cache of resolved dataclass types by module name and qualname
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _qualified_class_path(cls: type) -> tuple[str, str]:
|
|
139
|
+
"""
|
|
140
|
+
Get the module and qualname for a class, with validation for importability.
|
|
141
|
+
|
|
142
|
+
Raises SerializationError if the class is not importable by workers:
|
|
143
|
+
- Defined in __main__ (entrypoint script)
|
|
144
|
+
- Defined inside a function (local class with <locals> in qualname)
|
|
145
|
+
"""
|
|
146
|
+
module_name = cls.__module__
|
|
147
|
+
qualname = cls.__qualname__
|
|
148
|
+
|
|
149
|
+
# STRICT CHECK: Refuse to serialize classes defined in the entrypoint script
|
|
150
|
+
if module_name in ('__main__', '__mp_main__'):
|
|
151
|
+
raise SerializationError(
|
|
152
|
+
f"Cannot serialize '{qualname}' because it is defined in '__main__'. "
|
|
153
|
+
'Please move this class to a separate module (file) so it can be imported by the worker.'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# STRICT CHECK: Refuse to serialize local classes (defined inside functions)
|
|
157
|
+
if '<locals>' in qualname:
|
|
158
|
+
raise SerializationError(
|
|
159
|
+
f"Cannot serialize '{qualname}' because it is a local class defined inside a function. "
|
|
160
|
+
'Please move this class to module level so it can be imported by the worker.'
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return (module_name, qualname)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _qualified_model_path(model: BaseModel) -> tuple[str, str]:
|
|
167
|
+
"""Get qualified path for a Pydantic BaseModel instance."""
|
|
168
|
+
return _qualified_class_path(type(model))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _qualified_dataclass_path(instance: Any) -> tuple[str, str]:
|
|
172
|
+
"""Get qualified path for a dataclass instance."""
|
|
173
|
+
return _qualified_class_path(type(instance))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def to_jsonable(value: Any) -> Json:
|
|
177
|
+
"""
|
|
178
|
+
Convert value to JSON with special handling for Pydantic models, TaskError, TaskResult.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
value: The value to convert to JSON.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A JSON-serializable value. For more information, see `Json` Union type.
|
|
185
|
+
"""
|
|
186
|
+
# Is value a JSON-native type?
|
|
187
|
+
if _is_json_native(value):
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
# Is value a `TaskResult`?
|
|
191
|
+
if _is_task_result(value):
|
|
192
|
+
# Represent discriminated union explicitly
|
|
193
|
+
ok_json = to_jsonable(value.ok) if value.ok is not None else None
|
|
194
|
+
err_json: Optional[Dict[str, Json]] = None
|
|
195
|
+
if value.err is not None:
|
|
196
|
+
if isinstance(value.err, TaskError):
|
|
197
|
+
err_json = _task_error_to_json(value.err)
|
|
198
|
+
elif isinstance(value.err, BaseModel):
|
|
199
|
+
err_json = value.err.model_dump() # if someone used a model for error
|
|
200
|
+
else:
|
|
201
|
+
# last resort: stringify
|
|
202
|
+
err_json = {'message': str(value.err)}
|
|
203
|
+
return {'__task_result__': True, 'ok': ok_json, 'err': err_json}
|
|
204
|
+
|
|
205
|
+
# Is value a `TaskError`?
|
|
206
|
+
if isinstance(value, TaskError):
|
|
207
|
+
return _task_error_to_json(value)
|
|
208
|
+
|
|
209
|
+
# Is value a `BaseModel`?
|
|
210
|
+
if isinstance(value, BaseModel):
|
|
211
|
+
# Include type metadata so we can rehydrate on the other side
|
|
212
|
+
module, qualname = _qualified_model_path(value)
|
|
213
|
+
return {
|
|
214
|
+
'__pydantic_model__': True,
|
|
215
|
+
'module': module,
|
|
216
|
+
'qualname': qualname,
|
|
217
|
+
# Use mode="json" to ensure JSON-compatible field values
|
|
218
|
+
'data': value.model_dump(mode='json'),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Dataclass support - serialize with metadata for round-trip reconstruction
|
|
222
|
+
# Use field-by-field conversion instead of asdict() to preserve nested type metadata
|
|
223
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
224
|
+
module, qualname = _qualified_dataclass_path(value)
|
|
225
|
+
# Convert each field via to_jsonable to preserve nested Pydantic/dataclass metadata
|
|
226
|
+
field_data: Dict[str, Json] = {}
|
|
227
|
+
for field in dataclasses.fields(value):
|
|
228
|
+
field_value = getattr(value, field.name)
|
|
229
|
+
field_data[field.name] = to_jsonable(field_value)
|
|
230
|
+
return {
|
|
231
|
+
'__dataclass__': True,
|
|
232
|
+
'module': module,
|
|
233
|
+
'qualname': qualname,
|
|
234
|
+
'data': field_data,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Handle dictionary-like objects (Mappings). This is a generic way to handle
|
|
238
|
+
# not only `dict` but also other dictionary-like types such as `OrderedDict`
|
|
239
|
+
# or `defaultdict`. It ensures all keys are strings and that values are
|
|
240
|
+
# recursively made JSON-serializable.
|
|
241
|
+
if isinstance(value, Mapping):
|
|
242
|
+
mapping = cast(Mapping[object, object], value)
|
|
243
|
+
return {str(key): to_jsonable(item) for key, item in mapping.items()}
|
|
244
|
+
|
|
245
|
+
# Handle list-like objects (Sequences). This handles not only `list` but also
|
|
246
|
+
# other sequence types like `tuple` or `set`. The check excludes `str`,
|
|
247
|
+
# `bytes`, and `bytearray`, as they are treated as primitive types rather
|
|
248
|
+
# than sequences of characters. It recursively ensures all items in the
|
|
249
|
+
# sequence are JSON-serializable.
|
|
250
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
251
|
+
seq = cast(Sequence[object], value)
|
|
252
|
+
return [to_jsonable(item) for item in seq]
|
|
253
|
+
|
|
254
|
+
raise SerializationError(f'Cannot serialize value of type {type(value).__name__}')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def dumps_json(value: Any) -> str:
|
|
258
|
+
"""
|
|
259
|
+
Serialize a value to JSON string.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
value: The value to serialize.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
A JSON string.
|
|
266
|
+
"""
|
|
267
|
+
return json.dumps(
|
|
268
|
+
to_jsonable(value),
|
|
269
|
+
ensure_ascii=False,
|
|
270
|
+
separators=(',', ':'),
|
|
271
|
+
allow_nan=False, # Prevent NaN values in JSON
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def loads_json(s: Optional[str]) -> Json:
|
|
276
|
+
"""
|
|
277
|
+
Deserialize a JSON string to a JSON value.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
s: The JSON string to deserialize.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
A JSON value. For more information, see `Json` Union type.
|
|
284
|
+
"""
|
|
285
|
+
return json.loads(s) if s else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def args_to_json(args: tuple[Any, ...]) -> str:
|
|
289
|
+
"""
|
|
290
|
+
Serialize a tuple of arguments to a JSON string.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
args: The tuple of arguments to serialize.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
A JSON string.
|
|
297
|
+
"""
|
|
298
|
+
return dumps_json(list(args))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def kwargs_to_json(kwargs: dict[str, Any]) -> str:
|
|
302
|
+
"""
|
|
303
|
+
Serialize a dictionary of keyword arguments to a JSON string.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
kwargs: The dictionary of keyword arguments to serialize.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
A JSON string.
|
|
310
|
+
"""
|
|
311
|
+
return dumps_json(kwargs)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def rehydrate_value(value: Json) -> Any:
|
|
315
|
+
"""
|
|
316
|
+
Recursively rehydrate a JSON value, restoring Pydantic models from their serialized form.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
value: The JSON value to rehydrate.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
The rehydrated value with Pydantic models restored.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
SerializationError: If a Pydantic model cannot be rehydrated.
|
|
326
|
+
"""
|
|
327
|
+
# Handle Pydantic model rehydration
|
|
328
|
+
if isinstance(value, dict) and value.get('__pydantic_model__'):
|
|
329
|
+
module_name = cast(str, value.get('module'))
|
|
330
|
+
qualname = cast(str, value.get('qualname'))
|
|
331
|
+
data = value.get('data')
|
|
332
|
+
|
|
333
|
+
cache_key = f'{module_name}:{qualname}'
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
# 1. Check Cache
|
|
337
|
+
if cache_key in _CLASS_CACHE:
|
|
338
|
+
cls = _CLASS_CACHE[cache_key]
|
|
339
|
+
else:
|
|
340
|
+
# 2. Dynamic Import
|
|
341
|
+
try:
|
|
342
|
+
module = import_module(module_name)
|
|
343
|
+
except ImportError as e:
|
|
344
|
+
raise SerializationError(
|
|
345
|
+
f"Could not import module '{module_name}'. "
|
|
346
|
+
f'Did you move the file without leaving a re-export shim? Error: {e}'
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# 3. Resolve Class
|
|
350
|
+
cls = module
|
|
351
|
+
# Handle nested classes (e.g. ClassA.ClassB)
|
|
352
|
+
for part in qualname.split('.'):
|
|
353
|
+
cls = getattr(cls, part)
|
|
354
|
+
|
|
355
|
+
if not (isinstance(cls, type) and issubclass(cls, BaseModel)):
|
|
356
|
+
raise SerializationError(f'{cache_key} is not a BaseModel')
|
|
357
|
+
|
|
358
|
+
# 4. Save to Cache
|
|
359
|
+
_CLASS_CACHE[cache_key] = cls
|
|
360
|
+
|
|
361
|
+
# 5. Validate/Hydrate
|
|
362
|
+
return cls.model_validate(data)
|
|
363
|
+
|
|
364
|
+
except Exception as e:
|
|
365
|
+
# Catch Pydantic ValidationErrors or AttributeErrors here
|
|
366
|
+
logger.error(
|
|
367
|
+
f'Failed to rehydrate Pydantic model {cache_key}: {type(e).__name__}: {e}'
|
|
368
|
+
)
|
|
369
|
+
raise SerializationError(f'Failed to rehydrate {cache_key}: {str(e)}')
|
|
370
|
+
|
|
371
|
+
# Handle dataclass rehydration
|
|
372
|
+
if isinstance(value, dict) and value.get('__dataclass__'):
|
|
373
|
+
module_name = cast(str, value.get('module'))
|
|
374
|
+
qualname = cast(str, value.get('qualname'))
|
|
375
|
+
data = value.get('data')
|
|
376
|
+
|
|
377
|
+
cache_key = f'{module_name}:{qualname}'
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
# 1. Check Cache
|
|
381
|
+
if cache_key in _DATACLASS_CACHE:
|
|
382
|
+
dc_cls = _DATACLASS_CACHE[cache_key]
|
|
383
|
+
else:
|
|
384
|
+
# 2. Dynamic Import
|
|
385
|
+
try:
|
|
386
|
+
module = import_module(module_name)
|
|
387
|
+
except ImportError as e:
|
|
388
|
+
raise SerializationError(
|
|
389
|
+
f"Could not import module '{module_name}'. "
|
|
390
|
+
f'Did you move the file without leaving a re-export shim? Error: {e}'
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# 3. Resolve Class
|
|
394
|
+
resolved: Any = module
|
|
395
|
+
# Handle nested classes (e.g. ClassA.ClassB)
|
|
396
|
+
for part in qualname.split('.'):
|
|
397
|
+
resolved = getattr(resolved, part)
|
|
398
|
+
|
|
399
|
+
if not isinstance(resolved, type) or not dataclasses.is_dataclass(
|
|
400
|
+
resolved
|
|
401
|
+
):
|
|
402
|
+
raise SerializationError(f'{cache_key} is not a dataclass')
|
|
403
|
+
|
|
404
|
+
dc_cls = resolved
|
|
405
|
+
# 4. Save to Cache
|
|
406
|
+
_DATACLASS_CACHE[cache_key] = dc_cls
|
|
407
|
+
|
|
408
|
+
# 5. Instantiate dataclass with rehydrated field values
|
|
409
|
+
if not isinstance(data, dict):
|
|
410
|
+
raise SerializationError(
|
|
411
|
+
f'Dataclass data must be a dict, got {type(data)}'
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Rehydrate each field to restore nested Pydantic/dataclass types
|
|
415
|
+
rehydrated_data = {k: rehydrate_value(v) for k, v in data.items()}
|
|
416
|
+
|
|
417
|
+
# Separate init=True fields from init=False fields
|
|
418
|
+
dc_fields = {f.name: f for f in dataclasses.fields(dc_cls)}
|
|
419
|
+
init_kwargs: Dict[str, Any] = {}
|
|
420
|
+
non_init_fields: Dict[str, Any] = {}
|
|
421
|
+
for field_name, field_value in rehydrated_data.items():
|
|
422
|
+
field_def = dc_fields.get(field_name)
|
|
423
|
+
if field_def is None:
|
|
424
|
+
# Field not in dataclass definition - skip (could be removed field)
|
|
425
|
+
continue
|
|
426
|
+
if field_def.init:
|
|
427
|
+
init_kwargs[field_name] = field_value
|
|
428
|
+
else:
|
|
429
|
+
non_init_fields[field_name] = field_value
|
|
430
|
+
|
|
431
|
+
# Construct with init fields only
|
|
432
|
+
instance = dc_cls(**init_kwargs)
|
|
433
|
+
|
|
434
|
+
# Set non-init fields directly on the instance
|
|
435
|
+
for fname, fvalue in non_init_fields.items():
|
|
436
|
+
object.__setattr__(instance, fname, fvalue)
|
|
437
|
+
|
|
438
|
+
return instance
|
|
439
|
+
|
|
440
|
+
except SerializationError:
|
|
441
|
+
raise
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.error(
|
|
444
|
+
f'Failed to rehydrate dataclass {cache_key}: {type(e).__name__}: {e}'
|
|
445
|
+
)
|
|
446
|
+
raise SerializationError(
|
|
447
|
+
f'Failed to rehydrate dataclass {cache_key}: {str(e)}'
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Handle nested TaskResult rehydration
|
|
451
|
+
if isinstance(value, dict) and value.get('__task_result__'):
|
|
452
|
+
return task_result_from_json(value)
|
|
453
|
+
|
|
454
|
+
# Recursively rehydrate nested dicts
|
|
455
|
+
if isinstance(value, dict):
|
|
456
|
+
return {k: rehydrate_value(v) for k, v in value.items()}
|
|
457
|
+
|
|
458
|
+
# Recursively rehydrate nested lists
|
|
459
|
+
if isinstance(value, list):
|
|
460
|
+
return [rehydrate_value(item) for item in value]
|
|
461
|
+
|
|
462
|
+
# Return primitive values as-is
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def json_to_args(j: Json) -> List[Any]:
|
|
467
|
+
"""
|
|
468
|
+
Deserialize a JSON value to a list of arguments, rehydrating Pydantic models.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
j: The JSON value to deserialize.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
A list of arguments with Pydantic models rehydrated.
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
SerializationError: If the JSON value is not a list.
|
|
478
|
+
"""
|
|
479
|
+
if j is None:
|
|
480
|
+
return []
|
|
481
|
+
if isinstance(j, list):
|
|
482
|
+
return [rehydrate_value(item) for item in j]
|
|
483
|
+
raise SerializationError('Args payload is not a list JSON.')
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def json_to_kwargs(j: Json) -> Dict[str, Any]:
|
|
487
|
+
"""
|
|
488
|
+
Deserialize a JSON value to a dictionary of keyword arguments, rehydrating Pydantic models.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
j: The JSON value to deserialize.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
A dictionary of keyword arguments with Pydantic models rehydrated.
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
SerializationError: If the JSON value is not a dict.
|
|
498
|
+
"""
|
|
499
|
+
if j is None:
|
|
500
|
+
return {}
|
|
501
|
+
if isinstance(j, dict):
|
|
502
|
+
return {k: rehydrate_value(v) for k, v in j.items()}
|
|
503
|
+
raise SerializationError('Kwargs payload is not a dict JSON.')
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def task_result_from_json(j: Json) -> TaskResult[Any, TaskError]:
|
|
507
|
+
"""
|
|
508
|
+
Rehydrate `TaskResult` from JSON.
|
|
509
|
+
NOTES:
|
|
510
|
+
- We don't recreate `Exception` objects;
|
|
511
|
+
- We keep the flattened structure inside `TaskError.exception` (as dict) or `None`.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
j: The JSON string to deserialize.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
A `TaskResult`.
|
|
518
|
+
"""
|
|
519
|
+
if not isinstance(j, dict) or '__task_result__' not in j:
|
|
520
|
+
# Accept legacy "ok"/"err" shape if present
|
|
521
|
+
if isinstance(j, dict) and ('ok' in j or 'err' in j):
|
|
522
|
+
payload = j
|
|
523
|
+
else:
|
|
524
|
+
raise SerializationError('Not a TaskResult JSON')
|
|
525
|
+
else:
|
|
526
|
+
payload = j
|
|
527
|
+
|
|
528
|
+
ok = payload.get('ok', None)
|
|
529
|
+
err = payload.get('err', None)
|
|
530
|
+
|
|
531
|
+
# meaning task itself returned an error
|
|
532
|
+
if err is not None:
|
|
533
|
+
# Build TaskError from dict, letting pydantic validate
|
|
534
|
+
if isinstance(err, dict) and err.get('__task_error__'):
|
|
535
|
+
err = {k: v for k, v in err.items() if k != '__task_error__'}
|
|
536
|
+
task_err = TaskError.model_validate(err)
|
|
537
|
+
return TaskResult(err=task_err)
|
|
538
|
+
else:
|
|
539
|
+
# Try to rehydrate pydantic BaseModel if we have metadata (using reusable function)
|
|
540
|
+
try:
|
|
541
|
+
ok_value = rehydrate_value(ok)
|
|
542
|
+
return TaskResult(ok=ok_value)
|
|
543
|
+
except SerializationError as e:
|
|
544
|
+
# Any failure during rehydration becomes a library error
|
|
545
|
+
logger.warning(f'PYDANTIC_HYDRATION_ERROR: {e}')
|
|
546
|
+
return TaskResult(
|
|
547
|
+
err=TaskError(
|
|
548
|
+
error_code=LibraryErrorCode.PYDANTIC_HYDRATION_ERROR,
|
|
549
|
+
message=str(e),
|
|
550
|
+
data={},
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def serialize_task_options(task_options: TaskOptions) -> str:
|
|
556
|
+
# Normalize auto_retry_for entries to plain strings for JSON (support enums)
|
|
557
|
+
auto_retry: Optional[list[str]] = None
|
|
558
|
+
if task_options.auto_retry_for is not None:
|
|
559
|
+
auto_retry = []
|
|
560
|
+
for item in task_options.auto_retry_for:
|
|
561
|
+
if isinstance(item, LibraryErrorCode):
|
|
562
|
+
auto_retry.append(item.value)
|
|
563
|
+
else:
|
|
564
|
+
auto_retry.append(str(item))
|
|
565
|
+
return dumps_json(
|
|
566
|
+
{
|
|
567
|
+
'auto_retry_for': auto_retry,
|
|
568
|
+
'retry_policy': task_options.retry_policy.model_dump()
|
|
569
|
+
if task_options.retry_policy
|
|
570
|
+
else None,
|
|
571
|
+
'good_until': task_options.good_until.isoformat()
|
|
572
|
+
if task_options.good_until
|
|
573
|
+
else None,
|
|
574
|
+
}
|
|
575
|
+
)
|