flatagents 0.4.1__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.
flatagents/backends.py ADDED
@@ -0,0 +1,222 @@
1
+ """
2
+ Result backends for FlatMachine inter-machine communication.
3
+
4
+ Result backends handle the storage and retrieval of machine execution results,
5
+ enabling machines to read outputs from peer machines they launched.
6
+
7
+ URI Scheme: flatagents://{execution_id}/[checkpoint|result]
8
+ """
9
+
10
+ import asyncio
11
+ import logging
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass, asdict
14
+ from typing import Any, Dict, Optional, Protocol, runtime_checkable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def make_uri(execution_id: str, path: str = "result") -> str:
20
+ """Generate a FlatAgents URI for a given execution and path.
21
+
22
+ Args:
23
+ execution_id: Unique execution identifier
24
+ path: URI path component (default: "result")
25
+
26
+ Returns:
27
+ URI string in format flatagents://{execution_id}/{path}
28
+ """
29
+ return f"flatagents://{execution_id}/{path}"
30
+
31
+
32
+ def parse_uri(uri: str) -> tuple[str, str]:
33
+ """Parse a FlatAgents URI into execution_id and path.
34
+
35
+ Args:
36
+ uri: URI in format flatagents://{execution_id}/{path}
37
+
38
+ Returns:
39
+ Tuple of (execution_id, path)
40
+
41
+ Raises:
42
+ ValueError: If URI format is invalid
43
+ """
44
+ if not uri.startswith("flatagents://"):
45
+ raise ValueError(f"Invalid FlatAgents URI: {uri}")
46
+
47
+ rest = uri[len("flatagents://"):]
48
+ parts = rest.split("/", 1)
49
+
50
+ if len(parts) == 1:
51
+ return parts[0], "result"
52
+ return parts[0], parts[1]
53
+
54
+
55
+ @dataclass
56
+ class LaunchIntent:
57
+ """
58
+ Launch intent for outbox pattern.
59
+ Recorded in checkpoint before launching to ensure exactly-once semantics.
60
+ """
61
+ execution_id: str
62
+ machine: str
63
+ input: Dict[str, Any]
64
+ launched: bool = False
65
+
66
+ def to_dict(self) -> Dict[str, Any]:
67
+ return asdict(self)
68
+
69
+ @classmethod
70
+ def from_dict(cls, data: Dict[str, Any]) -> "LaunchIntent":
71
+ return cls(**data)
72
+
73
+
74
+ @runtime_checkable
75
+ class ResultBackend(Protocol):
76
+ """
77
+ Protocol for result backends.
78
+
79
+ Result backends store and retrieve machine execution results,
80
+ enabling inter-machine communication.
81
+ """
82
+
83
+ async def write(self, uri: str, data: Any) -> None:
84
+ """Write data to a URI.
85
+
86
+ Args:
87
+ uri: FlatAgents URI (flatagents://{execution_id}/{path})
88
+ data: Data to store (will be serialized)
89
+ """
90
+ ...
91
+
92
+ async def read(self, uri: str, block: bool = True, timeout: Optional[float] = None) -> Any:
93
+ """Read data from a URI.
94
+
95
+ Args:
96
+ uri: FlatAgents URI
97
+ block: If True, wait for data to be available
98
+ timeout: Maximum seconds to wait (None = forever, only used if block=True)
99
+
100
+ Returns:
101
+ The stored data, or None if not found and block=False
102
+
103
+ Raises:
104
+ TimeoutError: If timeout expires while waiting
105
+ """
106
+ ...
107
+
108
+ async def exists(self, uri: str) -> bool:
109
+ """Check if data exists at a URI.
110
+
111
+ Args:
112
+ uri: FlatAgents URI
113
+
114
+ Returns:
115
+ True if data exists, False otherwise
116
+ """
117
+ ...
118
+
119
+ async def delete(self, uri: str) -> None:
120
+ """Delete data at a URI.
121
+
122
+ Args:
123
+ uri: FlatAgents URI
124
+ """
125
+ ...
126
+
127
+
128
+ class InMemoryResultBackend:
129
+ """
130
+ In-memory result backend for local execution.
131
+
132
+ Stores results in memory with asyncio Event-based blocking reads.
133
+ Suitable for single-process execution where machines run in the same process.
134
+ """
135
+
136
+ def __init__(self):
137
+ self._store: Dict[str, Any] = {}
138
+ self._events: Dict[str, asyncio.Event] = {}
139
+ self._lock = asyncio.Lock()
140
+
141
+ def _get_key(self, uri: str) -> str:
142
+ """Convert URI to storage key."""
143
+ execution_id, path = parse_uri(uri)
144
+ return f"{execution_id}/{path}"
145
+
146
+ def _get_event(self, key: str) -> asyncio.Event:
147
+ """Get or create an event for a key."""
148
+ if key not in self._events:
149
+ self._events[key] = asyncio.Event()
150
+ return self._events[key]
151
+
152
+ async def write(self, uri: str, data: Any) -> None:
153
+ """Write data to a URI."""
154
+ key = self._get_key(uri)
155
+ async with self._lock:
156
+ self._store[key] = data
157
+ event = self._get_event(key)
158
+ event.set()
159
+ logger.debug(f"ResultBackend: wrote to {uri}")
160
+
161
+ async def read(self, uri: str, block: bool = True, timeout: Optional[float] = None) -> Any:
162
+ """Read data from a URI."""
163
+ key = self._get_key(uri)
164
+
165
+ if not block:
166
+ return self._store.get(key)
167
+
168
+ event = self._get_event(key)
169
+
170
+ # Check if already available
171
+ if key in self._store:
172
+ return self._store[key]
173
+
174
+ # Wait for data
175
+ try:
176
+ await asyncio.wait_for(event.wait(), timeout=timeout)
177
+ except asyncio.TimeoutError:
178
+ raise TimeoutError(f"Timeout waiting for result at {uri}")
179
+
180
+ return self._store.get(key)
181
+
182
+ async def exists(self, uri: str) -> bool:
183
+ """Check if data exists at a URI."""
184
+ key = self._get_key(uri)
185
+ return key in self._store
186
+
187
+ async def delete(self, uri: str) -> None:
188
+ """Delete data at a URI."""
189
+ key = self._get_key(uri)
190
+ async with self._lock:
191
+ self._store.pop(key, None)
192
+ if key in self._events:
193
+ del self._events[key]
194
+
195
+
196
+ # Singleton for shared in-memory backend
197
+ _default_backend: Optional[InMemoryResultBackend] = None
198
+
199
+
200
+ def get_default_result_backend() -> InMemoryResultBackend:
201
+ """Get the default shared in-memory result backend."""
202
+ global _default_backend
203
+ if _default_backend is None:
204
+ _default_backend = InMemoryResultBackend()
205
+ return _default_backend
206
+
207
+
208
+ def reset_default_result_backend() -> None:
209
+ """Reset the default result backend (for testing)."""
210
+ global _default_backend
211
+ _default_backend = None
212
+
213
+
214
+ __all__ = [
215
+ "ResultBackend",
216
+ "InMemoryResultBackend",
217
+ "LaunchIntent",
218
+ "make_uri",
219
+ "parse_uri",
220
+ "get_default_result_backend",
221
+ "reset_default_result_backend",
222
+ ]