edda-framework 0.9.0__py3-none-any.whl → 0.10.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.
@@ -8,6 +8,7 @@ and transactional outbox pattern.
8
8
 
9
9
  import json
10
10
  import logging
11
+ import re
11
12
  from collections.abc import AsyncIterator, Awaitable, Callable
12
13
  from contextlib import asynccontextmanager
13
14
  from contextvars import ContextVar
@@ -511,14 +512,43 @@ class SQLAlchemyStorage:
511
512
  - Automatic transaction management via @activity decorator
512
513
  """
513
514
 
514
- def __init__(self, engine: AsyncEngine):
515
+ def __init__(
516
+ self,
517
+ engine: AsyncEngine,
518
+ notify_listener: Any | None = None,
519
+ ):
515
520
  """
516
521
  Initialize SQLAlchemy storage.
517
522
 
518
523
  Args:
519
524
  engine: SQLAlchemy AsyncEngine instance
525
+ notify_listener: Optional notify listener for PostgreSQL LISTEN/NOTIFY.
526
+ If provided and PostgreSQL is used, NOTIFY messages
527
+ will be sent after key operations.
520
528
  """
521
529
  self.engine = engine
530
+ self._notify_listener = notify_listener
531
+
532
+ @property
533
+ def _is_postgresql(self) -> bool:
534
+ """Check if the database is PostgreSQL."""
535
+ return self.engine.dialect.name == "postgresql"
536
+
537
+ @property
538
+ def _notify_enabled(self) -> bool:
539
+ """Check if NOTIFY is enabled (PostgreSQL with listener)."""
540
+ return self._is_postgresql and self._notify_listener is not None
541
+
542
+ def set_notify_listener(self, listener: Any) -> None:
543
+ """Set the notify listener after initialization.
544
+
545
+ This allows setting the listener after EddaApp creates the storage,
546
+ useful for dependency injection patterns.
547
+
548
+ Args:
549
+ listener: NotifyProtocol implementation (PostgresNotifyListener or NoopNotifyListener)
550
+ """
551
+ self._notify_listener = listener
522
552
 
523
553
  async def initialize(self) -> None:
524
554
  """Initialize database connection and create tables.
@@ -544,6 +574,39 @@ class SQLAlchemyStorage:
544
574
  """Close database connection."""
545
575
  await self.engine.dispose()
546
576
 
577
+ async def _send_notify(
578
+ self,
579
+ channel: str,
580
+ payload: dict[str, Any],
581
+ ) -> None:
582
+ """Send PostgreSQL NOTIFY message.
583
+
584
+ This method sends a notification on the specified channel with the given
585
+ payload. It's a no-op if NOTIFY is not enabled (non-PostgreSQL or no listener).
586
+
587
+ Args:
588
+ channel: PostgreSQL NOTIFY channel name (max 63 chars).
589
+ payload: Dictionary to serialize as JSON payload (max ~7500 bytes).
590
+ """
591
+ if not self._notify_enabled:
592
+ return
593
+
594
+ try:
595
+ import json as json_module
596
+
597
+ payload_str = json_module.dumps(payload, separators=(",", ":"))
598
+
599
+ # Use a separate connection for NOTIFY to avoid transaction issues
600
+ async with self.engine.connect() as conn:
601
+ await conn.execute(
602
+ text("SELECT pg_notify(:channel, :payload)"),
603
+ {"channel": channel, "payload": payload_str},
604
+ )
605
+ await conn.commit()
606
+ except Exception as e:
607
+ # Log but don't fail - polling will catch it as backup
608
+ logger.warning(f"Failed to send NOTIFY on channel {channel}: {e}")
609
+
547
610
  async def _initialize_schema_version(self) -> None:
548
611
  """Initialize schema version for a fresh database."""
549
612
  async with AsyncSession(self.engine) as session:
