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.
Files changed (42) hide show
  1. horsies/__init__.py +115 -0
  2. horsies/core/__init__.py +0 -0
  3. horsies/core/app.py +552 -0
  4. horsies/core/banner.py +144 -0
  5. horsies/core/brokers/__init__.py +5 -0
  6. horsies/core/brokers/listener.py +444 -0
  7. horsies/core/brokers/postgres.py +864 -0
  8. horsies/core/cli.py +624 -0
  9. horsies/core/codec/serde.py +575 -0
  10. horsies/core/errors.py +535 -0
  11. horsies/core/logging.py +90 -0
  12. horsies/core/models/__init__.py +0 -0
  13. horsies/core/models/app.py +268 -0
  14. horsies/core/models/broker.py +79 -0
  15. horsies/core/models/queues.py +23 -0
  16. horsies/core/models/recovery.py +101 -0
  17. horsies/core/models/schedule.py +229 -0
  18. horsies/core/models/task_pg.py +307 -0
  19. horsies/core/models/tasks.py +332 -0
  20. horsies/core/models/workflow.py +1988 -0
  21. horsies/core/models/workflow_pg.py +245 -0
  22. horsies/core/registry/tasks.py +101 -0
  23. horsies/core/scheduler/__init__.py +26 -0
  24. horsies/core/scheduler/calculator.py +267 -0
  25. horsies/core/scheduler/service.py +569 -0
  26. horsies/core/scheduler/state.py +260 -0
  27. horsies/core/task_decorator.py +615 -0
  28. horsies/core/types/status.py +38 -0
  29. horsies/core/utils/imports.py +203 -0
  30. horsies/core/utils/loop_runner.py +44 -0
  31. horsies/core/worker/current.py +17 -0
  32. horsies/core/worker/worker.py +1967 -0
  33. horsies/core/workflows/__init__.py +23 -0
  34. horsies/core/workflows/engine.py +2344 -0
  35. horsies/core/workflows/recovery.py +501 -0
  36. horsies/core/workflows/registry.py +97 -0
  37. horsies/py.typed +0 -0
  38. horsies-0.1.0a1.dist-info/METADATA +31 -0
  39. horsies-0.1.0a1.dist-info/RECORD +42 -0
  40. horsies-0.1.0a1.dist-info/WHEEL +5 -0
  41. horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
  42. 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
+ )