commandnet 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandnet
3
+ Version: 0.3.0
4
+ Summary: A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime.
5
+ Author: Christopher Vaz
6
+ Author-email: christophervaz160@gmail.com
7
+ Requires-Python: >=3.11
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: System :: Distributed Computing
15
+ Provides-Extra: dev
16
+ Requires-Dist: pydantic (>=2.0.0)
17
+ Requires-Dist: pytest ; extra == "dev"
18
+ Requires-Dist: pytest-asyncio ; extra == "dev"
19
+ Project-URL: Homepage, https://github.com/NullAxon/commandnet
20
+ Project-URL: Issues, https://github.com/NullAxon/commandnet/issues
21
+ Project-URL: Repository, https://github.com/NullAxon/commandnet
22
+ Description-Content-Type: text/markdown
23
+
24
+ # 🕸️ CommandNet
25
+
26
+ **CommandNet** is a lightweight, distributed, event-driven state machine and typed node graph runtime for Python 3.11+.
27
+
28
+ It enables you to build durable, asynchronous workflows using strictly typed Python classes and Pydantic models. Unlike heavy orchestrators, CommandNet provides a minimal core that executes graph-based logic across distributed workers using your choice of database and message broker.
29
+
30
+ ---
31
+
32
+ ## 🚀 Installation
33
+
34
+ Install CommandNet via pip:
35
+
36
+ ```bash
37
+ pip install commandnet
38
+ ```
39
+
40
+ ---
41
+
42
+ ## ✨ Key Features
43
+
44
+ - **Type-Safe Transitions**: The execution graph is inferred directly from Python type hints (`-> Union[Type[NodeA], Type[NodeB]]`). No external JSON/YAML definitions.
45
+ - **Pydantic State Management**: Context is automatically serialized and rehydrated into Pydantic models with full validation.
46
+ - **Distributed by Design**: Built-in row-level locking and idempotency support for safe execution across horizontally scaled workers.
47
+ - **Fan-out / Fan-in (Parallel)**: Native support for triggering multiple concurrent sub-tasks and merging results back into the parent state.
48
+ - **Native Scheduling**: Schedule nodes to run after a specific delay with built-in idempotency keys to prevent duplicate execution.
49
+ - **Static Validation**: Validate your entire workflow graph (types and connectivity) before a single event is processed.
50
+
51
+ ---
52
+
53
+ ## 🛠️ Quick Start
54
+
55
+ ### 1. Define Your Context
56
+ The "Context" is the persistent state of your agent, defined using Pydantic.
57
+
58
+ ```python
59
+ from pydantic import BaseModel
60
+
61
+ class WorkflowCtx(BaseModel):
62
+ user_id: str
63
+ status: str = "pending"
64
+ attempts: int = 0
65
+ ```
66
+
67
+ ### 2. Define Your Nodes
68
+ Nodes are the building blocks of your graph. The return type hint of the `run` method defines the edges of your DAG.
69
+
70
+ ```python
71
+ from typing import Union, Type, Optional
72
+ from commandnet import Node
73
+
74
+ class ProcessPayment(Node[WorkflowCtx, None]):
75
+ async def run(self, ctx: WorkflowCtx, payload: None) -> None:
76
+ print(f"Processing for {ctx.user_id}...")
77
+ ctx.status = "complete"
78
+ return None # Terminal state
79
+
80
+ class CheckRisk(Node[WorkflowCtx, None]):
81
+ # The return type explicitly defines the possible next nodes
82
+ async def run(self, ctx: WorkflowCtx, payload: None) -> Union[Type[ProcessPayment], None]:
83
+ ctx.attempts += 1
84
+ if ctx.attempts > 3:
85
+ return None # Failure/Stop
86
+ return ProcessPayment
87
+ ```
88
+
89
+ ### 3. Advanced Routing (Parallel & Scheduled)
90
+ CommandNet supports complex workflow patterns beyond simple linear transitions.
91
+
92
+ #### Parallel Fan-out
93
+ ```python
94
+ from commandnet import Parallel, ParallelTask
95
+
96
+ class StartAnalysis(Node[WorkflowCtx, None]):
97
+ async def run(self, ctx: WorkflowCtx, payload: None) -> Parallel:
98
+ return Parallel(
99
+ branches=[
100
+ ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_1"),
101
+ ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_2")
102
+ ],
103
+ join_node=FinalMergeNode
104
+ )
105
+ ```
106
+
107
+ #### Delayed Scheduling
108
+ ```python
109
+ from commandnet import Schedule
110
+
111
+ class RetryNode(Node[WorkflowCtx, None]):
112
+ async def run(self, ctx: WorkflowCtx, payload: None) -> Schedule:
113
+ return Schedule(
114
+ node_cls=CheckRisk,
115
+ delay_seconds=300,
116
+ idempotency_key=f"retry-{ctx.attempts}"
117
+ )
118
+ ```
119
+
120
+ ---
121
+
122
+ ## 🏗️ Infrastructure Integration
123
+
124
+ CommandNet is unopinionated about your stack. You simply implement two abstract interfaces:
125
+
126
+ 1. **`Persistence`**: Handles locking state in your DB (Postgres, Redis, DynamoDB).
127
+ 2. **`EventBus`**: Handles moving events between workers (RabbitMQ, NATS, SQS).
128
+
129
+ ```python
130
+ from commandnet import Engine
131
+
132
+ # Implement these interfaces for your specific stack
133
+ db = MyPostgresAdapter()
134
+ bus = MyRabbitMQAdapter()
135
+
136
+ engine = Engine(persistence=db, event_bus=bus)
137
+
138
+ # Start the worker loop
139
+ await engine.start_worker()
140
+
141
+ # Trigger an execution
142
+ await engine.trigger_agent("agent-123", CheckRisk, WorkflowCtx(user_id="user_abc"))
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 🔍 Static Analysis & Safety
148
+
149
+ Prevent runtime failures by validating your graph during CI/CD or at startup. The `GraphAnalyzer` checks for disconnected nodes and ensures that if `NodeA` transitions to `NodeB`, they share compatible `Context` types.
150
+
151
+ ```python
152
+ from commandnet import GraphAnalyzer
153
+
154
+ # This will raise a TypeError if types don't match or a ValueError if edges are broken
155
+ GraphAnalyzer.validate(CheckRisk)
156
+
157
+ # Generate a dictionary representation of your DAG
158
+ dag = GraphAnalyzer.build_graph(CheckRisk)
159
+ print(dag) # {'CheckRisk': ['ProcessPayment'], 'ProcessPayment': []}
160
+ ```
161
+
162
+ ---
163
+
164
+ ## ⚖️ Design Philosophy
165
+
166
+ 1. **Code as Truth**: If your IDE can navigate it, CommandNet can run it. No "magic strings."
167
+ 2. **Stateless Execution**: Workers don't keep local state. Every node execution starts with a fresh database fetch and lock.
168
+ 3. **Zero Magic**: No hidden background threads or global singletons. You control the `Engine` lifecycle.
169
+ 4. **Ownership**: CommandNet provides the orchestration logic; you provide the infrastructure.
170
+
171
+ ## 📄 License
172
+
173
+ MIT
174
+
@@ -0,0 +1,150 @@
1
+ # 🕸️ CommandNet
2
+
3
+ **CommandNet** is a lightweight, distributed, event-driven state machine and typed node graph runtime for Python 3.11+.
4
+
5
+ It enables you to build durable, asynchronous workflows using strictly typed Python classes and Pydantic models. Unlike heavy orchestrators, CommandNet provides a minimal core that executes graph-based logic across distributed workers using your choice of database and message broker.
6
+
7
+ ---
8
+
9
+ ## 🚀 Installation
10
+
11
+ Install CommandNet via pip:
12
+
13
+ ```bash
14
+ pip install commandnet
15
+ ```
16
+
17
+ ---
18
+
19
+ ## ✨ Key Features
20
+
21
+ - **Type-Safe Transitions**: The execution graph is inferred directly from Python type hints (`-> Union[Type[NodeA], Type[NodeB]]`). No external JSON/YAML definitions.
22
+ - **Pydantic State Management**: Context is automatically serialized and rehydrated into Pydantic models with full validation.
23
+ - **Distributed by Design**: Built-in row-level locking and idempotency support for safe execution across horizontally scaled workers.
24
+ - **Fan-out / Fan-in (Parallel)**: Native support for triggering multiple concurrent sub-tasks and merging results back into the parent state.
25
+ - **Native Scheduling**: Schedule nodes to run after a specific delay with built-in idempotency keys to prevent duplicate execution.
26
+ - **Static Validation**: Validate your entire workflow graph (types and connectivity) before a single event is processed.
27
+
28
+ ---
29
+
30
+ ## 🛠️ Quick Start
31
+
32
+ ### 1. Define Your Context
33
+ The "Context" is the persistent state of your agent, defined using Pydantic.
34
+
35
+ ```python
36
+ from pydantic import BaseModel
37
+
38
+ class WorkflowCtx(BaseModel):
39
+ user_id: str
40
+ status: str = "pending"
41
+ attempts: int = 0
42
+ ```
43
+
44
+ ### 2. Define Your Nodes
45
+ Nodes are the building blocks of your graph. The return type hint of the `run` method defines the edges of your DAG.
46
+
47
+ ```python
48
+ from typing import Union, Type, Optional
49
+ from commandnet import Node
50
+
51
+ class ProcessPayment(Node[WorkflowCtx, None]):
52
+ async def run(self, ctx: WorkflowCtx, payload: None) -> None:
53
+ print(f"Processing for {ctx.user_id}...")
54
+ ctx.status = "complete"
55
+ return None # Terminal state
56
+
57
+ class CheckRisk(Node[WorkflowCtx, None]):
58
+ # The return type explicitly defines the possible next nodes
59
+ async def run(self, ctx: WorkflowCtx, payload: None) -> Union[Type[ProcessPayment], None]:
60
+ ctx.attempts += 1
61
+ if ctx.attempts > 3:
62
+ return None # Failure/Stop
63
+ return ProcessPayment
64
+ ```
65
+
66
+ ### 3. Advanced Routing (Parallel & Scheduled)
67
+ CommandNet supports complex workflow patterns beyond simple linear transitions.
68
+
69
+ #### Parallel Fan-out
70
+ ```python
71
+ from commandnet import Parallel, ParallelTask
72
+
73
+ class StartAnalysis(Node[WorkflowCtx, None]):
74
+ async def run(self, ctx: WorkflowCtx, payload: None) -> Parallel:
75
+ return Parallel(
76
+ branches=[
77
+ ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_1"),
78
+ ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_2")
79
+ ],
80
+ join_node=FinalMergeNode
81
+ )
82
+ ```
83
+
84
+ #### Delayed Scheduling
85
+ ```python
86
+ from commandnet import Schedule
87
+
88
+ class RetryNode(Node[WorkflowCtx, None]):
89
+ async def run(self, ctx: WorkflowCtx, payload: None) -> Schedule:
90
+ return Schedule(
91
+ node_cls=CheckRisk,
92
+ delay_seconds=300,
93
+ idempotency_key=f"retry-{ctx.attempts}"
94
+ )
95
+ ```
96
+
97
+ ---
98
+
99
+ ## 🏗️ Infrastructure Integration
100
+
101
+ CommandNet is unopinionated about your stack. You simply implement two abstract interfaces:
102
+
103
+ 1. **`Persistence`**: Handles locking state in your DB (Postgres, Redis, DynamoDB).
104
+ 2. **`EventBus`**: Handles moving events between workers (RabbitMQ, NATS, SQS).
105
+
106
+ ```python
107
+ from commandnet import Engine
108
+
109
+ # Implement these interfaces for your specific stack
110
+ db = MyPostgresAdapter()
111
+ bus = MyRabbitMQAdapter()
112
+
113
+ engine = Engine(persistence=db, event_bus=bus)
114
+
115
+ # Start the worker loop
116
+ await engine.start_worker()
117
+
118
+ # Trigger an execution
119
+ await engine.trigger_agent("agent-123", CheckRisk, WorkflowCtx(user_id="user_abc"))
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 🔍 Static Analysis & Safety
125
+
126
+ Prevent runtime failures by validating your graph during CI/CD or at startup. The `GraphAnalyzer` checks for disconnected nodes and ensures that if `NodeA` transitions to `NodeB`, they share compatible `Context` types.
127
+
128
+ ```python
129
+ from commandnet import GraphAnalyzer
130
+
131
+ # This will raise a TypeError if types don't match or a ValueError if edges are broken
132
+ GraphAnalyzer.validate(CheckRisk)
133
+
134
+ # Generate a dictionary representation of your DAG
135
+ dag = GraphAnalyzer.build_graph(CheckRisk)
136
+ print(dag) # {'CheckRisk': ['ProcessPayment'], 'ProcessPayment': []}
137
+ ```
138
+
139
+ ---
140
+
141
+ ## ⚖️ Design Philosophy
142
+
143
+ 1. **Code as Truth**: If your IDE can navigate it, CommandNet can run it. No "magic strings."
144
+ 2. **Stateless Execution**: Workers don't keep local state. Every node execution starts with a fresh database fetch and lock.
145
+ 3. **Zero Magic**: No hidden background threads or global singletons. You control the `Engine` lifecycle.
146
+ 4. **Ownership**: CommandNet provides the orchestration logic; you provide the infrastructure.
147
+
148
+ ## 📄 License
149
+
150
+ MIT
@@ -1,5 +1,5 @@
1
1
  from .core.models import Event
