python-saga-orchestrator 0.2.3.dev0__tar.gz → 0.3.0__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_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.github/workflows/publish.yml +4 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/Makefile +2 -2
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/PKG-INFO +1 -1
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/pyproject.toml +8 -2
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/SOURCES.txt +4 -1
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/_version.py +3 -3
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/context.py +0 -1
- python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/base_str_enum.py +10 -0
- python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/saga_status.py +17 -0
- python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/saga_step_phase.py +9 -0
- python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/saga_step_status.py +9 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/step.py +71 -15
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/models.py +1 -2
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/models.py +1 -2
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_admin_api.py +3 -3
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_context_persistence.py +3 -3
- python_saga_orchestrator-0.3.0/tests/unit/test_input_context.py +86 -0
- python_saga_orchestrator-0.3.0/tests/unit/test_step_type_resolution.py +136 -0
- python_saga_orchestrator-0.2.3.dev0/saga_orchestrator/domain/models/enums/saga_status.py +0 -15
- python_saga_orchestrator-0.2.3.dev0/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -7
- python_saga_orchestrator-0.2.3.dev0/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -7
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.github/workflows/ci.yml +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.gitignore +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/Dockerfile +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/LICENSE +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/README.md +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/docker-compose.yaml +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/admin_skip.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/common.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/compensation_flow.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/http_and_queue.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/llm_deploy.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/retry_recovery.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/builder.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/engine.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/orchestrator.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/types.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/notify.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/contracts.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/repository.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/retry.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/contracts.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/event.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/factory.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/repository.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/retry.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/serialization.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/setup.cfg +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/task.md +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/conftest.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/conftest.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/helpers.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/models.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_compensation_flow.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_core_flow.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_inbox_flow.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_lifecycle_hooks.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_notification_flow.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_outbox_flow.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_repository.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/__init__.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_builder.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_inbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_orchestrator_helpers.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_outbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_retry.py +0 -0
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.github/workflows/publish.yml
RENAMED
|
@@ -31,6 +31,10 @@ jobs:
|
|
|
31
31
|
python -m pip install --upgrade pip
|
|
32
32
|
python -m pip install build twine setuptools-scm
|
|
33
33
|
|
|
34
|
+
- name: Pin version from tag for build
|
|
35
|
+
run: |
|
|
36
|
+
echo "SETUPTOOLS_SCM_PRETEND_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
|
|
37
|
+
|
|
34
38
|
- name: Validate version matches tag
|
|
35
39
|
run: |
|
|
36
40
|
EXPECTED_VERSION="${GITHUB_REF_NAME#v}"
|
|
@@ -71,10 +71,16 @@ testpaths = ["tests"]
|
|
|
71
71
|
[tool.ruff]
|
|
72
72
|
target-version = "py312"
|
|
73
73
|
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
select = [
|
|
76
|
+
"W", # pycodestyle warnings
|
|
77
|
+
"F", # pyflakes
|
|
78
|
+
"I", # isort
|
|
79
|
+
]
|
|
80
|
+
|
|
74
81
|
[dependency-groups]
|
|
75
82
|
dev = [
|
|
76
|
-
"black>=26.3.1",
|
|
77
|
-
"isort>=8.0.1",
|
|
78
83
|
"pytest>=9.0.3",
|
|
79
84
|
"pytest-asyncio>=1.3.0",
|
|
85
|
+
"ruff>=0.15.12",
|
|
80
86
|
]
|
|
@@ -43,6 +43,7 @@ saga_orchestrator/domain/models/retry.py
|
|
|
43
43
|
saga_orchestrator/domain/models/saga_snapshot.py
|
|
44
44
|
saga_orchestrator/domain/models/step.py
|
|
45
45
|
saga_orchestrator/domain/models/enums/__init__.py
|
|
46
|
+
saga_orchestrator/domain/models/enums/base_str_enum.py
|
|
46
47
|
saga_orchestrator/domain/models/enums/saga_status.py
|
|
47
48
|
saga_orchestrator/domain/models/enums/saga_step_phase.py
|
|
48
49
|
saga_orchestrator/domain/models/enums/saga_step_status.py
|
|
@@ -79,6 +80,8 @@ tests/integration/test_repository.py
|
|
|
79
80
|
tests/unit/__init__.py
|
|
80
81
|
tests/unit/test_builder.py
|
|
81
82
|
tests/unit/test_inbox_extensibility.py
|
|
83
|
+
tests/unit/test_input_context.py
|
|
82
84
|
tests/unit/test_orchestrator_helpers.py
|
|
83
85
|
tests/unit/test_outbox_extensibility.py
|
|
84
|
-
tests/unit/test_retry.py
|
|
86
|
+
tests/unit/test_retry.py
|
|
87
|
+
tests/unit/test_step_type_resolution.py
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/_version.py
RENAMED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id =
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from enum import auto
|
|
2
|
+
|
|
3
|
+
from .base_str_enum import BaseStrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SagaStatus(BaseStrEnum):
|
|
7
|
+
RUNNING = auto()
|
|
8
|
+
SUSPENDED = auto()
|
|
9
|
+
FAILED = auto()
|
|
10
|
+
COMPENSATING = auto()
|
|
11
|
+
COMPLETED = auto()
|
|
12
|
+
COMPENSATING_SUSPENDED = auto()
|
|
13
|
+
COMPENSATED = auto()
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def is_terminal(self) -> bool:
|
|
17
|
+
return self in {self.FAILED, self.COMPLETED, self.COMPENSATED}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import contextlib
|
|
4
3
|
import inspect
|
|
5
4
|
from collections.abc import Callable
|
|
6
5
|
from dataclasses import dataclass
|
|
@@ -8,6 +7,7 @@ from datetime import timedelta
|
|
|
8
7
|
from typing import (
|
|
9
8
|
Any,
|
|
10
9
|
Generic,
|
|
10
|
+
Mapping,
|
|
11
11
|
TypeAlias,
|
|
12
12
|
TypeVar,
|
|
13
13
|
get_args,
|
|
@@ -60,16 +60,16 @@ class InputContext:
|
|
|
60
60
|
"""
|
|
61
61
|
Возвращает тип ('event_type') последнего полученного события.
|
|
62
62
|
"""
|
|
63
|
-
|
|
64
|
-
return
|
|
65
|
-
return
|
|
63
|
+
if not isinstance(self.context.latest_event_meta, Mapping):
|
|
64
|
+
return None
|
|
65
|
+
return self.context.latest_event_meta.get("event_type")
|
|
66
66
|
|
|
67
67
|
@property
|
|
68
68
|
def latest_event_payload(self) -> Any | None:
|
|
69
69
|
"""
|
|
70
70
|
Возвращает "сырую" полезную нагрузку (payload) последнего полученного события.
|
|
71
71
|
"""
|
|
72
|
-
return self.context.
|
|
72
|
+
return self.context.latest_event
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
RootInputMap: TypeAlias = Callable[[InputContext], InputModelT | dict[str, Any]]
|
|
@@ -102,16 +102,56 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
|
|
|
102
102
|
|
|
103
103
|
def __init_subclass__(cls) -> None:
|
|
104
104
|
super().__init_subclass__()
|
|
105
|
-
if cls is BaseStep:
|
|
105
|
+
if cls is BaseStep or inspect.isabstract(cls):
|
|
106
106
|
return
|
|
107
|
+
generic_base = None
|
|
108
|
+
for base in getattr(cls, "__orig_bases__", []):
|
|
109
|
+
origin = get_origin(base)
|
|
110
|
+
if origin and issubclass(origin, BaseStep):
|
|
111
|
+
generic_base = base
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if not generic_base:
|
|
115
|
+
raise TypeValidationError(
|
|
116
|
+
f"Could not find generic parameters for {cls.__name__}. "
|
|
117
|
+
f"Ensure it inherits from a parameterized BaseStep, e.g., BaseStep[MyInput, MyOutput]"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
concrete_args = get_args(generic_base)
|
|
121
|
+
if not concrete_args or any(isinstance(arg, TypeVar) for arg in concrete_args):
|
|
122
|
+
raise TypeValidationError(
|
|
123
|
+
f"Step '{cls.__name__}' inherits from a generic Step "
|
|
124
|
+
"but was not parameterized with concrete Input/Output models."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
concrete_input_model, concrete_output_model = concrete_args
|
|
128
|
+
try:
|
|
129
|
+
hints = get_type_hints(
|
|
130
|
+
cls.execute,
|
|
131
|
+
globalns=inspect.getmodule(cls).__dict__,
|
|
132
|
+
include_extras=True,
|
|
133
|
+
)
|
|
134
|
+
except (AttributeError, NameError) as e:
|
|
135
|
+
raise TypeValidationError(
|
|
136
|
+
f"Could not resolve type hints for '{cls.__name__}.execute'. "
|
|
137
|
+
f"Ensure all types are correctly imported. Original error: {e}"
|
|
138
|
+
)
|
|
107
139
|
|
|
108
|
-
hints = get_type_hints(cls.execute)
|
|
109
140
|
if "inp" not in hints or "return" not in hints:
|
|
110
141
|
raise TypeValidationError(
|
|
111
142
|
f"Step '{cls.__name__}' must type annotate execute(inp) and return type"
|
|
112
143
|
)
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
|
|
145
|
+
input_annotation = hints["inp"]
|
|
146
|
+
if isinstance(input_annotation, TypeVar):
|
|
147
|
+
input_model = concrete_input_model
|
|
148
|
+
else:
|
|
149
|
+
input_model = input_annotation
|
|
150
|
+
|
|
151
|
+
return_annotation = hints["return"]
|
|
152
|
+
output_model = cls._resolve_output_model(
|
|
153
|
+
return_annotation, concrete_output_model
|
|
154
|
+
)
|
|
115
155
|
if not (inspect.isclass(input_model) and issubclass(input_model, BaseModel)):
|
|
116
156
|
raise TypeValidationError(
|
|
117
157
|
f"Step '{cls.__name__}' input must inherit from pydantic BaseModel"
|
|
@@ -120,22 +160,38 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
|
|
|
120
160
|
cls.output_model = output_model
|
|
121
161
|
|
|
122
162
|
@staticmethod
|
|
123
|
-
def _resolve_output_model(
|
|
163
|
+
def _resolve_output_model(
|
|
164
|
+
annotation: Any, concrete_model: type[BaseModel] | None = None
|
|
165
|
+
) -> type[BaseModel]:
|
|
166
|
+
def find_model_in_args(args_tuple: tuple) -> list[type[BaseModel]]:
|
|
167
|
+
candidates = []
|
|
168
|
+
for arg in args_tuple:
|
|
169
|
+
if isinstance(arg, TypeVar) and concrete_model:
|
|
170
|
+
candidates.append(concrete_model)
|
|
171
|
+
elif inspect.isclass(arg) and issubclass(arg, BaseModel):
|
|
172
|
+
candidates.append(arg)
|
|
173
|
+
return list(dict.fromkeys(candidates))
|
|
174
|
+
|
|
124
175
|
if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
|
|
125
176
|
return annotation
|
|
126
177
|
|
|
127
178
|
origin = get_origin(annotation)
|
|
128
179
|
if origin is None:
|
|
180
|
+
if isinstance(annotation, TypeVar) and concrete_model:
|
|
181
|
+
return concrete_model
|
|
129
182
|
raise TypeValidationError("Step execute return type must include BaseModel")
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
]
|
|
183
|
+
|
|
184
|
+
args = get_args(annotation)
|
|
185
|
+
model_candidates = find_model_in_args(args)
|
|
134
186
|
await_candidates = [arg for arg in args if arg is StepAwaitEvent]
|
|
187
|
+
|
|
135
188
|
if len(model_candidates) == 1 and len(await_candidates) <= 1:
|
|
136
189
|
return model_candidates[0]
|
|
190
|
+
|
|
137
191
|
raise TypeValidationError(
|
|
138
|
-
"Step execute return type must be BaseModel or BaseModel | StepAwaitEvent"
|
|
192
|
+
f"Step execute return type must be BaseModel or BaseModel | StepAwaitEvent. "
|
|
193
|
+
f"Found models: {[m.__name__ for m in model_candidates]}, "
|
|
194
|
+
f"Found await events: {len(await_candidates)}."
|
|
139
195
|
)
|
|
140
196
|
|
|
141
197
|
async def execute(self, inp: InputModelT) -> OutputModelT | StepAwaitEvent:
|
|
@@ -5,9 +5,8 @@ from datetime import datetime
|
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from sqlalchemy import JSON, DateTime
|
|
8
|
+
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint, func
|
|
9
9
|
from sqlalchemy import Enum as SqlEnum
|
|
10
|
-
from sqlalchemy import Integer, String, Text, UniqueConstraint, func
|
|
11
10
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
12
11
|
from sqlalchemy.ext.mutable import MutableDict
|
|
13
12
|
from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
|
|
@@ -5,9 +5,8 @@ from datetime import datetime
|
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from sqlalchemy import JSON, DateTime
|
|
8
|
+
from sqlalchemy import JSON, DateTime, Integer, String, Text, func
|
|
9
9
|
from sqlalchemy import Enum as SqlEnum
|
|
10
|
-
from sqlalchemy import Integer, String, Text, func
|
|
11
10
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
12
11
|
from sqlalchemy.ext.mutable import MutableDict
|
|
13
12
|
from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
|
|
@@ -58,7 +58,7 @@ async def test_admin_retry_rejects_failed_saga_after_compensation(session_maker)
|
|
|
58
58
|
)
|
|
59
59
|
state = await admin.get_saga(saga_id)
|
|
60
60
|
assert state.status == SagaStatus.COMPENSATED
|
|
61
|
-
assert any(entry.phase == "
|
|
61
|
+
assert any(entry.phase == "COMPENSATE" for entry in state.step_history)
|
|
62
62
|
|
|
63
63
|
with pytest.raises(SagaStateError):
|
|
64
64
|
await admin.retry_step(saga_id)
|
|
@@ -150,7 +150,7 @@ async def test_admin_compensate_rolls_back_suspended_saga(session_maker):
|
|
|
150
150
|
state_after = await admin.get_saga(saga_id)
|
|
151
151
|
assert state_after.status == SagaStatus.COMPENSATED
|
|
152
152
|
assert compensating_step.compensated is True
|
|
153
|
-
assert any(entry.phase == "
|
|
153
|
+
assert any(entry.phase == "COMPENSATE" for entry in state_after.step_history)
|
|
154
154
|
|
|
155
155
|
|
|
156
156
|
@pytest.mark.asyncio
|
|
@@ -225,7 +225,7 @@ async def test_get_admin_snapshot_returns(session_maker):
|
|
|
225
225
|
assert isinstance(admin_snapshot.step_history, list)
|
|
226
226
|
assert len(admin_snapshot.step_history) == 1
|
|
227
227
|
assert admin_snapshot.step_history[0].step_name == "AddOneStep"
|
|
228
|
-
assert admin_snapshot.step_history[0].status == "
|
|
228
|
+
assert admin_snapshot.step_history[0].status == "SUCCESS"
|
|
229
229
|
|
|
230
230
|
|
|
231
231
|
@pytest.mark.asyncio
|
|
@@ -277,9 +277,9 @@ async def test_step_history_with_orm_model_is_persisted_and_rehydrated(
|
|
|
277
277
|
|
|
278
278
|
rehydrated_entry = reloaded_saga.step_history[0]
|
|
279
279
|
|
|
280
|
-
assert isinstance(
|
|
281
|
-
|
|
282
|
-
)
|
|
280
|
+
assert isinstance(rehydrated_entry, IntegrationSagaHistory), (
|
|
281
|
+
f"Expected IntegrationSagaHistory, got {type(rehydrated_entry)}"
|
|
282
|
+
)
|
|
283
283
|
|
|
284
284
|
assert rehydrated_entry.step_id == "test_step_orm"
|
|
285
285
|
assert rehydrated_entry.status == SagaStepStatus.SUCCESS
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from saga_orchestrator import InputContext
|
|
6
|
+
from saga_orchestrator.domain.models.context import SagaContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize(
|
|
10
|
+
"test_id, saga_context_config, expected_type, expected_payload",
|
|
11
|
+
[
|
|
12
|
+
(
|
|
13
|
+
"happy_path",
|
|
14
|
+
{
|
|
15
|
+
"latest_event": {"user_id": 123},
|
|
16
|
+
"latest_event_meta": {
|
|
17
|
+
"event_type": "user.created",
|
|
18
|
+
"event_id": "evt-1",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
"user.created",
|
|
22
|
+
{"user_id": 123},
|
|
23
|
+
),
|
|
24
|
+
(
|
|
25
|
+
"no_event_data",
|
|
26
|
+
{},
|
|
27
|
+
None,
|
|
28
|
+
None,
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"meta_without_type",
|
|
32
|
+
{
|
|
33
|
+
"latest_event": {"data": "something"},
|
|
34
|
+
"latest_event_meta": {"source": "legacy_system"},
|
|
35
|
+
},
|
|
36
|
+
None,
|
|
37
|
+
{"data": "something"},
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
"payload_is_none",
|
|
41
|
+
{
|
|
42
|
+
"latest_event": None,
|
|
43
|
+
"latest_event_meta": {"event_type": "user.deleted"},
|
|
44
|
+
},
|
|
45
|
+
"user.deleted",
|
|
46
|
+
None,
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"payload_is_primitive",
|
|
50
|
+
{
|
|
51
|
+
"latest_event": "simple_string_payload",
|
|
52
|
+
"latest_event_meta": {"event_type": "notification.sent"},
|
|
53
|
+
},
|
|
54
|
+
"notification.sent",
|
|
55
|
+
"simple_string_payload",
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_input_context_properties_parametrized(
|
|
60
|
+
test_id, saga_context_config, expected_type, expected_payload
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Параметризованный тест для проверки свойств InputContext.
|
|
64
|
+
"""
|
|
65
|
+
saga_id = uuid4()
|
|
66
|
+
initial_data = {"key": "value"}
|
|
67
|
+
|
|
68
|
+
saga_context = SagaContext(
|
|
69
|
+
saga_id=saga_id,
|
|
70
|
+
saga_name="test_saga",
|
|
71
|
+
initial_data=initial_data,
|
|
72
|
+
**saga_context_config,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
input_ctx = InputContext(
|
|
76
|
+
saga_id=saga_id,
|
|
77
|
+
initial_data=initial_data,
|
|
78
|
+
context=saga_context,
|
|
79
|
+
step_outputs={},
|
|
80
|
+
latest_event=saga_context.latest_event,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert input_ctx.latest_event_type == expected_type, f"Failed on test: {test_id}"
|
|
84
|
+
assert input_ctx.latest_event_payload == expected_payload, (
|
|
85
|
+
f"Failed on test: {test_id}"
|
|
86
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from saga_orchestrator import BaseStep, TypeValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutboxEvent(BaseModel):
|
|
14
|
+
topic: str
|
|
15
|
+
payload: dict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InputModelA(BaseModel):
|
|
19
|
+
a: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OutputModelA(BaseModel):
|
|
23
|
+
b: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InputModelB(BaseModel):
|
|
27
|
+
x: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OutputModelB(BaseModel):
|
|
31
|
+
y: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
InputModelT = TypeVar("InputModelT", bound=BaseModel)
|
|
35
|
+
OutputModelT = TypeVar("OutputModelT", bound=BaseModel)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class StepAwaitEvent:
|
|
40
|
+
event_types: tuple[str, ...] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
IntermediateInputT = TypeVar("IntermediateInputT", bound=BaseModel)
|
|
44
|
+
IntermediateOutputT = TypeVar("IntermediateOutputT", bound=BaseModel)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AbstractIntermediateStep(
|
|
48
|
+
BaseStep[IntermediateInputT, IntermediateOutputT],
|
|
49
|
+
ABC,
|
|
50
|
+
Generic[IntermediateInputT, IntermediateOutputT],
|
|
51
|
+
):
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def some_abstract_method(self):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
async def execute(
|
|
57
|
+
self, inp: IntermediateInputT
|
|
58
|
+
) -> IntermediateOutputT | StepAwaitEvent:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestTypeResolution:
|
|
63
|
+
def test_indirect_inheritance_resolves_types_correctly(self):
|
|
64
|
+
"""
|
|
65
|
+
GIVEN a concrete step class inheriting from a generic abstract step
|
|
66
|
+
WHEN the concrete class is defined
|
|
67
|
+
THEN BaseStep.__init_subclass__ should correctly resolve input and output models.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
class ConcreteStep(AbstractIntermediateStep[InputModelA, OutputModelA]):
|
|
71
|
+
def some_abstract_method(self):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
assert hasattr(ConcreteStep, "input_model")
|
|
75
|
+
assert hasattr(ConcreteStep, "output_model")
|
|
76
|
+
assert ConcreteStep.input_model is InputModelA
|
|
77
|
+
assert ConcreteStep.output_model is OutputModelA
|
|
78
|
+
|
|
79
|
+
def test_direct_inheritance_resolves_types_correctly(self):
|
|
80
|
+
"""
|
|
81
|
+
GIVEN a concrete step class inheriting directly from BaseStep
|
|
82
|
+
WHEN the concrete class is defined
|
|
83
|
+
THEN input and output models should be resolved from its own `execute` method.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
class DirectStep(BaseStep[InputModelB, OutputModelB]):
|
|
87
|
+
async def execute(self, inp: InputModelB) -> OutputModelB:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
assert DirectStep.input_model is InputModelB
|
|
91
|
+
assert DirectStep.output_model is OutputModelB
|
|
92
|
+
|
|
93
|
+
def test_missing_annotation_on_execute_raises_error(self):
|
|
94
|
+
"""
|
|
95
|
+
GIVEN a step class with a missing type annotation on `execute`
|
|
96
|
+
WHEN the class is defined
|
|
97
|
+
THEN TypeValidationError should be raised.
|
|
98
|
+
"""
|
|
99
|
+
with pytest.raises(TypeValidationError, match="must type annotate execute"):
|
|
100
|
+
|
|
101
|
+
class FaultyStep(BaseStep[InputModelA, OutputModelA]):
|
|
102
|
+
# `inp` не аннотирован
|
|
103
|
+
async def execute(self, inp) -> OutputModelA:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def test_wrong_parameterization_raises_error(self):
|
|
107
|
+
"""
|
|
108
|
+
GIVEN a step class parameterized with a non-BaseModel type
|
|
109
|
+
WHEN the class is defined
|
|
110
|
+
THEN a TypeError or TypeValidationError should be raised.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class NotAModel:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
with pytest.raises(TypeValidationError, match="Could not resolve type hints"):
|
|
118
|
+
|
|
119
|
+
class FaultyStep(BaseStep[InputModelA, OutputModelA]):
|
|
120
|
+
async def execute(self, inp: NotAModel) -> OutputModelA:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def test_unparameterized_generic_step_raises_error(self):
|
|
124
|
+
"""
|
|
125
|
+
GIVEN a concrete step inheriting from a generic abstract step without parameters
|
|
126
|
+
WHEN the class is defined
|
|
127
|
+
THEN TypeValidationError should be raised by our custom logic.
|
|
128
|
+
"""
|
|
129
|
+
with pytest.raises(
|
|
130
|
+
TypeValidationError,
|
|
131
|
+
match="inherits from a generic Step but was not parameterized with concrete Input/Output models",
|
|
132
|
+
):
|
|
133
|
+
|
|
134
|
+
class UnparameterizedStep(AbstractIntermediateStep):
|
|
135
|
+
def some_abstract_method(self):
|
|
136
|
+
pass
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
from enum import StrEnum
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class SagaStatus(StrEnum):
|
|
5
|
-
RUNNING = "RUNNING"
|
|
6
|
-
SUSPENDED = "SUSPENDED"
|
|
7
|
-
FAILED = "FAILED"
|
|
8
|
-
COMPENSATING = "COMPENSATING"
|
|
9
|
-
COMPLETED = "COMPLETED"
|
|
10
|
-
COMPENSATING_SUSPENDED = "COMPENSATING_SUSPENDED"
|
|
11
|
-
COMPENSATED = "COMPENSATED"
|
|
12
|
-
|
|
13
|
-
@property
|
|
14
|
-
def is_terminal(self) -> bool:
|
|
15
|
-
return self in {self.FAILED, self.COMPLETED, self.COMPENSATED}
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.github/workflows/ci.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/admin_skip.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/compensation_flow.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/http_and_queue.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/llm_deploy.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/retry_recovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/__init__.py
RENAMED
|
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
|
|
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
|
|
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
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/__init__.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/conftest.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/helpers.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/__init__.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_builder.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_retry.py
RENAMED
|
File without changes
|