@@ -849,6 +912,60 @@ class SQLAlchemyStorage:
849
912
  # PostgreSQL/MySQL: column is already timezone-aware
850
913
  return column
851
914
 
915
+ def _validate_json_path(self, json_path: str) -> bool:
916
+ """
917
+ Validate JSON path to prevent SQL injection.
918
+
919
+ Args:
920
+ json_path: JSON path string (e.g., "order_id" or "customer.email")
921
+
922
+ Returns:
923
+ True if valid, False otherwise
924
+ """
925
+ # Only allow alphanumeric characters, dots, and underscores
926
+ # Must start with letter or underscore
927
+ return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_.]*$", json_path))
928
+
929
+ def _build_json_extract_expr(self, column: Any, json_path: str) -> Any:
930
+ """
931
+ Build a database-agnostic JSON extraction expression.
932
+
933
+ Args:
934
+ column: SQLAlchemy column containing JSON text
935
+ json_path: Dot-notation path (e.g., "order_id" or "customer.email")
936
+
937
+ Returns:
938
+ SQLAlchemy expression that extracts the value at json_path
939
+
940
+ Raises:
941
+ ValueError: If json_path is invalid or dialect is unsupported
942
+ """
943
+ if not self._validate_json_path(json_path):
944
+ raise ValueError(f"Invalid JSON path: {json_path}")
945
+
946
+ full_path = f"$.{json_path}"
947
+ dialect = self.engine.dialect.name
948
+
949
+ if dialect == "sqlite":
950
+ # SQLite: json_extract(column, '$.key')
951
+ return func.json_extract(column, full_path)
952
+ elif dialect == "postgresql":
953
+ # PostgreSQL: For nested paths, use #>> operator with array path
954
+ # For simple paths, use ->> operator
955
+ # Since we're dealing with Text column, we need to cast first
956
+ if "." in json_path:
957
+ # Nested: (column)::json #>> '{customer,email}'
958
+ path_array = "{" + json_path.replace(".", ",") + "}"
959
+ return text(f"(input_data)::json #>> '{path_array}'")
960
+ else:
961
+ # Simple: (column)::json->>'key'
962
+ return text(f"(input_data)::json->>'{json_path}'")
963
+ elif dialect == "mysql":
964
+ # MySQL: JSON_UNQUOTE(JSON_EXTRACT(column, '$.key'))
965
+ return func.JSON_UNQUOTE(func.JSON_EXTRACT(column, full_path))
966
+ else:
967
+ raise ValueError(f"Unsupported database dialect: {dialect}")
968
+
852
969
  # -------------------------------------------------------------------------
853
970
  # Transaction Management Methods
854
971
  # -------------------------------------------------------------------------
@@ -1200,6 +1317,7 @@ class SQLAlchemyStorage:
1200
1317
  instance_id_filter: str | None = None,
1201
1318
  started_after: datetime | None = None,
1202
1319
  started_before: datetime | None = None,
1320
+ input_filters: dict[str, Any] | None = None,
1203
1321
  ) -> dict[str, Any]:
1204
1322
  """List workflow instances with cursor-based pagination and filtering."""
1205
1323
  session = self._get_session_for_operation()
@@ -1292,6 +1410,42 @@ class SQLAlchemyStorage:
1292
1410
  started_before_comparable = started_before
1293
1411
  stmt = stmt.where(started_at_comparable <= started_before_comparable)
1294
1412
 