2
- from .core.node import Node, Parallel, ParallelTask, Schedule
2
+ from .core.node import Node, Parallel, ParallelTask, Schedule, Wait
3
3
  from .core.graph import GraphAnalyzer
4
4
  from .interfaces.persistence import Persistence
5
5
  from .interfaces.event_bus import EventBus
@@ -1,7 +1,7 @@
1
1
  import typing
2
2
  import inspect
3
3
  from typing import Any, Dict, List, Set, Type, Union, get_args, get_origin
4
- from .node import Node, NODE_REGISTRY
4
+ from .node import Node
5
5
 
6
6
  class GraphAnalyzer:
7
7
  @staticmethod
@@ -25,16 +25,12 @@ class GraphAnalyzer:
25
25
  @staticmethod
26
26
  def _get_node_names(type_hint: Any) -> List[str]:
27
27
  origin = typing.get_origin(type_hint)
28
-
29
- # 1. Handle Union (and Optional)
30
28
  if origin is Union:
31
29
  names = []
32
30
  for arg in typing.get_args(type_hint):
33
31
  if arg is type(None): continue
34
32
  names.extend(GraphAnalyzer._get_node_names(arg))
35
33
  return names
36
-
37
- # 2. Handle Type[X] or typing.Type[X]
38
34
  if origin in (type, typing.Type):
