python-saga-orchestrator 0.2.3.dev0__py3-none-any.whl → 0.3.0__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.
@@ -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
@@ -1,6 +1,6 @@
1
- python_saga_orchestrator-0.2.3.dev0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
1
+ python_saga_orchestrator-0.3.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
2
2
  saga_orchestrator/__init__.py,sha256=FG7zoYhCzpZC_JEsy3ebDraH2ZZ9q3IGIjTGKqBzcF4,2836
3
- saga_orchestrator/_version.py,sha256=b8lE-OZpo51B0ODVlQA6zcNoIpJ5YIH4JrzxWeFFTE0,533
3
+ saga_orchestrator/_version.py,sha256=RCY04NY0kBCyn6ttH6lnVL6Gz2gceOrt45f0yDOOU8A,520
4
4
  saga_orchestrator/admin/__init__.py,sha256=TKwKTM7IieI4nlMAbJ0O0OI0KPKfwbclVffNjRtIyAg,80
5
5
  saga_orchestrator/admin/api.py,sha256=u_eLELUlaHpEiuHqweNHzBwSYCtotERAiOxyVtUMe2I,1715
6
6
  saga_orchestrator/core/__init__.py,sha256=EsUqhbO_CgCYZz0yBnf6OUXH3N-_uxMod4A7yGzvbMY,264
@@ -17,19 +17,20 @@ saga_orchestrator/domain/mixins/saga_step_histrory.py,sha256=phB_oue8ksMMtDiNSER
17
17
  saga_orchestrator/domain/mixins/types.py,sha256=FlKee4yNq6y1lXX3W_SeE385udNNg-oZqMxqVXZoEpo,2526
18
18
  saga_orchestrator/domain/models/__init__.py,sha256=vgUSmwVdPhKNijFZsfmZ7mQZD8DJgCV4s4hMVQZNlwE,853
19
19
  saga_orchestrator/domain/models/builder.py,sha256=D3mVYEJT7QJ0e2dNrR2UNHT5xhRK4te6s5WchU57EIA,816
20
- saga_orchestrator/domain/models/context.py,sha256=a0nNYTW0S73Qk5ln85Ji9_siEe38qlb9GPH8XpL0Gi8,3886
20
+ saga_orchestrator/domain/models/context.py,sha256=LF-6sjB70J3adrLQFz_zgEfRaErJA9Ci5TxIfYfkePA,3885
21
21
  saga_orchestrator/domain/models/notify.py,sha256=SuZILSFNAWcUQtjL-I_c4K6VFJXXhD5LLLC2KoMq8dw,837
22
22
  saga_orchestrator/domain/models/retry.py,sha256=UM2ZrSGKDZRiPMj0qOGp36xiTr78CVKsceXFFxtiOug,1362
23
23
  saga_orchestrator/domain/models/saga_snapshot.py,sha256=ysnBVB2rw059pC07Py_xzAA-Bv7d1h6ZZuubM_Fv0_4,867
24
- saga_orchestrator/domain/models/step.py,sha256=-4hWbH2xZpGSI-xFQYHI1SoucXe4Xyq5vQ3t0DH0rQ4,4854
24
+ saga_orchestrator/domain/models/step.py,sha256=BTR9wGTg3r6dcPaf3WoOyyMzajREOZAf_50psp-0eEg,7052
25
25
  saga_orchestrator/domain/models/enums/__init__.py,sha256=IFgCFvhHiQl1MSCN_I_w3O8yR94tZvN5Az8M3V3bXEQ,234
26
- saga_orchestrator/domain/models/enums/saga_status.py,sha256=Evz7Uy4-KrqGWFkb6RHhONCWLePd5Gjezw5u-FLmxw0,397
27
- saga_orchestrator/domain/models/enums/saga_step_phase.py,sha256=L73jg8q-IdDXPpMjuMRwhfVZbAMYW_42FkdXNKqwiqI,129
28
- saga_orchestrator/domain/models/enums/saga_step_status.py,sha256=DZN5qgDorhdFbhI6t6WXAu4xMpj7DE53OJ707wqj4RM,125
26
+ saga_orchestrator/domain/models/enums/base_str_enum.py,sha256=IsGQHXPav6EA8ACWhFIeEz7Y3PMi497l1Abx_T3mBog,255
27
+ saga_orchestrator/domain/models/enums/saga_status.py,sha256=_yLTAur47_6W-AXbmdXHfHzqp_GGcTBHWBiLITksjR0,390
28
+ saga_orchestrator/domain/models/enums/saga_step_phase.py,sha256=CuRhpy_3lDpUqhnPqNNW2ZcE-iQOUcp_sCTaNPooM0Q,164
29
+ saga_orchestrator/domain/models/enums/saga_step_status.py,sha256=dpqaDpxNgUXk3fV8HCQPnC5gDsru2d_Nki4OryAH-Pg,160
29
30
  saga_orchestrator/inbox/__init__.py,sha256=uoLytCCT-2-zr369ZpGmQOB_D63fmwjV9T6sCHYGk64,656