1413
+ # Apply input data filters (JSON field matching)
1414
+ if input_filters:
1415
+ for json_path, expected_value in input_filters.items():
1416
+ dialect = self.engine.dialect.name
1417
+ if dialect == "postgresql":
1418
+ # PostgreSQL: use text() for the entire condition
1419
+ if not self._validate_json_path(json_path):
1420
+ raise ValueError(f"Invalid JSON path: {json_path}")
1421
+ if "." in json_path:
1422
+ path_array = "{" + json_path.replace(".", ",") + "}"
1423
+ json_sql = f"(input_data)::json #>> '{path_array}'"
1424
+ else:
1425
+ json_sql = f"(input_data)::json->>'{json_path}'"
1426
+ if expected_value is None:
1427
+ stmt = stmt.where(text(f"({json_sql} IS NULL OR {json_sql} = 'null')"))
1428
+ else:
1429
+ # Escape single quotes in value
1430
+ safe_value = str(expected_value).replace("'", "''")
1431
+ stmt = stmt.where(text(f"{json_sql} = '{safe_value}'"))
1432
+ else:
1433
+ # SQLite and MySQL: use func-based approach
1434
+ json_expr = self._build_json_extract_expr(
1435
+ WorkflowInstance.input_data, json_path
1436
+ )
1437
+ if expected_value is None:
1438
+ stmt = stmt.where(or_(json_expr.is_(None), json_expr == "null"))
1439
+ elif isinstance(expected_value, bool):
1440
+ stmt = stmt.where(json_expr == str(expected_value).lower())
1441
+ elif isinstance(expected_value, (int, float)):
1442
+ if dialect == "sqlite":
1443
+ stmt = stmt.where(json_expr == expected_value)
1444
+ else:
1445
+ stmt = stmt.where(json_expr == str(expected_value))
1446
+ else:
1447
+ stmt = stmt.where(json_expr == str(expected_value))
1448
+
1295
1449
  # Fetch limit+1 to determine if there are more pages
1296
1450
  stmt = stmt.limit(limit + 1)
1297
1451
 
@@ -2096,6 +2250,12 @@ class SQLAlchemyStorage:
2096
2250
  session.add(event)
2097
2251
  await self._commit_if_not_in_transaction(session)
2098
2252
 
2253
+ # Send NOTIFY for new outbox event
2254
+ await self._send_notify(
2255
+ "edda_outbox_pending",
2256
+ {"evt_id": event_id, "evt_type": event_type},
2257
+ )
2258
+
2099
2259
  async def get_pending_outbox_events(self, limit: int = 10) -> list[dict[str, Any]]:
2100
2260
  """
2101
2261
  Get pending/failed outbox events for publishing (with row-level locking).
@@ -2719,29 +2879,34 @@ class SQLAlchemyStorage:
2719
2879
  # Workflow Resumption Methods
2720
2880
  # -------------------------------------------------------------------------
2721
2881
 
2722
- async def find_resumable_workflows(self) -> list[dict[str, Any]]:
2882
+ async def find_resumable_workflows(self, limit: int | None = None) -> list[dict[str, Any]]:
2723
2883
  """
2724
2884
  Find workflows that are ready to be resumed.
2725
2885
 
2726
2886
  Returns workflows with status='running' that don't have an active lock.
2727
2887
  Used for immediate resumption after message delivery.
2728
2888
 
2889
+ Args:
2890
+ limit: Optional maximum number of workflows to return.
2891
+ If None, returns all resumable workflows.
2892
+
2729
2893
  Returns:
2730
2894
  List of resumable workflows with instance_id and workflow_name.
