python-saga-orchestrator 0.2.3__tar.gz → 0.2.4__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 (88) hide show
  1. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/Makefile +2 -2
  2. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/PKG-INFO +1 -1
  3. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/pyproject.toml +8 -2
  4. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
  5. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/python_saga_orchestrator.egg-info/SOURCES.txt +3 -1
  6. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/_version.py +2 -2
  7. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/context.py +0 -1
  8. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/step.py +62 -15
  9. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/inbox/models.py +1 -2
  10. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/models.py +1 -2
  11. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_context_persistence.py +3 -3
  12. python_saga_orchestrator-0.2.4/tests/unit/test_input_context.py +86 -0
  13. python_saga_orchestrator-0.2.4/tests/unit/test_step_type_resolution.py +130 -0
  14. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/.github/workflows/ci.yml +0 -0
  15. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/.github/workflows/publish.yml +0 -0
  16. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/.gitignore +0 -0
  17. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/Dockerfile +0 -0
  18. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/LICENSE +0 -0
  19. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/README.md +0 -0
  20. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/docker-compose.yaml +0 -0
  21. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/examples/admin_skip.py +0 -0
  22. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/examples/common.py +0 -0
  23. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/examples/compensation_flow.py +0 -0
  24. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/examples/http_and_queue.py +0 -0
  25. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/examples/llm_deploy.py +0 -0
  26. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/examples/retry_recovery.py +0 -0
  27. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  28. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  29. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  30. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/__init__.py +0 -0
  31. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/admin/__init__.py +0 -0
  32. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/admin/api.py +0 -0
  33. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/core/__init__.py +0 -0
  34. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/core/builder.py +0 -0
  35. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/core/engine.py +0 -0
  36. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/core/orchestrator.py +0 -0
  37. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/core/repository.py +0 -0
  38. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/__init__.py +0 -0
  39. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  40. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  41. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  42. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  43. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
  44. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/mixins/types.py +0 -0
  45. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/__init__.py +0 -0
  46. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/builder.py +0 -0
  47. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  48. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  49. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
  50. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -0
  51. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/notify.py +0 -0
  52. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/retry.py +0 -0
  53. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  54. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/inbox/__init__.py +0 -0
  55. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/inbox/contracts.py +0 -0
  56. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/inbox/dispatcher.py +0 -0
  57. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/inbox/repository.py +0 -0
  58. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/inbox/retry.py +0 -0
  59. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/__init__.py +0 -0
  60. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/contracts.py +0 -0
  61. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/dispatcher.py +0 -0
  62. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/event.py +0 -0
  63. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/factory.py +0 -0
  64. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/repository.py +0 -0
  65. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/retry.py +0 -0
  66. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/saga_orchestrator/outbox/serialization.py +0 -0
  67. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/setup.cfg +0 -0
  68. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/task.md +0 -0
  69. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/__init__.py +0 -0
  70. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/conftest.py +0 -0
  71. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/__init__.py +0 -0
  72. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/conftest.py +0 -0
  73. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/helpers.py +0 -0
  74. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/models.py +0 -0
  75. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_admin_api.py +0 -0
  76. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_compensation_flow.py +0 -0
  77. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_core_flow.py +0 -0
  78. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_inbox_flow.py +0 -0
  79. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_lifecycle_hooks.py +0 -0
  80. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_notification_flow.py +0 -0
  81. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_outbox_flow.py +0 -0
  82. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/integration/test_repository.py +0 -0
  83. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/unit/__init__.py +0 -0
  84. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/unit/test_builder.py +0 -0
  85. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/unit/test_inbox_extensibility.py +0 -0
  86. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/unit/test_orchestrator_helpers.py +0 -0
  87. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/unit/test_outbox_extensibility.py +0 -0
  88. {python_saga_orchestrator-0.2.3 → python_saga_orchestrator-0.2.4}/tests/unit/test_retry.py +0 -0
@@ -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
3
+ Version: 0.2.4
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
3
+ Version: 0.2.4
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
@@ -79,6 +79,8 @@ tests/integration/test_repository.py
79
79
  tests/unit/__init__.py
80
80
  tests/unit/test_builder.py
81
81
  tests/unit/test_inbox_extensibility.py
82
+ tests/unit/test_input_context.py
82
83
  tests/unit/test_orchestrator_helpers.py
83
84
  tests/unit/test_outbox_extensibility.py
84
- tests/unit/test_retry.py
85
+ tests/unit/test_retry.py
86
+ 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'
22
- __version_tuple__ = version_tuple = (0, 2, 3)
21
+ __version__ = version = '0.2.4'
22
+ __version_tuple__ = version_tuple = (0, 2, 4)
23
23
 
24
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 --
@@ -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,49 @@ 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(f"Step '{cls.__name__}' inherits from a generic Step "
123
+ "but was not parameterized with concrete Input/Output models.")
124
+
125
+ concrete_input_model, concrete_output_model = concrete_args
126
+ try:
127
+ hints = get_type_hints(cls.execute, globalns=inspect.getmodule(cls).__dict__, include_extras=True)
128
+ except (AttributeError, NameError) as e:
129
+ raise TypeValidationError(
130
+ f"Could not resolve type hints for '{cls.__name__}.execute'. "
131
+ f"Ensure all types are correctly imported. Original error: {e}"
132
+ )
133
+
107
134
 