39
35
  arg = typing.get_args(type_hint)[0]
40
36
  if inspect.isclass(arg) and issubclass(arg, Node):
@@ -43,15 +39,12 @@ class GraphAnalyzer:
43
39
  return [arg.__forward_arg__]
44
40
  if isinstance(arg, str):
45
41
  return [arg]
46
-
47
- # 3. Direct class references
48
42
  if inspect.isclass(type_hint) and issubclass(type_hint, Node):
49
43
  return [type_hint.get_node_name()]
50
-
51
44
  return []
52
45
 
53
46
  @staticmethod
54
- def get_transitions(node_cls: Type[Node]) -> Set[Type[Node]]:
47
+ def get_transitions(node_cls: Type[Node], registry: Dict[str, Type[Node]]) -> Set[Type[Node]]:
55
48
  ret_annotation = node_cls.run.__annotations__.get("return")
56
49
  if not ret_annotation: return set()
57
50
 
@@ -59,47 +52,42 @@ class GraphAnalyzer:
59
52
  transitions = set()
60
53
 
61
54
  for name in node_names:
62
- if name not in NODE_REGISTRY:
55
+ if name not in registry:
63
56
  raise RuntimeError(
64
57
  f"Graph Error: Node '{node_cls.get_node_name()}' references unknown node '{name}'. "
65
- "Ensure it is imported and subclasses CommandNet Node."
58
+ "Ensure it is passed to the Engine/Registry."
66
59
  )