2731
2895
  """
2732
2896
  session = self._get_session_for_operation()
2733
2897
  async with self._session_scope(session) as session:
2734
- result = await session.execute(
2735
- select(
2736
- WorkflowInstance.instance_id,
2737
- WorkflowInstance.workflow_name,
2738
- ).where(
2739
- and_(
2740
- WorkflowInstance.status == "running",
2741
- WorkflowInstance.locked_by.is_(None),
2742
- )
2743
- )
2744
- )
2898
+ query = select(
2899
+ WorkflowInstance.instance_id,
2900
+ WorkflowInstance.workflow_name,
2901
+ ).where(
2902
+ and_(
2903
+ WorkflowInstance.status == "running",
2904
+ WorkflowInstance.locked_by.is_(None),
2905
+ )
2906
+ )
2907
+ if limit is not None:
2908
+ query = query.limit(limit)
2909
+ result = await session.execute(query)
2745
2910
  return [
2746
2911
  {
2747
2912
  "instance_id": row.instance_id,
@@ -2837,6 +3002,15 @@ class SQLAlchemyStorage:
2837
3002
  session.add(msg)
2838
3003
  await self._commit_if_not_in_transaction(session)
2839
3004
 
3005
+ # Send NOTIFY for message published (channel-specific)
3006
+ import hashlib
3007
+
3008
+ channel_hash = hashlib.sha256(channel.encode()).hexdigest()[:16]
3009
+ await self._send_notify(
3010
+ f"edda_msg_{channel_hash}",
3011
+ {"ch": channel, "msg_id": message_id},
3012
+ )
3013
+
2840
3014
  return message_id
2841
3015
 
2842
3016
  async def subscribe_to_channel(
@@ -3395,6 +3569,12 @@ class SQLAlchemyStorage:
3395
3569
 
3396
3570
  await session.commit()
3397
3571
 
3572
+ # Send NOTIFY for workflow resumable
3573
+ await self._send_notify(
3574
+ "edda_workflow_resumable",
3575
+ {"wf_id": instance_id, "wf_name": workflow_name},
3576
+ )
3577
+
3398
3578
  return {
3399
3579
  "instance_id": instance_id,
3400
3580
  "workflow_name": workflow_name,
edda/viewer_ui/app.py CHANGED
@@ -1296,6 +1296,8 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1296
1296
  "search_query": "",
1297
1297
  "started_after": None,
1298
1298
  "started_before": None,
1299
+ "input_filter_key": "",
1300
+ "input_filter_value": "",
1299
1301
  "instances": [],
1300
1302
  "next_page_token": None,
1301
1303
  "has_more": False,
@@ -1352,6 +1354,17 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1352
1354
  "click", lambda m=date_to_menu: m.open()
1353
1355
  )
1354
1356
 
1357
+ # Input data filter fields
1358
+ input_key = ui.input(
1359
+ label="Input Key",
1360
+ placeholder="e.g., input.order_id",
1361
+ ).classes("w-40")
1362
+
1363
+ input_value = ui.input(
1364
+ label="Input Value",
1365
+ placeholder="e.g., ORD-123",
1366
+ ).classes("w-36")
1367
+
1355
1368
  # Refresh button
1356
1369
  async def handle_refresh() -> None:
1357
1370
  """Handle refresh button click."""
@@ -1360,6 +1373,11 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1360
1373
  pagination_state["page_size"] = page_size_select.value
1361
1374
  pagination_state["started_after"] = date_from.value
1362
1375
  pagination_state["started_before"] = date_to.value
1376
+ pagination_state["input_filter_key"] = input_key.value
1377
+ pagination_state["input_filter_value"] = input_value.value
1378
+ # Debug output
1379
+ print(f"DEBUG: input_key.value = '{input_key.value}'")
1380
+ print(f"DEBUG: input_value.value = '{input_value.value}'")
1363
1381
  # Reset to first page
1364
1382
  pagination_state["current_token"] = None
1365
1383
  pagination_state["token_stack"] = []
@@ -1389,6 +1407,13 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1389
1407
  pagination_state["started_before"] + "T23:59:59"
1390
1408
  )
1391
1409
 
1410
+ # Build input_filters if key is provided
1411
+ input_filters = None
1412
+ if pagination_state["input_filter_key"]:
1413
+ input_filters = {
1414
+ pagination_state["input_filter_key"]: pagination_state["input_filter_value"]
1415
+ }
1416
+
1392
1417
  result = await service.get_instances_paginated(
1393
1418
  page_size=pagination_state["page_size"],
1394
1419
  page_token=pagination_state["current_token"],
@@ -1396,6 +1421,7 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
1396
1421
  search_query=pagination_state["search_query"] or None,
1397
1422
  started_after=started_after,
1398
1423
  started_before=started_before,
1424
+ input_filters=input_filters,
1399
1425
  )
1400
1426
  pagination_state["instances"] = result["instances"]
1401
1427
  pagination_state["next_page_token"] = result["next_page_token"]
@@ -48,6 +48,7 @@ class WorkflowDataService:
48
48
  search_query: str | None = None,
49
49
  started_after: datetime | None = None,
50
50
  started_before: datetime | None = None,
51
+ input_filters: dict[str, Any] | None = None,
51
52
  ) -> dict[str, Any]:
52
53
  """
