flowforge-sdk 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.
flowforge/client.py ADDED
@@ -0,0 +1,380 @@
1
+ """FlowForge client for sending events and managing functions."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Callable, Awaitable, TypeVar
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import uuid
9
+
10
+ import httpx
11
+
12
+ from flowforge.context import Context, Event
13
+ from flowforge.triggers import TriggerBuilder
14
+ from flowforge.config import (
15
+ Concurrency,
16
+ RateLimit,
17
+ Throttle,
18
+ Debounce,
19
+ concurrency as make_concurrency,
20
+ rate_limit as make_rate_limit,
21
+ throttle as make_throttle,
22
+ debounce as make_debounce,
23
+ )
24
+ from flowforge.decorators import FlowForgeFunction, function as make_function
25
+ from flowforge.execution import ExecutionEngine, FunctionDefinition
26
+
27
+ T = TypeVar("T")
28
+
29
+
30
+ class FlowForge:
31
+ """
32
+ FlowForge client for building durable AI workflows.
33
+
34
+ The client provides:
35
+ - Function decorator for defining workflows
36
+ - Event sending for triggering functions
37
+ - Configuration helpers for flow control
38
+ - Framework integrations (FastAPI, Flask, etc.)
39
+
40
+ Example:
41
+ from flowforge import FlowForge, Context, step
42
+
43
+ flowforge = FlowForge(
44
+ app_id="my-app",
45
+ api_url="http://localhost:8000",
46
+ signing_key="sk_...",
47
+ )
48
+
49
+ @flowforge.function(
50
+ id="process-order",
51
+ trigger=flowforge.trigger.event("order/created"),
52
+ )
53
+ async def process_order(ctx: Context) -> dict:
54
+ order = ctx.event.data
55
+ result = await step.run("validate", validate_order, order)
56
+ return {"status": "completed"}
57
+
58
+ # Send an event
59
+ await flowforge.send("order/created", data={"order_id": "123"})
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ app_id: str,
65
+ api_url: str = "http://localhost:8000",
66
+ event_key: str | None = None,
67
+ signing_key: str | None = None,
68
+ ) -> None:
69
+ """
70
+ Initialize the FlowForge client.
71
+
72
+ Args:
73
+ app_id: Unique identifier for your application.
74
+ api_url: URL of the FlowForge API server.
75
+ event_key: API key for sending events.
76
+ signing_key: Key for signing webhook requests.
77
+ """
78
+ self.app_id = app_id
79
+ self.api_url = api_url.rstrip("/")
80
+ self.event_key = event_key
81
+ self.signing_key = signing_key
82
+
83
+ # Trigger builder
84
+ self.trigger = TriggerBuilder()
85
+
86
+ # Function registry
87
+ self._functions: dict[str, FlowForgeFunction] = {}
88
+
89
+ # Execution engine
90
+ self._engine = ExecutionEngine()
91
+
92
+ # HTTP client
93
+ self._http_client: httpx.AsyncClient | None = None
94
+
95
+ # Configuration helpers
96
+ @staticmethod
97
+ def concurrency(limit: int, key: str | None = None) -> Concurrency:
98
+ """Create a concurrency configuration."""
99
+ return make_concurrency(limit, key)
100
+
101
+ @staticmethod
102
+ def rate_limit(limit: int, period: str, key: str | None = None) -> RateLimit:
103
+ """Create a rate limit configuration."""
104
+ return make_rate_limit(limit, period, key)
105
+
106
+ @staticmethod
107
+ def throttle(
108
+ limit: int, period: str, key: str | None = None, burst: int | None = None
109
+ ) -> Throttle:
110
+ """Create a throttle configuration."""
111
+ return make_throttle(limit, period, key, burst)
112
+
113
+ @staticmethod
114
+ def debounce(period: str, key: str | None = None) -> Debounce:
115
+ """Create a debounce configuration."""
116
+ return make_debounce(period, key)
117
+
118
+ def function(
119
+ self,
120
+ id: str,
121
+ *,
122
+ trigger: Any = None,
123
+ name: str | None = None,
124
+ retries: int = 3,
125
+ timeout: str = "5m",
126
+ concurrency: Concurrency | None = None,
127
+ rate_limit: RateLimit | None = None,
128
+ throttle: Throttle | None = None,
129
+ debounce: Debounce | None = None,
130
+ cancel_on: list[str] | None = None,
131
+ idempotency_key: str | None = None,
132
+ ) -> Callable[[Callable[[Context], Awaitable[T]]], FlowForgeFunction]:
133
+ """
134
+ Decorator to define a FlowForge function.
135
+
136
+ Args:
137
+ id: Unique identifier for this function.
138
+ trigger: How this function is triggered.
139
+ name: Human-readable name.
140
+ retries: Number of retry attempts.
141
+ timeout: Maximum execution time.
142
+ concurrency: Concurrency configuration.
143
+ rate_limit: Rate limiting configuration.
144
+ throttle: Throttle configuration.
145
+ debounce: Debounce configuration.
146
+ cancel_on: Events that cancel running instances.
147
+ idempotency_key: Expression for deduplication.
148
+
149
+ Returns:
150
+ Decorator for the function.
151
+ """
152
+
153
+ def decorator(fn: Callable[[Context], Awaitable[T]]) -> FlowForgeFunction:
154
+ # Create the wrapped function
155
+ wrapped = make_function(
156
+ id=id,
157
+ trigger=trigger,
158
+ name=name,
159
+ retries=retries,
160
+ timeout=timeout,
161
+ concurrency=concurrency,
162
+ rate_limit=rate_limit,
163
+ throttle=throttle,
164
+ debounce=debounce,
165
+ cancel_on=cancel_on,
166
+ idempotency_key=idempotency_key,
167
+ )(fn)
168
+
169
+ # Register with the client
170
+ self._functions[id] = wrapped
171
+
172
+ # Register with the execution engine
173
+ self._engine.function_registry[id] = FunctionDefinition(
174
+ id=id,
175
+ name=wrapped.name,
176
+ handler=wrapped,
177
+ trigger=trigger,
178
+ config=wrapped.config.to_dict(),
179
+ )
180
+
181
+ return wrapped
182
+
183
+ return decorator
184
+
185
+ @property
186
+ def functions(self) -> list[FlowForgeFunction]:
187
+ """Get all registered functions."""
188
+ return list(self._functions.values())
189
+
190
+ def get_function(self, function_id: str) -> FlowForgeFunction | None:
191
+ """Get a function by ID."""
192
+ return self._functions.get(function_id)
193
+
194
+ async def _get_client(self) -> httpx.AsyncClient:
195
+ """Get or create the HTTP client."""
196
+ if self._http_client is None:
197
+ self._http_client = httpx.AsyncClient(
198
+ base_url=self.api_url,
199
+ timeout=30.0,
200
+ )
201
+ return self._http_client
202
+
203
+ def _sign_request(self, body: bytes) -> str:
204
+ """Sign a request body with the signing key."""
205
+ if not self.signing_key:
206
+ raise ValueError("Signing key is required for request signing")
207
+
208
+ signature = hmac.new(
209
+ self.signing_key.encode(),
210
+ body,
211
+ hashlib.sha256,
212
+ ).hexdigest()
213
+
214
+ return f"sha256={signature}"
215
+
216
+ async def send(
217
+ self,
218
+ name: str,
219
+ data: dict[str, Any],
220
+ id: str | None = None,
221
+ timestamp: datetime | None = None,
222
+ user_id: str | None = None,
223
+ ) -> str:
224
+ """
225
+ Send an event to trigger functions.
226
+
227
+ Args:
228
+ name: Event type name (e.g., "order/created").
229
+ data: Event payload data.
230
+ id: Optional idempotency key (auto-generated if not provided).
231
+ timestamp: Event timestamp (defaults to now).
232
+ user_id: Optional user ID associated with the event.
233
+
234
+ Returns:
235
+ The event ID.
236
+
237
+ Example:
238
+ event_id = await flowforge.send(
239
+ "order/created",
240
+ data={"order_id": "123", "total": 99.99},
241
+ )
242
+ """
243
+ event_id = id or str(uuid.uuid4())
244
+ event_timestamp = timestamp or datetime.utcnow()
245
+
246
+ event = {
247
+ "id": event_id,
248
+ "name": name,
249
+ "data": data,
250
+ "timestamp": event_timestamp.isoformat() + "Z",
251
+ "user_id": user_id,
252
+ }
253
+
254
+ client = await self._get_client()
255
+
256
+ headers = {
257
+ "Content-Type": "application/json",
258
+ }
259
+
260
+ if self.event_key:
261
+ headers["X-FlowForge-Event-Key"] = self.event_key
262
+
263
+ body = json.dumps(event).encode()
264
+
265
+ if self.signing_key:
266
+ headers["X-FlowForge-Signature"] = self._sign_request(body)
267
+
268
+ response = await client.post(
269
+ "/api/v1/events",
270
+ content=body,
271
+ headers=headers,
272
+ )
273
+ response.raise_for_status()
274
+
275
+ return event_id
276
+
277
+ async def send_many(self, events: list[dict[str, Any] | Event]) -> list[str]:
278
+ """
279
+ Send multiple events in a batch.
280
+
281
+ Args:
282
+ events: List of events to send.
283
+
284
+ Returns:
285
+ List of event IDs.
286
+
287
+ Example:
288
+ event_ids = await flowforge.send_many([
289
+ {"name": "user/signup", "data": {"user_id": "1"}},
290
+ {"name": "user/signup", "data": {"user_id": "2"}},
291
+ ])
292
+ """
293
+ event_ids = []
294
+
295
+ for event in events:
296
+ if isinstance(event, Event):
297
+ event_id = await self.send(
298
+ name=event.name,
299
+ data=event.data,
300
+ id=event.id,
301
+ timestamp=event.timestamp,
302
+ user_id=event.user_id,
303
+ )
304
+ else:
305
+ event_id = await self.send(
306
+ name=event["name"],
307
+ data=event.get("data", {}),
308
+ id=event.get("id"),
309
+ timestamp=event.get("timestamp"),
310
+ user_id=event.get("user_id"),
311
+ )
312
+ event_ids.append(event_id)
313
+
314
+ return event_ids
315
+
316
+ def serve(
317
+ self,
318
+ functions: list[FlowForgeFunction] | None = None,
319
+ host: str = "0.0.0.0",
320
+ port: int = 8080,
321
+ ) -> None:
322
+ """
323
+ Start a local development server.
324
+
325
+ Args:
326
+ functions: Functions to serve (uses registered functions if not provided).
327
+ host: Host to bind to.
328
+ port: Port to listen on.
329
+ """
330
+ from flowforge.dev.server import run_dev_server
331
+
332
+ fns = functions or list(self._functions.values())
333
+ run_dev_server(self, fns, host=host, port=port)
334
+
335
+ def work(
336
+ self,
337
+ functions: list[FlowForgeFunction] | None = None,
338
+ server_url: str | None = None,
339
+ host: str = "0.0.0.0",
340
+ port: int = 8080,
341
+ worker_url: str | None = None,
342
+ ) -> None:
343
+ """
344
+ Start as a worker connected to the central FlowForge server.
345
+
346
+ This mode:
347
+ 1. Registers functions with the central server
348
+ 2. Exposes an /invoke endpoint for the server to call
349
+ 3. Handles function execution
350
+
351
+ Args:
352
+ functions: Functions to serve (uses registered functions if not provided).
353
+ server_url: URL of the central FlowForge server.
354
+ host: Host to bind to.
355
+ port: Port to listen on.
356
+ worker_url: URL where this worker can be reached by the server.
357
+ """
358
+ from flowforge.worker import run_worker
359
+
360
+ fns = functions or list(self._functions.values())
361
+ run_worker(
362
+ self,
363
+ fns,
364
+ server_url=server_url,
365
+ host=host,
366
+ port=port,
367
+ worker_url=worker_url,
368
+ )
369
+
370
+ async def close(self) -> None:
371
+ """Close the HTTP client."""
372
+ if self._http_client:
373
+ await self._http_client.aclose()
374
+ self._http_client = None
375
+
376
+ async def __aenter__(self) -> "FlowForge":
377
+ return self
378
+
379
+ async def __aexit__(self, *args: Any) -> None:
380
+ await self.close()
flowforge/config.py ADDED
@@ -0,0 +1,199 @@
1
+ """Configuration classes for FlowForge functions."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class Concurrency:
9
+ """
10
+ Concurrency configuration for a function.
11
+
12
+ Limits how many instances of a function can run simultaneously.
13
+ """
14
+
15
+ limit: int
16
+ """Maximum number of concurrent executions."""
17
+
18
+ key: str | None = None
19
+ """
20
+ Optional key expression for per-key concurrency limiting.
21
+ Example: "event.data.user_id" limits concurrency per user.
22
+ """
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return {"limit": self.limit, "key": self.key}
26
+
27
+
28
+ @dataclass
29
+ class RateLimit:
30
+ """
31
+ Rate limiting configuration for a function.
32
+
33
+ Limits the rate of function invocations over a time period.
34
+ """
35
+
36
+ limit: int
37
+ """Maximum number of invocations."""
38
+
39
+ period: str
40
+ """Time period (e.g., "1m", "1h", "1d")."""
41
+
42
+ key: str | None = None
43
+ """Optional key expression for per-key rate limiting."""
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ return {"limit": self.limit, "period": self.period, "key": self.key}
47
+
48
+
49
+ @dataclass
50
+ class Throttle:
51
+ """
52
+ Throttle configuration for a function.
53
+
54
+ Ensures a minimum time gap between invocations.
55
+ """
56
+
57
+ limit: int
58
+ """Maximum invocations in the period."""
59
+
60
+ period: str
61
+ """Time period (e.g., "1s", "1m")."""
62
+
63
+ key: str | None = None
64
+ """Optional key expression for per-key throttling."""
65
+
66
+ burst: int | None = None
67
+ """Optional burst allowance."""
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "limit": self.limit,
72
+ "period": self.period,
73
+ "key": self.key,
74
+ "burst": self.burst,
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class Debounce:
80
+ """
81
+ Debounce configuration for a function.
82
+
83
+ Delays execution until no new events arrive for a period.
84
+ """
85
+
86
+ period: str
87
+ """Time to wait for more events before executing."""
88
+
89
+ key: str | None = None
90
+ """Optional key expression for per-key debouncing."""
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return {"period": self.period, "key": self.key}
94
+
95
+
96
+ @dataclass
97
+ class Priority:
98
+ """
99
+ Priority configuration for a function.
100
+
101
+ Controls execution order when jobs are queued.
102
+ """
103
+
104
+ run: str | None = None
105
+ """Expression to determine run priority (e.g., "event.data.priority")."""
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ return {"run": self.run}
109
+
110
+
111
+ @dataclass
112
+ class FunctionConfig:
113
+ """Complete configuration for a FlowForge function."""
114
+
115
+ id: str
116
+ """Unique identifier for the function."""
117
+
118
+ name: str | None = None
119
+ """Human-readable name (defaults to function name)."""
120
+
121
+ retries: int = 3
122
+ """Number of retry attempts on failure."""
123
+
124
+ timeout: str = "5m"
125
+ """Maximum execution time (e.g., "5m", "1h")."""
126
+
127
+ concurrency: Concurrency | None = None
128
+ """Concurrency limiting configuration."""
129
+
130
+ rate_limit: RateLimit | None = None
131
+ """Rate limiting configuration."""
132
+
133
+ throttle: Throttle | None = None
134
+ """Throttle configuration."""
135
+
136
+ debounce: Debounce | None = None
137
+ """Debounce configuration."""
138
+
139
+ priority: Priority | None = None
140
+ """Priority configuration."""
141
+
142
+ cancel_on: list[str] = field(default_factory=list)
143
+ """Events that cancel running instances."""
144
+
145
+ idempotency_key: str | None = None
146
+ """Expression for idempotency (e.g., "event.data.order_id")."""
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ config: dict[str, Any] = {
150
+ "id": self.id,
151
+ "name": self.name,
152
+ "retries": self.retries,
153
+ "timeout": self.timeout,
154
+ }
155
+
156
+ if self.concurrency:
157
+ config["concurrency"] = self.concurrency.to_dict()
158
+ if self.rate_limit:
159
+ config["rate_limit"] = self.rate_limit.to_dict()
160
+ if self.throttle:
161
+ config["throttle"] = self.throttle.to_dict()
162
+ if self.debounce:
163
+ config["debounce"] = self.debounce.to_dict()
164
+ if self.priority:
165
+ config["priority"] = self.priority.to_dict()
166
+ if self.cancel_on:
167
+ config["cancel_on"] = self.cancel_on
168
+ if self.idempotency_key:
169
+ config["idempotency_key"] = self.idempotency_key
170
+
171
+ return config
172
+
173
+
174
+ # Convenience functions for creating configurations
175
+ def concurrency(limit: int, key: str | None = None) -> Concurrency:
176
+ """Create a concurrency configuration."""
177
+ return Concurrency(limit=limit, key=key)
178
+
179
+
180
+ def rate_limit(limit: int, period: str, key: str | None = None) -> RateLimit:
181
+ """Create a rate limit configuration."""
182
+ return RateLimit(limit=limit, period=period, key=key)
183
+
184
+
185
+ def throttle(
186
+ limit: int, period: str, key: str | None = None, burst: int | None = None
187
+ ) -> Throttle:
188
+ """Create a throttle configuration."""
189
+ return Throttle(limit=limit, period=period, key=key, burst=burst)
190
+
191
+
192
+ def debounce(period: str, key: str | None = None) -> Debounce:
193
+ """Create a debounce configuration."""
194
+ return Debounce(period=period, key=key)
195
+
196
+
197
+ def priority(run: str | None = None) -> Priority:
198
+ """Create a priority configuration."""
199
+ return Priority(run=run)