108
- hints = get_type_hints(cls.execute)
109
135
  if "inp" not in hints or "return" not in hints:
110
136
  raise TypeValidationError(
111
137
  f"Step '{cls.__name__}' must type annotate execute(inp) and return type"
112
138
  )
113
- input_model = hints["inp"]
114
- output_model = cls._resolve_output_model(hints["return"])
139
+
140
+ input_annotation = hints["inp"]
141
+ if isinstance(input_annotation, TypeVar):
142
+ input_model = concrete_input_model
143
+ else:
144
+ input_model = input_annotation
145
+
146
+ return_annotation = hints["return"]
147
+ output_model = cls._resolve_output_model(return_annotation, concrete_output_model)
115
148
  if not (inspect.isclass(input_model) and issubclass(input_model, BaseModel)):
116
149
  raise TypeValidationError(
117
150
  f"Step '{cls.__name__}' input must inherit from pydantic BaseModel"
@@ -120,22 +153,36 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
120
153
  cls.output_model = output_model
121
154
 
122
155
  @staticmethod
123
- def _resolve_output_model(annotation: Any) -> type[BaseModel]:
156
+ def _resolve_output_model(annotation: Any, concrete_model: type[BaseModel] | None = None) -> type[BaseModel]:
157
+ def find_model_in_args(args_tuple: tuple) -> list[type[BaseModel]]:
158
+ candidates = []
159
+ for arg in args_tuple:
160
+ if isinstance(arg, TypeVar) and concrete_model:
161
+ candidates.append(concrete_model)
162
+ elif inspect.isclass(arg) and issubclass(arg, BaseModel):
163
+ candidates.append(arg)
164
+ return list(dict.fromkeys(candidates))
165
+
124
166
  if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
125
167
  return annotation
126
168
 
127
169
  origin = get_origin(annotation)
128
170
  if origin is None:
171
+ if isinstance(annotation, TypeVar) and concrete_model:
172
+ return concrete_model
129
173
  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
- ]
174
+
175
+ args = get_args(annotation)
176
+ model_candidates = find_model_in_args(args)
134
177
  await_candidates = [arg for arg in args if arg is StepAwaitEvent]
178
+
135
179
  if len(model_candidates) == 1 and len(await_candidates) <= 1:
136
180
  return model_candidates[0]
181
+
137
182
  raise TypeValidationError(
138
- "Step execute return type must be BaseModel or BaseModel | StepAwaitEvent"
183
+ f"Step execute return type must be BaseModel or BaseModel | StepAwaitEvent. "
184
+ f"Found models: {[m.__name__ for m in model_candidates]}, "
185
+ f"Found await events: {len(await_candidates)}."
139
186
  )
140
187
 
141
188
  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
@@ -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 (
85
+ input_ctx.latest_event_payload == expected_payload
86
+ ), f"Failed on test: {test_id}"
@@ -0,0 +1,130 @@
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
+
44
+ IntermediateInputT = TypeVar("IntermediateInputT", bound=BaseModel)
45
+ IntermediateOutputT = TypeVar("IntermediateOutputT", bound=BaseModel)
46
+
47
+
48
+ class AbstractIntermediateStep(
49
+ BaseStep[IntermediateInputT, IntermediateOutputT],
50
+ ABC,
51
+ Generic[IntermediateInputT, IntermediateOutputT]
52
+ ):
53
+ @abstractmethod
54
+ def some_abstract_method(self):
55
+ pass
56
+
57
+ async def execute(self, inp: IntermediateInputT) -> IntermediateOutputT | StepAwaitEvent:
58
+ pass
59
+
60
+
61
+ class TestTypeResolution:
62
+
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
+ class FaultyStep(BaseStep[InputModelA, OutputModelA]):
101
+ # `inp` не аннотирован
102
+ async def execute(self, inp) -> OutputModelA:
103
+ pass
104
+
105
+ def test_wrong_parameterization_raises_error(self):
106
+ """
107
+ GIVEN a step class parameterized with a non-BaseModel type
108
+ WHEN the class is defined
109
+ THEN a TypeError or TypeValidationError should be raised.
110
+ """
111
+
112
+ @dataclass
113
+ class NotAModel:
114
+ pass
115
+
116
+ with pytest.raises(TypeValidationError, match="Could not resolve type hints"):
117
+ class FaultyStep(BaseStep[InputModelA, OutputModelA]):
118
+ async def execute(self, inp: NotAModel) -> OutputModelA:
119
+ pass
120
+
121
+ def test_unparameterized_generic_step_raises_error(self):
122
+ """
123
+ GIVEN a concrete step inheriting from a generic abstract step without parameters
124
+ WHEN the class is defined
125
+ THEN TypeValidationError should be raised by our custom logic.
126
+ """
127
+ with pytest.raises(TypeValidationError, match="inherits from a generic Step but was not parameterized with concrete Input/Output models"):
128
+ class UnparameterizedStep(AbstractIntermediateStep):
129
+ def some_abstract_method(self):
130
+ pass