53
54
  Get workflow instances with cursor-based pagination and filtering.
@@ -59,6 +60,8 @@ class WorkflowDataService:
59
60
  search_query: Search by workflow name or instance ID (partial match)
60
61
  started_after: Filter instances started after this datetime
61
62
  started_before: Filter instances started before this datetime
63
+ input_filters: Filter by input data values. Keys are JSON paths
64
+ (e.g., "order_id"), values are expected values (exact match).
62
65
 
63
66
  Returns:
64
67
  Dictionary containing:
@@ -75,6 +78,7 @@ class WorkflowDataService:
75
78
  instance_id_filter=search_query,
76
79
  started_after=started_after,
77
80
  started_before=started_before,
81
+ input_filters=input_filters,
78
82
  )
79
83
  return result
80
84
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Lightweight Durable Execution Framework
5
5
  Project-URL: Homepage, https://github.com/i2y/edda
6
6
  Project-URL: Documentation, https://github.com/i2y/edda#readme
@@ -28,6 +28,8 @@ Requires-Dist: httpx>=0.28.1
28
28
  Requires-Dist: pydantic>=2.0.0
29
29
  Requires-Dist: sqlalchemy[asyncio]>=2.0.0
30
30
  Requires-Dist: uvloop>=0.22.1
31
+ Provides-Extra: cpu-monitor
32
+ Requires-Dist: psutil>=5.9.0; extra == 'cpu-monitor'
31
33
  Provides-Extra: dev
32
34
  Requires-Dist: black>=25.9.0; extra == 'dev'
33
35
  Requires-Dist: mcp>=1.22.0; extra == 'dev'
@@ -48,6 +50,8 @@ Provides-Extra: opentelemetry
48
50
  Requires-Dist: opentelemetry-api>=1.20.0; extra == 'opentelemetry'
49
51
  Requires-Dist: opentelemetry-exporter-otlp>=1.20.0; extra == 'opentelemetry'
50
52
  Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'opentelemetry'
53
+ Provides-Extra: postgres-notify
54
+ Requires-Dist: asyncpg>=0.30.0; extra == 'postgres-notify'
51
55
  Provides-Extra: postgresql
52
56
  Requires-Dist: asyncpg>=0.30.0; extra == 'postgresql'
53
57
  Provides-Extra: server
@@ -87,6 +91,7 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
87
91
  - ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
88
92
  - ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
89
93
  - 📬 **Channel-based Messaging**: Actor-model style communication with competing (job queue) and broadcast (fan-out) modes
94
+ - ⚡ **Instant Notifications**: PostgreSQL LISTEN/NOTIFY for near-instant event delivery (optional)
90
95
  - 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
91
96
  - 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
92
97
 
@@ -221,6 +226,9 @@ uv add edda-framework --extra mysql
221
226
  # With Viewer UI
222
227
  uv add edda-framework --extra viewer
223
228
 
229
+ # With PostgreSQL instant notifications (LISTEN/NOTIFY)
230
+ uv add edda-framework --extra postgres-notify
231
+
224
232
  # All extras (PostgreSQL, MySQL, Viewer UI)
225
233
  uv add edda-framework --extra postgresql --extra mysql --extra viewer
