edda-framework 0.1.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.
- edda/__init__.py +56 -0
- edda/activity.py +505 -0
- edda/app.py +996 -0
- edda/compensation.py +326 -0
- edda/context.py +489 -0
- edda/events.py +505 -0
- edda/exceptions.py +64 -0
- edda/hooks.py +284 -0
- edda/locking.py +322 -0
- edda/outbox/__init__.py +15 -0
- edda/outbox/relayer.py +274 -0
- edda/outbox/transactional.py +112 -0
- edda/pydantic_utils.py +316 -0
- edda/replay.py +799 -0
- edda/retry.py +207 -0
- edda/serialization/__init__.py +9 -0
- edda/serialization/base.py +83 -0
- edda/serialization/json.py +102 -0
- edda/storage/__init__.py +9 -0
- edda/storage/models.py +194 -0
- edda/storage/protocol.py +737 -0
- edda/storage/sqlalchemy_storage.py +1809 -0
- edda/viewer_ui/__init__.py +20 -0
- edda/viewer_ui/app.py +1399 -0
- edda/viewer_ui/components.py +1105 -0
- edda/viewer_ui/data_service.py +880 -0
- edda/visualizer/__init__.py +11 -0
- edda/visualizer/ast_analyzer.py +383 -0
- edda/visualizer/mermaid_generator.py +355 -0
- edda/workflow.py +218 -0
- edda_framework-0.1.0.dist-info/METADATA +748 -0
- edda_framework-0.1.0.dist-info/RECORD +35 -0
- edda_framework-0.1.0.dist-info/WHEEL +4 -0
- edda_framework-0.1.0.dist-info/entry_points.txt +2 -0
- edda_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
edda/outbox/relayer.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Outbox Relayer - Background publisher for transactional outbox pattern.
|
|
3
|
+
|
|
4
|
+
This module implements a background task that polls the database for pending
|
|
5
|
+
outbox events and publishes them to a Message Broker as CloudEvents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from cloudevents.conversion import to_structured
|
|
16
|
+
from cloudevents.http import CloudEvent
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from edda.storage.protocol import StorageProtocol
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OutboxRelayer:
|
|
25
|
+
"""
|
|
26
|
+
Background relayer for publishing outbox events.
|
|
27
|
+
|
|
28
|
+
The relayer polls the database for pending events and publishes them
|
|
29
|
+
to a Message Broker. It implements exponential backoff for retries
|
|
30
|
+
and graceful shutdown.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> storage = SQLiteStorage("saga.db")
|
|
34
|
+
>>> relayer = OutboxRelayer(
|
|
35
|
+
... storage=storage,
|
|
36
|
+
... broker_url="http://broker-ingress.svc.cluster.local/default/default",
|
|
37
|
+
... poll_interval=1.0,
|
|
38
|
+
... max_retries=3
|
|
39
|
+
... )
|
|
40
|
+
>>> await relayer.start()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
storage: "StorageProtocol",
|
|
46
|
+
broker_url: str,
|
|
47
|
+
poll_interval: float = 1.0,
|
|
48
|
+
max_retries: int = 3,
|
|
49
|
+
batch_size: int = 10,
|
|
50
|
+
max_age_hours: float | None = None,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the Outbox Relayer.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
storage: Storage backend for outbox events
|
|
57
|
+
broker_url: Message Broker URL for publishing events
|
|
58
|
+
poll_interval: Polling interval in seconds (default: 1.0)
|
|
59
|
+
max_retries: Maximum retry attempts (default: 3)
|
|
60
|
+
batch_size: Number of events to process per batch (default: 10)
|
|
61
|
+
max_age_hours: Maximum event age in hours before expiration (default: None, disabled)
|
|
62
|
+
Events older than this are marked as 'expired' and won't be retried.
|
|
63
|
+
"""
|
|
64
|
+
self.storage = storage
|
|
65
|
+
self.broker_url = broker_url
|
|
66
|
+
self.poll_interval = poll_interval
|
|
67
|
+
self.max_retries = max_retries
|
|
68
|
+
self.batch_size = batch_size
|
|
69
|
+
self.max_age_hours = max_age_hours
|
|
70
|
+
|
|
71
|
+
self._task: asyncio.Task[Any] | None = None
|
|
72
|
+
self._running = False
|
|
73
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
74
|
+
|
|
75
|
+
async def start(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Start the background relayer task.
|
|
78
|
+
|
|
79
|
+
This creates an HTTP client and starts the polling loop in a background task.
|
|
80
|
+
"""
|
|
81
|
+
if self._running:
|
|
82
|
+
logger.warning("Outbox relayer is already running")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
self._running = True
|
|
86
|
+
self._http_client = httpx.AsyncClient(timeout=30.0)
|
|
87
|
+
|
|
88
|
+
# Start background task
|
|
89
|
+
self._task = asyncio.create_task(self._poll_loop())
|
|
90
|
+
logger.info(
|
|
91
|
+
f"Outbox relayer started (broker={self.broker_url}, "
|
|
92
|
+
f"poll_interval={self.poll_interval}s)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def stop(self) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Stop the background relayer task gracefully.
|
|
98
|
+
|
|
99
|
+
This cancels the polling loop and closes the HTTP client.
|
|
100
|
+
"""
|
|
101
|
+
if not self._running:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
self._running = False
|
|
105
|
+
|
|
106
|
+
# Cancel background task
|
|
107
|
+
if self._task:
|
|
108
|
+
self._task.cancel()
|
|
109
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
110
|
+
await self._task
|
|
111
|
+
|
|
112
|
+
# Close HTTP client
|
|
113
|
+
if self._http_client:
|
|
114
|
+
await self._http_client.aclose()
|
|
115
|
+
|
|
116
|
+
logger.info("Outbox relayer stopped")
|
|
117
|
+
|
|
118
|
+
async def _poll_loop(self) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Main polling loop.
|
|
121
|
+
|
|
122
|
+
Continuously polls the database for pending events and publishes them.
|
|
123
|
+
"""
|
|
124
|
+
while self._running:
|
|
125
|
+
try:
|
|
126
|
+
await self._poll_and_publish()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Error in outbox relayer poll loop: {e}")
|
|
129
|
+
|
|
130
|
+
# Wait before next poll
|
|
131
|
+
await asyncio.sleep(self.poll_interval)
|
|
132
|
+
|
|
133
|
+
async def _poll_and_publish(self) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Poll for pending events and publish them.
|
|
136
|
+
|
|
137
|
+
Fetches a batch of pending events from the database and attempts
|
|
138
|
+
to publish each one to the Message Broker.
|
|
139
|
+
"""
|
|
140
|
+
# Get pending events
|
|
141
|
+
events = await self.storage.get_pending_outbox_events(limit=self.batch_size)
|
|
142
|
+
|
|
143
|
+
if not events:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
logger.debug(f"Processing {len(events)} pending outbox events")
|
|
147
|
+
|
|
148
|
+
# Publish each event
|
|
149
|
+
for event in events:
|
|
150
|
+
try:
|
|
151
|
+
await self._publish_event(event)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(
|
|
154
|
+
f"Failed to publish event {event['event_id']}: {e}",
|
|
155
|
+
exc_info=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _publish_event(self, event: dict[str, Any]) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Publish a single outbox event to the Message Broker.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
event: Outbox event record from database
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
Exception: If publishing fails after max retries
|
|
167
|
+
"""
|
|
168
|
+
event_id = event["event_id"]
|
|
169
|
+
retry_count = event["retry_count"]
|
|
170
|
+
|
|
171
|
+
# Check if max age exceeded (optional feature)
|
|
172
|
+
if self.max_age_hours is not None:
|
|
173
|
+
created_at = event["created_at"]
|
|
174
|
+
# Convert to datetime if it's a string (from database)
|
|
175
|
+
if isinstance(created_at, str):
|
|
176
|
+
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
177
|
+
elif not hasattr(created_at, "tzinfo") or created_at.tzinfo is None:
|
|
178
|
+
# Assume UTC if no timezone info
|
|
179
|
+
created_at = created_at.replace(tzinfo=UTC)
|
|
180
|
+
|
|
181
|
+
event_age = datetime.now(UTC) - created_at
|
|
182
|
+
max_age = timedelta(hours=self.max_age_hours)
|
|
183
|
+
|
|
184
|
+
if event_age > max_age:
|
|
185
|
+
logger.warning(
|
|
186
|
+
f"Event {event_id} exceeded max age "
|
|
187
|
+
f"({self.max_age_hours} hours, age={event_age}), "
|
|
188
|
+
"marking as expired"
|
|
189
|
+
)
|
|
190
|
+
await self.storage.mark_outbox_expired(
|
|
191
|
+
event_id,
|
|
192
|
+
f"Exceeded max age ({self.max_age_hours} hours, age={event_age})",
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Check if max retries exceeded
|
|
197
|
+
if retry_count >= self.max_retries:
|
|
198
|
+
logger.warning(
|
|
199
|
+
f"Event {event_id} exceeded max retries ({self.max_retries}), "
|
|
200
|
+
"marking as permanently failed"
|
|
201
|
+
)
|
|
202
|
+
await self.storage.mark_outbox_permanently_failed(
|
|
203
|
+
event_id, f"Exceeded max retries ({self.max_retries})"
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
# Build CloudEvent
|
|
209
|
+
ce = CloudEvent(
|
|
210
|
+
{
|
|
211
|
+
"type": event["event_type"],
|
|
212
|
+
"source": event["event_source"],
|
|
213
|
+
"id": event_id,
|
|
214
|
+
},
|
|
215
|
+
event["event_data"],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Convert to structured format
|
|
219
|
+
headers, body = to_structured(ce)
|
|
220
|
+
|
|
221
|
+
# Publish to Message Broker
|
|
222
|
+
if self._http_client is None:
|
|
223
|
+
raise RuntimeError("HTTP client not initialized")
|
|
224
|
+
|
|
225
|
+
response = await self._http_client.post(
|
|
226
|
+
self.broker_url,
|
|
227
|
+
headers=headers,
|
|
228
|
+
content=body,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Check response
|
|
232
|
+
response.raise_for_status()
|
|
233
|
+
|
|
234
|
+
# Mark as published
|
|
235
|
+
await self.storage.mark_outbox_published(event_id)
|
|
236
|
+
logger.info(f"Successfully published event {event_id}")
|
|
237
|
+
|
|
238
|
+
except httpx.HTTPStatusError as e:
|
|
239
|
+
# HTTP error with status code - distinguish 4xx (client) vs 5xx (server)
|
|
240
|
+
status_code = e.response.status_code
|
|
241
|
+
error_msg = f"HTTP {status_code}: {str(e)}"
|
|
242
|
+
|
|
243
|
+
if 400 <= status_code < 500:
|
|
244
|
+
# Client error (4xx) - permanent failure, don't retry
|
|
245
|
+
logger.error(
|
|
246
|
+
f"Permanent error for event {event_id}: {error_msg}. "
|
|
247
|
+
"Marking as invalid (won't retry)"
|
|
248
|
+
)
|
|
249
|
+
await self.storage.mark_outbox_invalid(event_id, error_msg)
|
|
250
|
+
else:
|
|
251
|
+
# Server error (5xx) - temporary failure, retry
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"Server error for event {event_id} "
|
|
254
|
+
f"(retry {retry_count + 1}/{self.max_retries}): {error_msg}"
|
|
255
|
+
)
|
|
256
|
+
await self.storage.mark_outbox_failed(event_id, error_msg)
|
|
257
|
+
|
|
258
|
+
except httpx.RequestError as e:
|
|
259
|
+
# Network error (connection timeout, DNS failure, etc.) - retry
|
|
260
|
+
error_msg = f"Network error: {str(e)}"
|
|
261
|
+
logger.warning(
|
|
262
|
+
f"Network error for event {event_id} "
|
|
263
|
+
f"(retry {retry_count + 1}/{self.max_retries}): {error_msg}"
|
|
264
|
+
)
|
|
265
|
+
await self.storage.mark_outbox_failed(event_id, error_msg)
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
# Unknown error - retry (safety net)
|
|
269
|
+
error_msg = f"{type(e).__name__}: {str(e)}"
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"Unknown error for event {event_id} "
|
|
272
|
+
f"(retry {retry_count + 1}/{self.max_retries}): {error_msg}"
|
|
273
|
+
)
|
|
274
|
+
await self.storage.mark_outbox_failed(event_id, error_msg)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transactional outbox helpers for reliable event publishing.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for sending events using the transactional
|
|
5
|
+
outbox pattern. Events are written to the database within the same transaction
|
|
6
|
+
as other business logic, ensuring atomicity.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from edda.pydantic_utils import is_pydantic_instance, to_json_dict
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from edda.context import WorkflowContext
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def send_event_transactional(
|
|
24
|
+
ctx: "WorkflowContext",
|
|
25
|
+
event_type: str,
|
|
26
|
+
event_source: str,
|
|
27
|
+
event_data: dict[str, Any] | BaseModel,
|
|
28
|
+
content_type: str = "application/json",
|
|
29
|
+
) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Send an event using the transactional outbox pattern.
|
|
32
|
+
|
|
33
|
+
This function writes an event to the outbox table instead of sending it
|
|
34
|
+
directly. The event will be asynchronously published by the Outbox Relayer.
|
|
35
|
+
|
|
36
|
+
This ensures that event publishing is atomic with the workflow execution:
|
|
37
|
+
- If the workflow fails, the event is not published
|
|
38
|
+
- If the workflow succeeds, the event is guaranteed to be published
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
>>> # With dict
|
|
42
|
+
>>> @activity
|
|
43
|
+
... async def reserve_inventory(ctx: WorkflowContext, order_id: str) -> dict:
|
|
44
|
+
... reservation_id = str(uuid.uuid4())
|
|
45
|
+
... await send_event_transactional(
|
|
46
|
+
... ctx,
|
|
47
|
+
... event_type="inventory.reserved",
|
|
48
|
+
... event_source="order-service",
|
|
49
|
+
... event_data={
|
|
50
|
+
... "order_id": order_id,
|
|
51
|
+
... "reservation_id": reservation_id,
|
|
52
|
+
... }
|
|
53
|
+
... )
|
|
54
|
+
... return {"reservation_id": reservation_id}
|
|
55
|
+
>>>
|
|
56
|
+
>>> # With Pydantic model (automatically converted to JSON)
|
|
57
|
+
>>> @activity
|
|
58
|
+
... async def reserve_inventory_typed(ctx: WorkflowContext, order_id: str) -> dict:
|
|
59
|
+
... reservation_id = str(uuid.uuid4())
|
|
60
|
+
... event = InventoryReserved(
|
|
61
|
+
... order_id=order_id,
|
|
62
|
+
... reservation_id=reservation_id,
|
|
63
|
+
... )
|
|
64
|
+
... await send_event_transactional(
|
|
65
|
+
... ctx,
|
|
66
|
+
... event_type="inventory.reserved",
|
|
67
|
+
... event_source="order-service",
|
|
68
|
+
... event_data=event,
|
|
69
|
+
... )
|
|
70
|
+
... return {"reservation_id": reservation_id}
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
ctx: Workflow context
|
|
74
|
+
event_type: CloudEvent type (e.g., "order.created")
|
|
75
|
+
event_source: CloudEvent source (e.g., "order-service")
|
|
76
|
+
event_data: Event payload (JSON dict or Pydantic model)
|
|
77
|
+
content_type: Content type (defaults to "application/json")
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Event ID (UUID)
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
Exception: If writing to outbox fails
|
|
84
|
+
"""
|
|
85
|
+
# Check if in transaction
|
|
86
|
+
if not ctx.in_transaction():
|
|
87
|
+
logger.warning(
|
|
88
|
+
"send_event_transactional() called outside of a transaction. "
|
|
89
|
+
"Event will still be sent, but atomicity with other operations is not guaranteed. "
|
|
90
|
+
"Consider using @activity (with default transactional=True) or wrapping in ctx.transaction()."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Convert Pydantic model to JSON dict
|
|
94
|
+
event_data_dict: dict[str, Any]
|
|
95
|
+
if is_pydantic_instance(event_data):
|
|
96
|
+
event_data_dict = to_json_dict(event_data)
|
|
97
|
+
else:
|
|
98
|
+
event_data_dict = cast(dict[str, Any], event_data)
|
|
99
|
+
|
|
100
|
+
# Generate event ID
|
|
101
|
+
event_id = str(uuid.uuid4())
|
|
102
|
+
|
|
103
|
+
# Write to outbox table
|
|
104
|
+
await ctx.storage.add_outbox_event(
|
|
105
|
+
event_id=event_id,
|
|
106
|
+
event_type=event_type,
|
|
107
|
+
event_source=event_source,
|
|
108
|
+
event_data=event_data_dict,
|
|
109
|
+
content_type=content_type,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return event_id
|
edda/pydantic_utils.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Pydantic integration utilities for Edda framework.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for detecting Pydantic models and converting
|
|
4
|
+
between Pydantic models and JSON-compatible dictionaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import types
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, TypeVar, Union, cast, get_args, get_origin
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=BaseModel)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_pydantic_model(obj: Any) -> bool:
|
|
17
|
+
"""Check if an object is a Pydantic model class.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
obj: Object to check
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if obj is a Pydantic model class, False otherwise
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
>>> from pydantic import BaseModel
|
|
27
|
+
>>> class User(BaseModel):
|
|
28
|
+
... name: str
|
|
29
|
+
>>> is_pydantic_model(User)
|
|
30
|
+
True
|
|
31
|
+
>>> is_pydantic_model(User(name="Alice"))
|
|
32
|
+
False
|
|
33
|
+
>>> is_pydantic_model(str)
|
|
34
|
+
False
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
return isinstance(obj, type) and issubclass(obj, BaseModel)
|
|
38
|
+
except TypeError:
|
|
39
|
+
# issubclass raises TypeError for non-class objects
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_pydantic_instance(obj: Any) -> bool:
|
|
44
|
+
"""Check if an object is a Pydantic model instance.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
obj: Object to check
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if obj is a Pydantic model instance, False otherwise
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
>>> from pydantic import BaseModel
|
|
54
|
+
>>> class User(BaseModel):
|
|
55
|
+
... name: str
|
|
56
|
+
>>> is_pydantic_instance(User(name="Alice"))
|
|
57
|
+
True
|
|
58
|
+
>>> is_pydantic_instance(User)
|
|
59
|
+
False
|
|
60
|
+
>>> is_pydantic_instance("Alice")
|
|
61
|
+
False
|
|
62
|
+
"""
|
|
63
|
+
return isinstance(obj, BaseModel)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def to_json_dict(obj: Any) -> Any:
|
|
67
|
+
"""Convert a Pydantic model or Enum to a JSON-compatible value.
|
|
68
|
+
|
|
69
|
+
Recursively handles lists and dicts containing Pydantic models or Enums.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
obj: Object to convert (Pydantic model, Enum, list, dict, or primitive)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
JSON-compatible value (dict for Pydantic, primitive for Enum, or original value)
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
>>> from pydantic import BaseModel
|
|
79
|
+
>>> from enum import Enum
|
|
80
|
+
>>> class User(BaseModel):
|
|
81
|
+
... name: str
|
|
82
|
+
... age: int
|
|
83
|
+
>>> class Status(Enum):
|
|
84
|
+
... ACTIVE = "active"
|
|
85
|
+
>>> user = User(name="Alice", age=30)
|
|
86
|
+
>>> to_json_dict(user)
|
|
87
|
+
{'name': 'Alice', 'age': 30}
|
|
88
|
+
>>> to_json_dict(Status.ACTIVE)
|
|
89
|
+
'active'
|
|
90
|
+
>>> to_json_dict([user])
|
|
91
|
+
[{'name': 'Alice', 'age': 30}]
|
|
92
|
+
>>> to_json_dict({'name': 'Bob'})
|
|
93
|
+
{'name': 'Bob'}
|
|
94
|
+
"""
|
|
95
|
+
if is_pydantic_instance(obj):
|
|
96
|
+
return obj.model_dump(mode="json")
|
|
97
|
+
if isinstance(obj, Enum):
|
|
98
|
+
return obj.value
|
|
99
|
+
if isinstance(obj, list):
|
|
100
|
+
return [to_json_dict(item) for item in obj]
|
|
101
|
+
if isinstance(obj, dict):
|
|
102
|
+
return {key: to_json_dict(value) for key, value in obj.items()}
|
|
103
|
+
return obj
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def from_json_dict(data: dict[str, Any], model: type[T]) -> T:
|
|
107
|
+
"""Convert a JSON dictionary to a Pydantic model instance.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
data: JSON-compatible dictionary
|
|
111
|
+
model: Pydantic model class
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Pydantic model instance
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValidationError: If data does not match model schema
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> from pydantic import BaseModel
|
|
121
|
+
>>> class User(BaseModel):
|
|
122
|
+
... name: str
|
|
123
|
+
... age: int
|
|
124
|
+
>>> data = {'name': 'Alice', 'age': 30}
|
|
125
|
+
>>> user = from_json_dict(data, User)
|
|
126
|
+
>>> user.name
|
|
127
|
+
'Alice'
|
|
128
|
+
>>> user.age
|
|
129
|
+
30
|
|
130
|
+
"""
|
|
131
|
+
return model.model_validate(data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_pydantic_model_from_annotation(annotation: Any) -> type[BaseModel] | None:
|
|
135
|
+
"""Extract Pydantic model class from a type annotation.
|
|
136
|
+
|
|
137
|
+
Handles various annotation patterns:
|
|
138
|
+
- Direct: User
|
|
139
|
+
- Optional: User | None, Optional[User]
|
|
140
|
+
- Generic: list[User], dict[str, User]
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
annotation: Type annotation to analyze
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Pydantic model class if found, None otherwise
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
>>> from pydantic import BaseModel
|
|
150
|
+
>>> from typing import Optional
|
|
151
|
+
>>> class User(BaseModel):
|
|
152
|
+
... name: str
|
|
153
|
+
>>> extract_pydantic_model_from_annotation(User)
|
|
154
|
+
<class 'User'>
|
|
155
|
+
>>> extract_pydantic_model_from_annotation(User | None)
|
|
156
|
+
<class 'User'>
|
|
157
|
+
>>> extract_pydantic_model_from_annotation(Optional[User])
|
|
158
|
+
<class 'User'>
|
|
159
|
+
>>> extract_pydantic_model_from_annotation(str)
|
|
160
|
+
None
|
|
161
|
+
"""
|
|
162
|
+
# Direct Pydantic model
|
|
163
|
+
if is_pydantic_model(annotation):
|
|
164
|
+
return cast(type[BaseModel], annotation)
|
|
165
|
+
|
|
166
|
+
# Handle Union types (e.g., User | None, Optional[User])
|
|
167
|
+
# Only check for Union types, not other generics (list, dict, etc.)
|
|
168
|
+
origin = get_origin(annotation)
|
|
169
|
+
# Check for typing.Union (Optional[X]) and types.UnionType (X | Y in Python 3.10+)
|
|
170
|
+
if origin is Union or origin is types.UnionType:
|
|
171
|
+
args = get_args(annotation)
|
|
172
|
+
for arg in args:
|
|
173
|
+
if is_pydantic_model(arg):
|
|
174
|
+
return cast(type[BaseModel], arg)
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ============================================================================
|
|
180
|
+
# Enum utilities
|
|
181
|
+
# ============================================================================
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_enum_class(obj: Any) -> bool:
|
|
185
|
+
"""Check if an object is an Enum class.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
obj: Object to check
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if obj is an Enum class, False otherwise
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
>>> from enum import Enum
|
|
195
|
+
>>> class Status(Enum):
|
|
196
|
+
... ACTIVE = "active"
|
|
197
|
+
>>> is_enum_class(Status)
|
|
198
|
+
True
|
|
199
|
+
>>> is_enum_class(Status.ACTIVE)
|
|
200
|
+
False
|
|
201
|
+
>>> is_enum_class(str)
|
|
202
|
+
False
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
return isinstance(obj, type) and issubclass(obj, Enum)
|
|
206
|
+
except TypeError:
|
|
207
|
+
# issubclass raises TypeError for non-class objects
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_enum_instance(obj: Any) -> bool:
|
|
212
|
+
"""Check if an object is an Enum instance.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
obj: Object to check
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if obj is an Enum instance, False otherwise
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
>>> from enum import Enum
|
|
222
|
+
>>> class Status(Enum):
|
|
223
|
+
... ACTIVE = "active"
|
|
224
|
+
>>> is_enum_instance(Status.ACTIVE)
|
|
225
|
+
True
|
|
226
|
+
>>> is_enum_instance(Status)
|
|
227
|
+
False
|
|
228
|
+
>>> is_enum_instance("active")
|
|
229
|
+
False
|
|
230
|
+
"""
|
|
231
|
+
return isinstance(obj, Enum)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def extract_enum_from_annotation(annotation: Any) -> type[Enum] | None:
|
|
235
|
+
"""Extract Enum class from a type annotation.
|
|
236
|
+
|
|
237
|
+
Handles various annotation patterns:
|
|
238
|
+
- Direct: Status
|
|
239
|
+
- Optional: Status | None, Optional[Status]
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
annotation: Type annotation to analyze
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Enum class if found, None otherwise
|
|
246
|
+
|
|
247
|
+
Examples:
|
|
248
|
+
>>> from enum import Enum
|
|
249
|
+
>>> from typing import Optional
|
|
250
|
+
>>> class Status(Enum):
|
|
251
|
+
... ACTIVE = "active"
|
|
252
|
+
>>> extract_enum_from_annotation(Status)
|
|
253
|
+
<class 'Status'>
|
|
254
|
+
>>> extract_enum_from_annotation(Status | None)
|
|
255
|
+
<class 'Status'>
|
|
256
|
+
>>> extract_enum_from_annotation(Optional[Status])
|
|
257
|
+
<class 'Status'>
|
|
258
|
+
>>> extract_enum_from_annotation(str)
|
|
259
|
+
None
|
|
260
|
+
"""
|
|
261
|
+
# Direct Enum class
|
|
262
|
+
if is_enum_class(annotation):
|
|
263
|
+
return cast(type[Enum], annotation)
|
|
264
|
+
|
|
265
|
+
# Handle Union types (e.g., Status | None, Optional[Status])
|
|
266
|
+
origin = get_origin(annotation)
|
|
267
|
+
# Check for typing.Union (Optional[X]) and types.UnionType (X | Y in Python 3.10+)
|
|
268
|
+
if origin is Union or origin is types.UnionType:
|
|
269
|
+
args = get_args(annotation)
|
|
270
|
+
for arg in args:
|
|
271
|
+
if is_enum_class(arg):
|
|
272
|
+
return cast(type[Enum], arg)
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def enum_value_to_enum(value: Any, enum_class: type[Enum]) -> Enum:
|
|
278
|
+
"""Convert a value (str or int) to Enum instance.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
value: Value to convert (must match an Enum member's value)
|
|
282
|
+
enum_class: Enum class to convert to
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Enum instance
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ValueError: If value does not match any Enum member
|
|
289
|
+
|
|
290
|
+
Examples:
|
|
291
|
+
>>> from enum import Enum
|
|
292
|
+
>>> class Status(Enum):
|
|
293
|
+
... ACTIVE = "active"
|
|
294
|
+
... INACTIVE = "inactive"
|
|
295
|
+
>>> enum_value_to_enum("active", Status)
|
|
296
|
+
<Status.ACTIVE: 'active'>
|
|
297
|
+
>>> enum_value_to_enum("invalid", Status)
|
|
298
|
+
Traceback (most recent call last):
|
|
299
|
+
...
|
|
300
|
+
ValueError: Cannot convert 'invalid' to Status
|
|
301
|
+
"""
|
|
302
|
+
# Try by value first (most common case)
|
|
303
|
+
for member in enum_class:
|
|
304
|
+
if member.value == value:
|
|
305
|
+
return member
|
|
306
|
+
|
|
307
|
+
# Try by name (fallback for string values matching member names)
|
|
308
|
+
if isinstance(value, str):
|
|
309
|
+
# Try exact match first
|
|
310
|
+
if hasattr(enum_class, value):
|
|
311
|
+
return cast(Enum, getattr(enum_class, value))
|
|
312
|
+
# Try uppercase
|
|
313
|
+
if hasattr(enum_class, value.upper()):
|
|
314
|
+
return cast(Enum, getattr(enum_class, value.upper()))
|
|
315
|
+
|
|
316
|
+
raise ValueError(f"Cannot convert {value!r} to {enum_class.__name__}")
|