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 +35 -0
- vs_graph/checkpointer/__init__.py +0 -0
- vs_graph/checkpointer/vs_base_checkpointer.py +33 -0
- vs_graph/checkpointer/vs_checkpointer_factory.py +44 -0
- vs_graph/checkpointer/vs_memory_checkpointer.py +22 -0
- vs_graph/checkpointer/vs_redis_checkpointer.py +46 -0
- vs_graph/decorator/__init__.py +0 -0
- vs_graph/decorator/vs_edge_decorator.py +14 -0
- vs_graph/decorator/vs_graph_decorator.py +23 -0
- vs_graph/decorator/vs_node_decorator.py +15 -0
- vs_graph/edge/__init__.py +0 -0
- vs_graph/edge/vs_base_conditional_edge.py +57 -0
- vs_graph/edge/vs_base_edge.py +48 -0
- vs_graph/edge/vs_direct_edge.py +23 -0
- vs_graph/edge/vs_dynamic_fan_out_edge.py +49 -0
- vs_graph/edge/vs_fan_out_edge.py +30 -0
- vs_graph/exception/__init__.py +0 -0
- vs_graph/exception/vs_graph_exceptions.py +17 -0
- vs_graph/node/__init__.py +0 -0
- vs_graph/node/vs_base_node.py +82 -0
- vs_graph/py.typed +0 -0
- vs_graph/registry/__init__.py +0 -0
- vs_graph/registry/vs_edge_registry.py +65 -0
- vs_graph/registry/vs_node_registry.py +65 -0
- vs_graph/schema/__init__.py +0 -0
- vs_graph/schema/vs_base_graph_state.py +9 -0
- vs_graph/schema/vs_graph_error.py +8 -0
- vs_graph/vs_base_graph.py +282 -0
- vs_graph-0.1.0.dist-info/METADATA +970 -0
- vs_graph-0.1.0.dist-info/RECORD +33 -0
- vs_graph-0.1.0.dist-info/WHEEL +5 -0
- vs_graph-0.1.0.dist-info/licenses/LICENSE.txt +181 -0
- vs_graph-0.1.0.dist-info/top_level.txt +1 -0
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()
|