226
234
  ```
@@ -272,6 +280,8 @@ pip install "git+https://github.com/i2y/edda.git[postgresql,viewer]"
272
280
 
273
281
  **Important**: For multi-process or multi-pod deployments (K8s, Docker Compose with multiple replicas, etc.), you **must** use PostgreSQL or MySQL. SQLite supports multiple async workers within a single process, but its table-level locking makes it unsuitable for multi-process/multi-pod scenarios.
274
282
 
283
+ > **Tip**: For PostgreSQL, install the `postgres-notify` extra for near-instant event delivery using LISTEN/NOTIFY instead of polling.
284
+
275
285
  ### Development Installation
276
286
 
277
287
  If you want to contribute to Edda or modify the framework itself:
@@ -492,6 +502,8 @@ app = EddaApp(
492
502
  # Connection pool settings (optional)
493
503
  pool_size=5, # Concurrent connections
494
504
  max_overflow=10, # Additional burst capacity
505
+ # Batch processing (optional)
506
+ max_workflows_per_batch=10, # Or "auto" / "auto:cpu" for dynamic scaling
495
507
  )
496
508
  ```
497
509
 
@@ -1,9 +1,9 @@
1
1
  edda/__init__.py,sha256=hGC6WR2R36M8LWC97F-0Rw4Ln0QUUT_1xC-7acOy_Fk,2237
2
2
  edda/activity.py,sha256=nRm9eBrr0lFe4ZRQ2whyZ6mo5xd171ITIVhqytUhOpw,21025
3
- edda/app.py,sha256=kZ-VEvjIe3GjUA8RhT6OimuezNyPf2IhrvQ2kL44zJs,45201
3
+ edda/app.py,sha256=hoxDKp6q5qHl_dNLMkLxKhuibUC6D8FtuiWsIZkzwhA,61546
4
4
  edda/channels.py,sha256=Budi0FyxalmcAMwj50mX3WzRce5OuLKXGws0Hp_snfw,34745
5
5
  edda/compensation.py,sha256=iKLlnTxiF1YSatmYQW84EkPB1yMKUEZBtgjuGnghLtY,11824
6
- edda/context.py,sha256=IavmrbCdTAozP4QWlQ5-rCHR9yJAT-aohqyrOnbVLBU,20858
6
+ edda/context.py,sha256=pPn98-G5HgaOGDRzEhma58TzBulwsiTvmNEMLIu0XwI,21330
7
7
  edda/exceptions.py,sha256=-ntBLGpVQgPFG5N1o8m_7weejAYkNrUdxTkOP38vsHk,1766
8
8
  edda/hooks.py,sha256=HUZ6FTM__DZjwuomDfTDEroQ3mugEPuJHcGm7CTQNvg,8193
9
9
  edda/locking.py,sha256=NAFJmw-JaSVsXn4Y4czJyv_s9bWG8cdrzDBWIEag5X8,13661
@@ -19,25 +19,27 @@ edda/integrations/mcp/server.py,sha256=Q5r4AbMn-9gBcy2CZocbgW7O0fn7Qb4e9CBJa1FEm
19
19
  edda/integrations/opentelemetry/__init__.py,sha256=x1_PyyygGDW-rxQTwoIrGzyjKErXHOOKdquFAMlCOAo,906
20
20
  edda/integrations/opentelemetry/hooks.py,sha256=rCb6K_gJJMxjQ-UoJnbIOWsafapipzu7w-YPROZKxDA,21330
21
21
  edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
22
- edda/outbox/relayer.py,sha256=2tnN1aOQ8pKWfwEGIlYwYLLwyOKXBjZ4XZsIr1HjgK4,9454
22
+ edda/outbox/relayer.py,sha256=_yOZVpjj862lzMtEK47RMdVdbxXmL8v-FXppZWdy4Ag,10444
23
23
  edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
24
24
  edda/serialization/__init__.py,sha256=hnOVJN-mJNIsSa_XH9jwhIydOsWvIfCaFaSd37HUplg,216