30
31
  saga_orchestrator/inbox/contracts.py,sha256=unV7h1Eqil6sZhmpGVE_-9c_voRQhYA3KdE63T79s4U,1768
31
32
  saga_orchestrator/inbox/dispatcher.py,sha256=O4Zzi5fRAZIUQqlk3oJof6rl7_CPzvqSVQ_Un-1WkOY,4323
32
- saga_orchestrator/inbox/models.py,sha256=i6ANKTqDTanmpA2hp5V5pky06u5DoHfo2oCWCsTVQUI,2654
33
+ saga_orchestrator/inbox/models.py,sha256=MBID-3wBepnIetwMMAU22sD9olG_LpNu9K0LyOo_m5c,2632
33
34
  saga_orchestrator/inbox/repository.py,sha256=BdcVlBW9zvM7WrsL2S3Rmihmn-Ri6gr69L8ji7qqip8,5286
34
35
  saga_orchestrator/inbox/retry.py,sha256=jTxg6tZBl7Vllt89Yw-QdOSObYWXrZWjAyW36mCJHdg,590
35
36
  saga_orchestrator/outbox/__init__.py,sha256=g-isr2D737wMV1Mf_vEPFoS34YE6n6l09iKLLAL86uU,917
@@ -37,11 +38,11 @@ saga_orchestrator/outbox/contracts.py,sha256=KhqIDIDvwA26xu4VQJpU6qg2ySmsRAORest
37
38
  saga_orchestrator/outbox/dispatcher.py,sha256=BX9WTuTLx1gfu-5jV8_ejpi9nnEilnKLYY_BljJkxC8,3196
38
39
  saga_orchestrator/outbox/event.py,sha256=Kj-u_JO55jx3YMTpA9arIvmTgKEAxhH2-WQE1eTi4KU,273
39
40
  saga_orchestrator/outbox/factory.py,sha256=_p7XT_ulouBhl9J9fcgWsZkhkjmzkpfIs9m6E55UPdA,1743
40
- saga_orchestrator/outbox/models.py,sha256=ei8Q1b9NnjAWq5qm0vAIeuj22yWVOFx0UsqGszZA0Q0,2355
41
+ saga_orchestrator/outbox/models.py,sha256=BZRAxfKXPun_qpOG3rzG3QI6AxsW0cVrio6ec-yZjFI,2333
41
42
  saga_orchestrator/outbox/repository.py,sha256=A3nCi5UOwQT9mlEY03BrLidyO2sFOQGgYcE4pFeY-pA,4786
42
43
  saga_orchestrator/outbox/retry.py,sha256=9Ygz3I0HK0r9imTYevBgz14TkAZphKiOSf0grJpH99c,599
43
44
  saga_orchestrator/outbox/serialization.py,sha256=CDicJS95CHhLP47XukJAgfm0baZ83daWXQQF3MyczDo,1687
44
- python_saga_orchestrator-0.2.3.dev0.dist-info/METADATA,sha256=TKDQ42mOx7sTrACtJtxs60bcDE0AhWQNH8JZz-6c7Lo,10293
45
- python_saga_orchestrator-0.2.3.dev0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
46
- python_saga_orchestrator-0.2.3.dev0.dist-info/top_level.txt,sha256=XBp_2J8dacJGCoVxIDaUYhSEuOusCN3BD_uhEjBEEBA,18
47
- python_saga_orchestrator-0.2.3.dev0.dist-info/RECORD,,
45
+ python_saga_orchestrator-0.3.0.dist-info/METADATA,sha256=E7vSU3oOd4EfTRfRQkI_kKfgOUho6nE1chRQVDVypmA,10288
46
+ python_saga_orchestrator-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
47
+ python_saga_orchestrator-0.3.0.dist-info/top_level.txt,sha256=XBp_2J8dacJGCoVxIDaUYhSEuOusCN3BD_uhEjBEEBA,18
48
+ python_saga_orchestrator-0.3.0.dist-info/RECORD,,
@@ -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
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()
@@ -1,14 +1,16 @@
1
- from enum import StrEnum
1
+ from enum import auto
2
2
 
3
+ from .base_str_enum import BaseStrEnum
3
4
 
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"
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()
12
14
 
13
15
  @property
14
16
  def is_terminal(self) -> bool:
@@ -1,7 +1,9 @@
1
- from enum import StrEnum, auto
1
+ from enum import auto
2
2
 
3
+ from .base_str_enum import BaseStrEnum
3
4
 
4
- class SagaStepPhase(StrEnum):
5
+
6
+ class SagaStepPhase(BaseStrEnum):
5
7
  COMPENSATE = auto()
6
8
  SUCCESS = auto()
7
9
  EXECUTE = auto()
@@ -1,7 +1,9 @@
1
- from enum import StrEnum, auto
1
+ from enum import auto
2
2
 
3
+ from .base_str_enum import BaseStrEnum
3
4
 
4
- class SagaStepStatus(StrEnum):
5
+
6
+ class SagaStepStatus(BaseStrEnum):
5
7
  SUCCESS = auto()
6
8
  ERROR = auto()
7
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