vs-graph 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.
vs_graph/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ from vs_graph.vs_base_graph import VsBaseGraph
2
+ from vs_graph.node.vs_base_node import VsBaseNode
3
+ from vs_graph.edge.vs_base_edge import VsBaseEdge
4
+ from vs_graph.edge.vs_base_conditional_edge import VsBaseConditionalEdge
5
+ from vs_graph.edge.vs_direct_edge import VsDirectEdge
6
+ from vs_graph.edge.vs_fan_out_edge import VsFanOutEdge
7
+ from vs_graph.edge.vs_dynamic_fan_out_edge import VsDynamicFanOutEdge
8
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
9
+ from vs_graph.schema.vs_graph_error import VsGraphError
10
+ from vs_graph.decorator.vs_node_decorator import node
11
+ from vs_graph.decorator.vs_edge_decorator import edge
12
+ from vs_graph.exception.vs_graph_exceptions import (
13
+ VsUserException,
14
+ VsGraphBuildException,
15
+ VsNodeExecutionException,
16
+ VsEdgeEvaluationException,
17
+ )
18
+
19
+ __all__ = [
20
+ "VsBaseGraph",
21
+ "VsBaseNode",
22
+ "VsBaseEdge",
23
+ "VsBaseConditionalEdge",
24
+ "VsDirectEdge",
25
+ "VsFanOutEdge",
26
+ "VsDynamicFanOutEdge",
27
+ "VsBaseGraphState",
28
+ "VsGraphError",
29
+ "node",
30
+ "edge",
31
+ "VsUserException",
32
+ "VsGraphBuildException",
33
+ "VsNodeExecutionException",
34
+ "VsEdgeEvaluationException",
35
+ ]
File without changes
@@ -0,0 +1,33 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, Optional
3
+
4
+ from vs_common.log.vs_log_manager import VsLogManager
5
+
6
+
7
+ class VsBaseCheckpointer(ABC):
8
+
9
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
10
+ self.config = config or {}
11
+ self.logger = VsLogManager.get_instance(self.__class__.__name__)
12
+ self._checkpointer: Optional[Any] = None
13
+
14
+ @abstractmethod
15
+ def initialize(self) -> None:
16
+ pass
17
+
18
+ @abstractmethod
19
+ def health_check(self) -> bool:
20
+ pass
21
+
22
+ def cleanup(self) -> None:
23
+ pass
24
+
25
+ async def ensure_ready(self) -> None:
26
+ pass
27
+
28
+ def get_checkpointer(self) -> Any:
29
+ if self._checkpointer is None:
30
+ raise RuntimeError(
31
+ f"{self.__class__.__name__} has not been initialized. Call initialize() first."
32
+ )
33
+ return self._checkpointer
@@ -0,0 +1,44 @@
1
+ from typing import Any, Callable, Dict
2
+
3
+ from vs_common.log.vs_log_manager import VsLogManager
4
+
5
+
6
+ class VsCheckpointerFactory:
7
+
8
+ _registry: Dict[str, Callable] = {}
9
+ _logger = None
10
+
11
+ @classmethod
12
+ def _get_logger(cls):
13
+ if cls._logger is None:
14
+ cls._logger = VsLogManager.get_instance("VsCheckpointerFactory")
15
+ return cls._logger
16
+
17
+ @classmethod
18
+ def register(cls, name: str, factory: Callable) -> None:
19
+ cls._registry[name] = factory
20
+ cls._get_logger().info(f"Registered checkpointer factory: '{name}'")
21
+
22
+ @classmethod
23
+ def create(cls, checkpointer_type: str, config: Dict[str, Any]):
24
+ if checkpointer_type not in cls._registry:
25
+ raise ValueError(
26
+ f"Unknown checkpointer type: '{checkpointer_type}'. "
27
+ f"Registered: {sorted(cls._registry.keys())}"
28
+ )
29
+ return cls._registry[checkpointer_type](config)
30
+
31
+
32
+ VsCheckpointerFactory.register(
33
+ "memory",
34
+ lambda cfg: __import__(
35
+ "vs_graph.checkpointer.vs_memory_checkpointer", fromlist=["VsMemoryCheckpointer"]
36
+ ).VsMemoryCheckpointer(config=cfg),
37
+ )
38
+
39
+ VsCheckpointerFactory.register(
40
+ "redis",
41
+ lambda cfg: __import__(
42
+ "vs_graph.checkpointer.vs_redis_checkpointer", fromlist=["VsRedisCheckpointer"]
43
+ ).VsRedisCheckpointer(config=cfg),
44
+ )
@@ -0,0 +1,22 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from vs_graph.checkpointer.vs_base_checkpointer import VsBaseCheckpointer
4
+
5
+
6
+ class VsMemoryCheckpointer(VsBaseCheckpointer):
7
+
8
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
9
+ super().__init__(config)
10
+
11
+ def initialize(self) -> None:
12
+ try:
13
+ from langgraph.checkpoint.memory import MemorySaver
14
+ self._checkpointer = MemorySaver()
15
+ self.logger.info("Memory checkpointer initialized")
16
+ except ImportError as e:
17
+ raise ImportError("langgraph not installed. Run: pip install langgraph") from e
18
+ except Exception as e:
19
+ raise RuntimeError(f"Failed to initialize memory checkpointer: {e}") from e
20
+
21
+ def health_check(self) -> bool:
22
+ return self._checkpointer is not None
@@ -0,0 +1,46 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from vs_graph.checkpointer.vs_base_checkpointer import VsBaseCheckpointer
4
+
5
+
6
+ class VsRedisCheckpointer(VsBaseCheckpointer):
7
+
8
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
9
+ super().__init__(config)
10
+ if not self.config.get("redis_url"):
11
+ raise ValueError("redis_url is required in config for VsRedisCheckpointer.")
12
+
13
+ def initialize(self) -> None:
14
+ try:
15
+ from langgraph.checkpoint.redis.aio import AsyncRedisSaver
16
+
17
+ redis_url = self.config["redis_url"]
18
+ key_prefix = self.config.get("key_prefix", "vs:checkpoint:")
19
+
20
+ self._checkpointer = AsyncRedisSaver(redis_url=redis_url, checkpoint_prefix=key_prefix)
21
+ self._setup_done = False
22
+ self.logger.info(f"Redis checkpointer initialized: {redis_url}")
23
+ except ImportError as e:
24
+ raise ImportError("Install with: pip install redis langgraph[redis]") from e
25
+ except Exception as e:
26
+ raise RuntimeError(f"Failed to initialize Redis checkpointer: {e}") from e
27
+
28
+ async def ensure_ready(self) -> None:
29
+ if not getattr(self, "_setup_done", False):
30
+ await self._checkpointer.setup()
31
+ self._setup_done = True
32
+
33
+ def cleanup(self) -> None:
34
+ self._checkpointer = None
35
+
36
+ def health_check(self) -> bool:
37
+ if not self._checkpointer:
38
+ return False
39
+ try:
40
+ import redis
41
+ r = redis.from_url(self.config["redis_url"])
42
+ r.ping()
43
+ r.close()
44
+ return True
45
+ except Exception:
46
+ return False
File without changes
@@ -0,0 +1,14 @@
1
+ from typing import Optional, Type
2
+
3
+
4
+ def edge(_cls=None, *, name: Optional[str] = None):
5
+ def decorator(cls: Type) -> Type:
6
+ from vs_graph.registry.vs_edge_registry import VsEdgeRegistry
7
+ registered_name = name or cls.__name__
8
+ VsEdgeRegistry.register(registered_name, cls)
9
+ return cls
10
+
11
+ if _cls is not None:
12
+ return decorator(_cls)
13
+
14
+ return decorator
@@ -0,0 +1,23 @@
1
+ import importlib
2
+ import pkgutil
3
+ from typing import List, Optional, Type, Union
4
+
5
+
6
+ def graph(
7
+ nodes: Optional[Union[str, List[str]]] = None,
8
+ edges: Optional[Union[str, List[str]]] = None,
9
+ ):
10
+ def decorator(cls: Type) -> Type:
11
+ cls._vs_node_packages = [nodes] if isinstance(nodes, str) else (nodes or [])
12
+ cls._vs_edge_packages = [edges] if isinstance(edges, str) else (edges or [])
13
+ return cls
14
+
15
+ return decorator
16
+
17
+
18
+ def _import_package(package_path: str) -> None:
19
+ pkg = importlib.import_module(package_path)
20
+ if not hasattr(pkg, "__path__"):
21
+ return
22
+ for _, modname, _ in pkgutil.walk_packages(pkg.__path__, prefix=pkg.__name__ + "."):
23
+ importlib.import_module(modname)
@@ -0,0 +1,15 @@
1
+ from typing import Optional, Type
2
+
3
+
4
+ def node(_cls=None, *, name: Optional[str] = None):
5
+ def decorator(cls: Type) -> Type:
6
+ from vs_graph.registry.vs_node_registry import VsNodeRegistry
7
+ registered_name = name or cls.__name__
8
+ VsNodeRegistry.register(registered_name, cls)
9
+ cls._node_name = registered_name
10
+ return cls
11
+
12
+ if _cls is not None:
13
+ return decorator(_cls)
14
+
15
+ return decorator
File without changes
@@ -0,0 +1,57 @@
1
+ import time
2
+ from abc import abstractmethod
3
+ from typing import Any, Dict, Optional, TypeVar
4
+
5
+ from vs_common.publisher.vs_base_trace_publisher import VsBaseTracePublisher
6
+ from vs_graph.edge.vs_base_edge import VsBaseEdge
7
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
8
+
9
+ S = TypeVar("S", bound=VsBaseGraphState)
10
+
11
+
12
+ class VsBaseConditionalEdge(VsBaseEdge[S]):
13
+
14
+ def __init__(
15
+ self,
16
+ source: str,
17
+ route: Dict[str, str],
18
+ config: Optional[Dict[str, Any]] = None,
19
+ trace_publisher: Optional[VsBaseTracePublisher] = None,
20
+ ):
21
+ super().__init__(source=source, trace_publisher=trace_publisher)
22
+
23
+ if not route or "default" not in route:
24
+ raise ValueError(f"Conditional edge from '{source}' must have a 'default' route.")
25
+ if not route["default"]:
26
+ raise ValueError(f"Conditional edge from '{source}' has an empty 'default' target.")
27
+
28
+ self.route_map = route
29
+ self.config = config or {}
30
+
31
+ async def route(self, state: S) -> str:
32
+ trace_id = state.get("trace_id", "")
33
+ self.logger.info(f"[{self.source}] routing started")
34
+ start_time = time.time()
35
+ try:
36
+ route_key = self._evaluate(state)
37
+ next_node = self.route_map.get(route_key) or self.route_map.get("default")
38
+ if next_node is None:
39
+ raise RuntimeError(f"Conditional edge from '{self.source}' has no default route.")
40
+ duration = round(time.time() - start_time, 3)
41
+ self.logger.info(
42
+ f"[{self.source}] routed to '{next_node}' (key='{route_key}') in {duration}s"
43
+ )
44
+ await self.trace(
45
+ trace_id,
46
+ f"edge '{self.source}' routing to '{next_node}'",
47
+ data={"route_key": route_key, "next_node": next_node, "duration_seconds": duration},
48
+ )
49
+ return next_node
50
+ except Exception as e:
51
+ duration = round(time.time() - start_time, 3)
52
+ self.logger.error(f"[{self.source}] routing failed in {duration}s: {e}", exc_info=True)
53
+ raise
54
+
55
+ @abstractmethod
56
+ def _evaluate(self, state: S) -> str:
57
+ pass
@@ -0,0 +1,48 @@
1
+ from abc import ABC
2
+ from typing import Any, Dict, Generic, Optional, TypeVar
3
+
4
+ from vs_common.log.vs_log_manager import VsLogManager
5
+ from vs_common.publisher.vs_base_trace_publisher import VsBaseTracePublisher
6
+ from vs_common.schema.vs_trace_level import VsTraceLevel
7
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
8
+
9
+ S = TypeVar("S", bound=VsBaseGraphState)
10
+
11
+
12
+ class VsBaseEdge(ABC, Generic[S]):
13
+
14
+ def __init__(
15
+ self,
16
+ source: str,
17
+ trace_publisher: Optional[VsBaseTracePublisher] = None,
18
+ ):
19
+ if not source:
20
+ raise ValueError("source cannot be empty or None")
21
+
22
+ self.source = source
23
+ self.trace_publisher = trace_publisher
24
+ self.logger = VsLogManager.get_instance(self.__class__.__name__)
25
+
26
+ async def route(self, state: S):
27
+ """Async routing function registered with LangGraph for edges that make a
28
+ decision at runtime — conditional and dynamic fan-out. Direct and static
29
+ fan-out edges carry no decision and are wired as plain LangGraph edges,
30
+ so they never reach this method."""
31
+ raise NotImplementedError(
32
+ f"{type(self).__name__} does not implement route(). "
33
+ "Only conditional and dynamic fan-out edges are registered as routing functions."
34
+ )
35
+
36
+ async def trace(
37
+ self,
38
+ trace_id: str,
39
+ message: str,
40
+ data: Optional[Dict[str, Any]] = None,
41
+ level: VsTraceLevel = VsTraceLevel.INFO,
42
+ ) -> None:
43
+ if not self.trace_publisher:
44
+ return
45
+ try:
46
+ await self.trace_publisher.publish(trace_id=trace_id, message=message, level=level, data=data)
47
+ except Exception as e:
48
+ self.logger.warning(f"[{self.source}] trace publish failed: {e}")
@@ -0,0 +1,23 @@
1
+ from typing import Optional, TypeVar
2
+
3
+ from vs_common.publisher.vs_base_trace_publisher import VsBaseTracePublisher
4
+ from vs_graph.edge.vs_base_edge import VsBaseEdge
5
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
6
+
7
+ S = TypeVar("S", bound=VsBaseGraphState)
8
+
9
+
10
+ class VsDirectEdge(VsBaseEdge[S]):
11
+
12
+ def __init__(
13
+ self,
14
+ source: str,
15
+ target: str,
16
+ trace_publisher: Optional[VsBaseTracePublisher] = None,
17
+ ):
18
+ super().__init__(source=source, trace_publisher=trace_publisher)
19
+
20
+ if not target:
21
+ raise ValueError(f"DirectEdge from '{source}' requires a target.")
22
+
23
+ self.target = target
@@ -0,0 +1,49 @@
1
+ import time
2
+ from abc import abstractmethod
3
+ from typing import Any, Dict, List, Optional, TypeVar
4
+
5
+ from vs_common.publisher.vs_base_trace_publisher import VsBaseTracePublisher
6
+ from vs_graph.edge.vs_base_edge import VsBaseEdge
7
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
8
+
9
+ S = TypeVar("S", bound=VsBaseGraphState)
10
+
11
+
12
+ class VsDynamicFanOutEdge(VsBaseEdge[S]):
13
+
14
+ def __init__(
15
+ self,
16
+ source: str,
17
+ config: Optional[Dict[str, Any]] = None,
18
+ trace_publisher: Optional[VsBaseTracePublisher] = None,
19
+ ):
20
+ super().__init__(source=source, trace_publisher=trace_publisher)
21
+ self.config = config or {}
22
+
23
+ async def route(self, state: S) -> List:
24
+ trace_id = state.get("trace_id", "")
25
+ self.logger.info(f"[{self.source}] dynamic fan-out started")
26
+ start_time = time.time()
27
+ try:
28
+ sends = self.get_sends(state)
29
+ targets = [getattr(s, "node", None) for s in sends]
30
+ duration = round(time.time() - start_time, 3)
31
+ self.logger.info(
32
+ f"[{self.source}] fanning out to {len(sends)} branch(es) {targets} in {duration}s"
33
+ )
34
+ await self.trace(
35
+ trace_id,
36
+ f"edge '{self.source}' fanning out to {len(sends)} branch(es)",
37
+ data={"branch_count": len(sends), "targets": targets, "duration_seconds": duration},
38
+ )
39
+ return sends
40
+ except Exception as e:
41
+ duration = round(time.time() - start_time, 3)
42
+ self.logger.error(
43
+ f"[{self.source}] dynamic fan-out failed in {duration}s: {e}", exc_info=True
44
+ )
45
+ raise
46
+
47
+ @abstractmethod
48
+ def get_sends(self, state: S) -> List:
49
+ pass
@@ -0,0 +1,30 @@
1
+ from typing import List, Optional, TypeVar
2
+
3
+ from vs_common.publisher.vs_base_trace_publisher import VsBaseTracePublisher
4
+ from vs_graph.edge.vs_base_edge import VsBaseEdge
5
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
6
+
7
+ S = TypeVar("S", bound=VsBaseGraphState)
8
+
9
+
10
+ class VsFanOutEdge(VsBaseEdge[S]):
11
+
12
+ def __init__(
13
+ self,
14
+ source: str,
15
+ targets: List[str],
16
+ trace_publisher: Optional[VsBaseTracePublisher] = None,
17
+ ):
18
+ super().__init__(source=source, trace_publisher=trace_publisher)
19
+
20
+ if not targets or len(targets) < 2:
21
+ raise ValueError(f"FanOutEdge from '{source}' requires at least 2 targets.")
22
+
23
+ duplicates = [t for t in targets if targets.count(t) > 1]
24
+ if duplicates:
25
+ raise ValueError(f"FanOutEdge from '{source}' has duplicate targets: {sorted(set(duplicates))}.")
26
+
27
+ self.targets = targets
28
+
29
+ def get_targets(self) -> List[str]:
30
+ return self.targets
File without changes
@@ -0,0 +1,17 @@
1
+ from vs_common.exception.vs_base_exception import VsBaseException
2
+
3
+
4
+ class VsUserException(VsBaseException):
5
+ pass
6
+
7
+
8
+ class VsGraphBuildException(VsBaseException):
9
+ pass
10
+
11
+
12
+ class VsNodeExecutionException(VsBaseException):
13
+ pass
14
+
15
+
16
+ class VsEdgeEvaluationException(VsBaseException):
17
+ pass
File without changes
@@ -0,0 +1,82 @@
1
+ import time
2
+ import traceback
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Dict, Generic, Optional, TypeVar
5
+
6
+ from vs_common.log.vs_log_manager import VsLogManager
7
+ from vs_common.publisher.vs_base_trace_publisher import VsBaseTracePublisher
8
+ from vs_common.schema.vs_trace_level import VsTraceLevel
9
+ from vs_graph.exception.vs_graph_exceptions import VsUserException, VsNodeExecutionException
10
+ from vs_graph.schema.vs_base_graph_state import VsBaseGraphState
11
+
12
+ S = TypeVar("S", bound=VsBaseGraphState)
13
+
14
+
15
+ class VsBaseNode(ABC, Generic[S]):
16
+
17
+ def __init__(
18
+ self,
19
+ node_id: str,
20
+ config: Optional[Dict[str, Any]] = None,
21
+ trace_publisher: Optional[VsBaseTracePublisher] = None,
22
+ ):
23
+ if not node_id:
24
+ raise ValueError("node_id cannot be empty or None")
25
+
26
+ self.node_id = node_id
27
+ self.config = config or {}
28
+ self.trace_publisher = trace_publisher
29
+ self.logger = VsLogManager.get_instance(self.__class__.__name__)
30
+
31
+ async def invoke(self, state: S) -> dict:
32
+ trace_id = state.get("trace_id", "")
33
+ self.logger.info(f"[{self.node_id}] execution started")
34
+ await self.trace(trace_id, f"node '{self.node_id}' started")
35
+ start_time = time.time()
36
+ try:
37
+ result = await self.execute(state)
38
+ duration = round(time.time() - start_time, 3)
39
+ self.logger.info(f"[{self.node_id}] execution completed in {duration}s")
40
+ await self.trace(trace_id, f"node '{self.node_id}' completed", data={"duration_seconds": duration})
41
+ return result
42
+ except VsUserException as e:
43
+ duration = round(time.time() - start_time, 3)
44
+ self.logger.warning(f"[{self.node_id}] user-facing error in {duration}s: {e.message}")
45
+ await self.trace(trace_id, f"node '{self.node_id}' user error", data={"error": e.message}, level=VsTraceLevel.WARN)
46
+ state["error"] = {
47
+ "message": e.message,
48
+ "cause": None,
49
+ "stack_trace": None,
50
+ }
51
+ return state
52
+ except VsNodeExecutionException as e:
53
+ duration = round(time.time() - start_time, 3)
54
+ self.logger.error(f"[{self.node_id}] node execution error in {duration}s: {e.message}", exc_info=True)
55
+ await self.trace(trace_id, f"node '{self.node_id}' failed", data={"error": e.message}, level=VsTraceLevel.ERROR)
56
+ raise
57
+ except Exception as e:
58
+ duration = round(time.time() - start_time, 3)
59
+ self.logger.error(f"[{self.node_id}] unexpected error in {duration}s: {e}", exc_info=True)
60
+ await self.trace(trace_id, f"node '{self.node_id}' failed", data={"error": str(e)}, level=VsTraceLevel.ERROR)
61
+ raise
62
+
63
+ async def trace(
64
+ self,
65
+ trace_id: str,
66
+ message: str,
67
+ data: Optional[Dict[str, Any]] = None,
68
+ level: VsTraceLevel = VsTraceLevel.INFO,
69
+ ) -> None:
70
+ if not self.trace_publisher:
71
+ return
72
+ try:
73
+ await self.trace_publisher.publish(trace_id=trace_id,
74
+ message=message,
75
+ level=level,
76
+ data=data)
77
+ except Exception as e:
78
+ self.logger.warning(f"[{self.node_id}] trace publish failed: {e}")
79
+
80
+ @abstractmethod
81
+ async def execute(self, state: S) -> dict:
82
+ pass
vs_graph/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,65 @@
1
+ import threading
2
+ from typing import Any, Dict, List, Type
3
+
4
+ from vs_common.log.vs_log_manager import VsLogManager
5
+
6
+
7
+ class VsEdgeRegistry:
8
+
9
+ def __new__(cls, *args, **kwargs):
10
+ raise TypeError("VsEdgeRegistry cannot be instantiated.")
11
+
12
+ _edges: Dict[str, Type] = {}
13
+ _lock = threading.Lock()
14
+ _logger = None
15
+
16
+ @classmethod
17
+ def _get_logger(cls):
18
+ if cls._logger is None:
19
+ cls._logger = VsLogManager.get_instance("VsEdgeRegistry")
20
+ return cls._logger
21
+
22
+ @classmethod
23
+ def register(cls, name: str, edge_class: Type) -> None:
24
+ from vs_graph.edge.vs_base_edge import VsBaseEdge
25
+ with cls._lock:
26
+ if name in cls._edges:
27
+ existing = cls._edges[name]
28
+ raise ValueError(
29
+ f"Edge '{name}' is already registered to {existing.__module__}.{existing.__name__}."
30
+ )
31
+ if not issubclass(edge_class, VsBaseEdge):
32
+ raise TypeError(f"Edge class {edge_class.__name__} must extend VsBaseEdge.")
33
+ cls._edges[name] = edge_class
34
+ cls._get_logger().info(f"Registered edge: '{name}' -> {edge_class.__module__}.{edge_class.__name__}")
35
+
36
+ @classmethod
37
+ def get(cls, name: str) -> Type:
38
+ if name not in cls._edges:
39
+ raise ValueError(
40
+ f"Edge '{name}' not found in registry. Available: {sorted(cls._edges.keys())}"
41
+ )
42
+ return cls._edges[name]
43
+
44
+ @classmethod
45
+ def create(cls, name: str, source: str, **kwargs: Any) -> Any:
46
+ edge_class = cls.get(name)
47
+ try:
48
+ instance = edge_class(source=source, **kwargs)
49
+ cls._get_logger().info(f"Created edge: '{name}' source='{source}'")
50
+ return instance
51
+ except Exception as e:
52
+ raise RuntimeError(f"Failed to instantiate edge '{name}' (source='{source}'): {e}") from e
53
+
54
+ @classmethod
55
+ def is_registered(cls, name: str) -> bool:
56
+ return name in cls._edges
57
+
58
+ @classmethod
59
+ def list_edges(cls) -> List[str]:
60
+ return sorted(cls._edges.keys())
61
+
62
+ @classmethod
63
+ def clear(cls) -> None:
64
+ with cls._lock:
65
+ cls._edges.clear()