25
25
  edda/serialization/base.py,sha256=xJy2CY9gdJDCF0tmCor8NomL2Lr_w7cveVvxccuc-tA,1998
26
26
  edda/serialization/json.py,sha256=Dq96V4n1yozexjCPd_CL6Iuvh1u3jJhef6sTcNxXZeA,2842
27
- edda/storage/__init__.py,sha256=Q-kNJsjF8hMc2Q5MYFlLBENKExlNlKkbmUkwBOosj9I,216
27
+ edda/storage/__init__.py,sha256=NjvAzYV3SknACrC16ZQOA-xCKOj1-s3rBIWOS1ZGCaM,407
28
28
  edda/storage/models.py,sha256=vUwjiAOvp9uFNQgLK57kEGo7uzXplDZikOfnlOyed2M,12146
29
- edda/storage/protocol.py,sha256=NTUuLZ5_OlBiASaJIRuz5x7NykpCOjQgDWWNrRQzong,39021
30
- edda/storage/sqlalchemy_storage.py,sha256=KvSGapeKJ3hhClXNxFKHByD3Key5aidxBMUjs6-EJvE,136811
29
+ edda/storage/notify_base.py,sha256=gUb-ypG1Bo0c-KrleYmC7eKtdwQNUeqGS5k7UILlSsQ,5055
30
+ edda/storage/pg_notify.py,sha256=to5rDIQbiqqkNNVMODye_KvY4EDqRSUQblTeoeDZv8w,11850
31
+ edda/storage/protocol.py,sha256=vdB5GvBen8lgUA0qEfBXfQTLbVfGKeBTQuEwSUqLZtI,39463
32
+ edda/storage/sqlalchemy_storage.py,sha256=IAc8SYHM2xoJEIVDG05_mkguWQGB3wIAALsc0QI8EcE,144484
31
33
  edda/viewer_ui/__init__.py,sha256=N1-T33SXadOXcBsDSgJJ9Iqz4y4verJngWryQu70c5c,517
32
- edda/viewer_ui/app.py,sha256=CqHKsUj5pcysHCk0aRfkEqV4DIV4l3GzOPKBJ5DTYOQ,95624
34
+ edda/viewer_ui/app.py,sha256=K3c5sMeJz_AE9gh5QftxwvfDthLeJi1i2CDkP9gb4Ig,96695
33
35
  edda/viewer_ui/components.py,sha256=A0IxLwgj_Lu51O57OfzOwME8jzoJtKegEVvSnWc7uPo,45174
34
- edda/viewer_ui/data_service.py,sha256=yzmPz67rBoECY7eNK5nl6YS3jIZAi-haaqrP0GIgJYE,36373
36
+ edda/viewer_ui/data_service.py,sha256=KOqnWr-Y8seH_dkJH_ejHRfxQqn7aY8Ni5C54tx2Z-E,36621
35
37
  edda/viewer_ui/theme.py,sha256=mrXoXLRzgSnvE2a58LuMcPJkhlvHEDMWVa8Smqtk4l0,8118
36
38
  edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
37
39
  edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
38
40
  edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
39
- edda_framework-0.9.0.dist-info/METADATA,sha256=esgoKFgUTWqAZWIHxgtKGl5j8VTaWiJw_oz93Dtm064,35741
40
- edda_framework-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
41
- edda_framework-0.9.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
42
- edda_framework-0.9.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
43
- edda_framework-0.9.0.dist-info/RECORD,,
41
+ edda_framework-0.10.0.dist-info/METADATA,sha256=HGJf790N4Rh8EDGIC4e7lFhH8xV5kCQP_xNPTLK8kpE,36366
42
+ edda_framework-0.10.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
43
+ edda_framework-0.10.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
44
+ edda_framework-0.10.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
45
+ edda_framework-0.10.0.dist-info/RECORD,,