67
- transitions.add(NODE_REGISTRY[name])
68
-
60
+ transitions.add(registry[name])
69
61
  return transitions
70
62
 
71
63
  @staticmethod
72
- def build_graph(start_node: Type[Node]) -> Dict[str, List[str]]:
64
+ def build_graph(start_node: Type[Node], registry: Dict[str, Type[Node]]) -> Dict[str, List[str]]:
73
65
  graph = {}
74
66
  visited, queue = set(), [start_node]
75
67
  while queue:
76
68
  current = queue.pop(0)
77
69
  if current in visited: continue
78
70
  visited.add(current)
79
- transitions = GraphAnalyzer.get_transitions(current)
71
+ transitions = GraphAnalyzer.get_transitions(current, registry)
80
72
  graph[current.get_node_name()] = [t.get_node_name() for t in transitions]
81
73
  for t in transitions:
82
74
  if t not in visited: queue.append(t)
83
75
  return graph
84
76
 
85
77
  @staticmethod
86
- def validate(start_node: Type[Node]):
87
- """Static analysis of the DAG to prevent runtime crashes."""
88
- graph = GraphAnalyzer.build_graph(start_node)
89
-
78
+ def validate(start_node: Type[Node], registry: Dict[str, Type[Node]]):
79
+ graph = GraphAnalyzer.build_graph(start_node, registry)
90
80
  for node_name, edges in graph.items():
