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.
Files changed (92) hide show
  1. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.github/workflows/publish.yml +4 -0
  2. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/Makefile +2 -2
  3. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/PKG-INFO +1 -1
  4. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/pyproject.toml +8 -2
  5. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
  6. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/SOURCES.txt +4 -1
  7. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/_version.py +3 -3
  8. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/context.py +0 -1
  9. python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/base_str_enum.py +10 -0
  10. python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/saga_status.py +17 -0
  11. python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/saga_step_phase.py +9 -0
  12. python_saga_orchestrator-0.3.0/saga_orchestrator/domain/models/enums/saga_step_status.py +9 -0
  13. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/step.py +71 -15
  14. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/models.py +1 -2
  15. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/models.py +1 -2
  16. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_admin_api.py +3 -3
  17. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_context_persistence.py +3 -3
  18. python_saga_orchestrator-0.3.0/tests/unit/test_input_context.py +86 -0
  19. python_saga_orchestrator-0.3.0/tests/unit/test_step_type_resolution.py +136 -0
  20. python_saga_orchestrator-0.2.3.dev0/saga_orchestrator/domain/models/enums/saga_status.py +0 -15
  21. python_saga_orchestrator-0.2.3.dev0/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -7
  22. python_saga_orchestrator-0.2.3.dev0/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -7
  23. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.github/workflows/ci.yml +0 -0
  24. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/.gitignore +0 -0
  25. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/Dockerfile +0 -0
  26. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/LICENSE +0 -0
  27. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/README.md +0 -0
  28. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/docker-compose.yaml +0 -0
  29. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/admin_skip.py +0 -0
  30. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/common.py +0 -0
  31. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/compensation_flow.py +0 -0
  32. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/http_and_queue.py +0 -0
  33. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/llm_deploy.py +0 -0
  34. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/examples/retry_recovery.py +0 -0
  35. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  36. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  37. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  38. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/__init__.py +0 -0
  39. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/admin/__init__.py +0 -0
  40. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/admin/api.py +0 -0
  41. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/__init__.py +0 -0
  42. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/builder.py +0 -0
  43. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/engine.py +0 -0
  44. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/orchestrator.py +0 -0
  45. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/core/repository.py +0 -0
  46. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/__init__.py +0 -0
  47. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  48. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  49. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  50. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  51. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
  52. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/mixins/types.py +0 -0
  53. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/__init__.py +0 -0
  54. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/builder.py +0 -0
  55. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  56. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/notify.py +0 -0
  57. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/retry.py +0 -0
  58. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  59. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/__init__.py +0 -0
  60. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/contracts.py +0 -0
  61. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
  62. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/repository.py +0 -0
  63. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/inbox/retry.py +0 -0
  64. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/__init__.py +0 -0
  65. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/contracts.py +0 -0
  66. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
  67. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/event.py +0 -0
  68. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/factory.py +0 -0
  69. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/repository.py +0 -0
  70. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/retry.py +0 -0
  71. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/saga_orchestrator/outbox/serialization.py +0 -0
  72. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/setup.cfg +0 -0
  73. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/task.md +0 -0
  74. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/__init__.py +0 -0
  75. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/conftest.py +0 -0
  76. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/__init__.py +0 -0
  77. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/conftest.py +0 -0
  78. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/helpers.py +0 -0
  79. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/models.py +0 -0
  80. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_compensation_flow.py +0 -0
  81. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_core_flow.py +0 -0
  82. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_inbox_flow.py +0 -0
  83. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_lifecycle_hooks.py +0 -0
  84. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_notification_flow.py +0 -0
  85. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_outbox_flow.py +0 -0
  86. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/integration/test_repository.py +0 -0
  87. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/__init__.py +0 -0
  88. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_builder.py +0 -0
  89. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_inbox_extensibility.py +0 -0
  90. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_orchestrator_helpers.py +0 -0
  91. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_outbox_extensibility.py +0 -0
  92. {python_saga_orchestrator-0.2.3.dev0 → python_saga_orchestrator-0.3.0}/tests/unit/test_retry.py +0 -0
@@ -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}"
@@ -5,8 +5,8 @@ test: tests
5
5
 
6
6
 
7
7
  format:
8
- @isort .
9
- @black .
8
+ @ruff format .
9
+ @ruff check . --fix
10
10
 
11
11
  tests:
12
12
  @docker compose up --build --abort-on-container-exit
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.2.3.dev0
3
+ Version: 0.3.0
4
4
  Summary: Lightweight embedded saga orchestrator for asyncio Python services
5
5
  Author-email: Maxim Vasilyev <mayxis@inbox.ru>
6
6
  License-Expression: MIT
@@ -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
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.2.3.dev0
3
+ Version: 0.3.0
4
4
  Summary: Lightweight embedded saga orchestrator for asyncio Python services
5
5
  Author-email: Maxim Vasilyev <mayxis@inbox.ru>
6
6
  License-Expression: MIT
@@ -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
@@ -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.2.3.dev0'
22
- __version_tuple__ = version_tuple = (0, 2, 3, 'dev0')
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g4268b7950'
24
+ __commit_id__ = commit_id = None
@@ -55,7 +55,6 @@ class SagaStepHistoryEntry(BaseModel):
55
55
 
56
56
 
57
57
  class SagaContext(BaseModel):
58
-
59
58
  model_config = ConfigDict(from_attributes=True, validate_assignment=True)
60
59
 
61
60
  # -- Core data --
@@ -0,0 +1,10 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class BaseStrEnum(StrEnum):
5
+ @staticmethod
6
+ def _generate_next_value_(name, start, count, last_values) -> str:
7
+ """
8
+ Return the upper-cased version of the member name.
9
+ """
10
+ return name.upper()
@@ -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}
@@ -0,0 +1,9 @@
1
+ from enum import auto
2
+
3
+ from .base_str_enum import BaseStrEnum
4
+
5
+
6
+ class SagaStepPhase(BaseStrEnum):
7
+ COMPENSATE = auto()
8
+ SUCCESS = auto()
9
+ EXECUTE = auto()
@@ -0,0 +1,9 @@
1
+ from enum import auto
2
+
3
+ from .base_str_enum import BaseStrEnum
4
+
5
+
6
+ class SagaStepStatus(BaseStrEnum):
7
+ SUCCESS = auto()
8
+ ERROR = auto()
9
+ WAITING = auto()
@@ -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
- with contextlib.suppress(KeyError, TypeError):
64
- return self.context["latest_event_meta"]["event_type"]
65
- return None
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.get("latest_event")
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
- input_model = hints["inp"]
114
- output_model = cls._resolve_output_model(hints["return"])
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(annotation: Any) -> type[BaseModel]:
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
- args = tuple(arg for arg in get_args(annotation) if arg is not type(None))
131
- model_candidates = [
132
- arg for arg in args if inspect.isclass(arg) and issubclass(arg, BaseModel)
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 == "compensate" for entry in state.step_history)
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 == "compensate" for entry in state_after.step_history)
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 == "success"
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
- rehydrated_entry, IntegrationSagaHistory
282
- ), f"Expected IntegrationSagaHistory, got {type(rehydrated_entry)}"
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}
@@ -1,7 +0,0 @@
1
- from enum import StrEnum, auto
2
-
3
-
4
- class SagaStepPhase(StrEnum):
5
- COMPENSATE = auto()
6
- SUCCESS = auto()
7
- EXECUTE = auto()
@@ -1,7 +0,0 @@
1
- from enum import StrEnum, auto
2
-
3
-
4
- class SagaStepStatus(StrEnum):
5
- SUCCESS = auto()
6
- ERROR = auto()
7
- WAITING = auto()