91
- node_cls = NODE_REGISTRY.get(node_name)
81
+ node_cls = registry.get(node_name)
92
82
  if not node_cls:
93
83
  raise ValueError(f"Validation Error: Node '{node_name}' missing from registry.")
94
84
 
95
85
  source_ctx = GraphAnalyzer.get_context_type(node_cls)
96
-
97
86
  for edge in edges:
98
- target_cls = NODE_REGISTRY.get(edge)
87
+ target_cls = registry.get(edge)
99
88
  if not target_cls:
100
89
  raise ValueError(f"Validation Error: Edge '{edge}' from '{node_name}' does not exist.")
101
90
 
102
- # STRICT CONTEXT MATCHING
103
91
  target_ctx = GraphAnalyzer.get_context_type(target_cls)
104
92
  if source_ctx is not target_ctx and not issubclass(source_ctx, target_ctx):
105
93
  raise TypeError(
@@ -107,5 +95,4 @@ class GraphAnalyzer:
107
95
  f"but their Context types do not match! "
108
96
  f"({source_ctx.__name__} -> {target_ctx.__name__})"
109
97
  )
110
-
111
98
  return True
@@ -0,0 +1,43 @@
1
+ from typing import Generic, TypeVar, Type, Optional, Union, List, Any
2
+ from pydantic import BaseModel, ConfigDict
3
+
4
+ C = TypeVar('C', bound=BaseModel) # Context
5
+ P = TypeVar('P', bound=BaseModel) # Payload
6
+
7
+ # The Recursive Type Definition
8
+ Target = Union[Type['Node'], 'Parallel', 'Schedule', 'Wait', None]
9
+
10
+ class ParallelTask(BaseModel):
11
+ model_config = ConfigDict(arbitrary_types_allowed=True)
12
+ action: Target
13
+ sub_context_path: str
14
+ payload: Optional[Any] = None
15
+
16
+ class Parallel(BaseModel):
17
+ model_config = ConfigDict(arbitrary_types_allowed=True)
18
+ branches: List[Union[ParallelTask, 'Wait']]
19
+ join_node: Optional[Type['Node']] = None
20
+
21
+ class Schedule(BaseModel):
22
+ model_config = ConfigDict(arbitrary_types_allowed=True)
23
+ action: Target
24
+ delay_seconds: int
25
+ payload: Optional[Any] = None
26
+ idempotency_key: Optional[str] = None
27
+
28
+ class Wait(BaseModel):
29
+ model_config = ConfigDict(arbitrary_types_allowed=True)
30
+ signal_id: str
31
+ resume_action: Target
32
+ sub_context_path: Optional[str] = None
33
+
34
+ TransitionResult = Target
35
+
36
+ class Node(Generic[C, P]):
37
+ @classmethod
38
+ def get_node_name(cls) -> str:
39
+ return cls.__name__
40
+
41
+ async def run(self, ctx: C, payload: Optional[P] = None) -> TransitionResult:
42
+ """Executes node logic. Returns a Target (Node class, Parallel, Schedule, Wait, or None)."""
43
+ pass