dory-sdk 2.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.
Files changed (69) hide show
  1. dory/__init__.py +70 -0
  2. dory/auto_instrument.py +142 -0
  3. dory/cli/__init__.py +5 -0
  4. dory/cli/main.py +290 -0
  5. dory/cli/templates.py +333 -0
  6. dory/config/__init__.py +23 -0
  7. dory/config/defaults.py +50 -0
  8. dory/config/loader.py +361 -0
  9. dory/config/presets.py +325 -0
  10. dory/config/schema.py +152 -0
  11. dory/core/__init__.py +27 -0
  12. dory/core/app.py +404 -0
  13. dory/core/context.py +209 -0
  14. dory/core/lifecycle.py +214 -0
  15. dory/core/meta.py +121 -0
  16. dory/core/modes.py +479 -0
  17. dory/core/processor.py +654 -0
  18. dory/core/signals.py +122 -0
  19. dory/decorators.py +142 -0
  20. dory/errors/__init__.py +117 -0
  21. dory/errors/classification.py +362 -0
  22. dory/errors/codes.py +495 -0
  23. dory/health/__init__.py +10 -0
  24. dory/health/probes.py +210 -0
  25. dory/health/server.py +306 -0
  26. dory/k8s/__init__.py +11 -0
  27. dory/k8s/annotation_watcher.py +184 -0
  28. dory/k8s/client.py +251 -0
  29. dory/k8s/pod_metadata.py +182 -0
  30. dory/logging/__init__.py +9 -0
  31. dory/logging/logger.py +175 -0
  32. dory/metrics/__init__.py +7 -0
  33. dory/metrics/collector.py +301 -0
  34. dory/middleware/__init__.py +36 -0
  35. dory/middleware/connection_tracker.py +608 -0
  36. dory/middleware/request_id.py +321 -0
  37. dory/middleware/request_tracker.py +501 -0
  38. dory/migration/__init__.py +11 -0
  39. dory/migration/configmap.py +260 -0
  40. dory/migration/serialization.py +167 -0
  41. dory/migration/state_manager.py +301 -0
  42. dory/monitoring/__init__.py +23 -0
  43. dory/monitoring/opentelemetry.py +462 -0
  44. dory/py.typed +2 -0
  45. dory/recovery/__init__.py +60 -0
  46. dory/recovery/golden_image.py +480 -0
  47. dory/recovery/golden_snapshot.py +561 -0
  48. dory/recovery/golden_validator.py +518 -0
  49. dory/recovery/partial_recovery.py +479 -0
  50. dory/recovery/recovery_decision.py +242 -0
  51. dory/recovery/restart_detector.py +142 -0
  52. dory/recovery/state_validator.py +187 -0
  53. dory/resilience/__init__.py +45 -0
  54. dory/resilience/circuit_breaker.py +454 -0
  55. dory/resilience/retry.py +389 -0
  56. dory/sidecar/__init__.py +6 -0
  57. dory/sidecar/main.py +75 -0
  58. dory/sidecar/server.py +329 -0
  59. dory/simple.py +342 -0
  60. dory/types.py +75 -0
  61. dory/utils/__init__.py +25 -0
  62. dory/utils/errors.py +59 -0
  63. dory/utils/retry.py +115 -0
  64. dory/utils/timeout.py +80 -0
  65. dory_sdk-2.1.0.dist-info/METADATA +663 -0
  66. dory_sdk-2.1.0.dist-info/RECORD +69 -0
  67. dory_sdk-2.1.0.dist-info/WHEEL +5 -0
  68. dory_sdk-2.1.0.dist-info/entry_points.txt +3 -0
  69. dory_sdk-2.1.0.dist-info/top_level.txt +1 -0
dory/sidecar/server.py ADDED
@@ -0,0 +1,329 @@
1
+ """Sidecar health server that proxies health checks for non-SDK apps."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+
10
+ import aiohttp
11
+ from aiohttp import web
12
+
13
+ logger = logging.getLogger("dory.sidecar")
14
+
15
+
16
+ @dataclass
17
+ class SidecarConfig:
18
+ """Configuration for the sidecar server."""
19
+
20
+ # Sidecar health server port
21
+ health_port: int = 8080
22
+
23
+ # Main app configuration (optional - for health forwarding)
24
+ app_port: Optional[int] = None
25
+ app_host: str = "localhost"
26
+ app_health_path: Optional[str] = None # e.g., "/health"
27
+ app_ready_path: Optional[str] = None # e.g., "/ready"
28
+ app_prestop_path: Optional[str] = None # e.g., "/shutdown"
29
+
30
+ # Timeouts
31
+ app_check_timeout: float = 2.0
32
+ prestop_timeout: float = 25.0
33
+
34
+ # Behavior
35
+ ready_requires_app: bool = False # If True, /ready fails if app doesn't respond
36
+
37
+ @classmethod
38
+ def from_env(cls) -> "SidecarConfig":
39
+ """Load configuration from environment variables."""
40
+ return cls(
41
+ health_port=int(os.getenv("DORY_HEALTH_PORT", "8080")),
42
+ app_port=int(os.getenv("DORY_APP_PORT")) if os.getenv("DORY_APP_PORT") else None,
43
+ app_host=os.getenv("DORY_APP_HOST", "localhost"),
44
+ app_health_path=os.getenv("DORY_APP_HEALTH_PATH"),
45
+ app_ready_path=os.getenv("DORY_APP_READY_PATH"),
46
+ app_prestop_path=os.getenv("DORY_APP_PRESTOP_PATH"),
47
+ app_check_timeout=float(os.getenv("DORY_APP_CHECK_TIMEOUT", "2.0")),
48
+ prestop_timeout=float(os.getenv("DORY_PRESTOP_TIMEOUT", "25.0")),
49
+ ready_requires_app=os.getenv("DORY_READY_REQUIRES_APP", "false").lower() == "true",
50
+ )
51
+
52
+
53
+ class SidecarServer:
54
+ """
55
+ Lightweight sidecar server that provides Kubernetes health endpoints.
56
+
57
+ This allows apps without SDK integration to run in Dory by providing
58
+ the required health endpoints. Apps won't get state migration benefits,
59
+ but they will run normally.
60
+
61
+ Features:
62
+ - Always responds to /healthz (sidecar is alive)
63
+ - Optionally checks main app for /ready
64
+ - Optionally calls main app on /prestop
65
+ - Exposes basic /metrics
66
+ """
67
+
68
+ def __init__(self, config: Optional[SidecarConfig] = None):
69
+ self.config = config or SidecarConfig.from_env()
70
+ self._app: Optional[web.Application] = None
71
+ self._runner: Optional[web.AppRunner] = None
72
+ self._start_time = time.time()
73
+ self._ready = True
74
+ self._shutting_down = False
75
+ self._request_count = 0
76
+ self._app_check_failures = 0
77
+
78
+ async def _check_app_endpoint(self, path: str) -> tuple[bool, str]:
79
+ """Check if the main app responds on the given path."""
80
+ if not self.config.app_port:
81
+ return True, "No app port configured"
82
+
83
+ url = f"http://{self.config.app_host}:{self.config.app_port}{path}"
84
+
85
+ try:
86
+ timeout = aiohttp.ClientTimeout(total=self.config.app_check_timeout)
87
+ async with aiohttp.ClientSession(timeout=timeout) as session:
88
+ async with session.get(url) as response:
89
+ if response.status < 400:
90
+ return True, f"App responded with {response.status}"
91
+ else:
92
+ return False, f"App responded with {response.status}"
93
+ except asyncio.TimeoutError:
94
+ return False, "App health check timed out"
95
+ except aiohttp.ClientError as e:
96
+ return False, f"App connection failed: {e}"
97
+ except Exception as e:
98
+ return False, f"App check error: {e}"
99
+
100
+ async def _call_app_prestop(self) -> tuple[bool, str]:
101
+ """Call the main app's prestop endpoint if configured."""
102
+ if not self.config.app_port or not self.config.app_prestop_path:
103
+ return True, "No app prestop path configured"
104
+
105
+ url = f"http://{self.config.app_host}:{self.config.app_port}{self.config.app_prestop_path}"
106
+
107
+ try:
108
+ timeout = aiohttp.ClientTimeout(total=self.config.prestop_timeout)
109
+ async with aiohttp.ClientSession(timeout=timeout) as session:
110
+ async with session.get(url) as response:
111
+ body = await response.text()
112
+ return response.status < 400, f"App prestop returned {response.status}: {body}"
113
+ except Exception as e:
114
+ return False, f"App prestop failed: {e}"
115
+
116
+ async def handle_healthz(self, request: web.Request) -> web.Response:
117
+ """
118
+ Liveness probe - returns 200 if sidecar is running.
119
+
120
+ This always succeeds because if we can respond, we're alive.
121
+ The main app's health is checked separately via /ready if configured.
122
+ """
123
+ self._request_count += 1
124
+
125
+ return web.json_response({
126
+ "status": "healthy",
127
+ "sidecar": True,
128
+ "uptime_seconds": int(time.time() - self._start_time),
129
+ })
130
+
131
+ async def handle_ready(self, request: web.Request) -> web.Response:
132
+ """
133
+ Readiness probe - optionally checks main app health.
134
+
135
+ Behavior depends on configuration:
136
+ - If app_ready_path is set, checks that endpoint
137
+ - If app_health_path is set (and ready_path not), checks health endpoint
138
+ - If app_port is set (no paths), checks if port is accepting connections
139
+ - If ready_requires_app=true, fails if app check fails
140
+ - Otherwise, always returns ready
141
+ """
142
+ self._request_count += 1
143
+
144
+ if self._shutting_down:
145
+ return web.json_response(
146
+ {"status": "shutting_down", "ready": False},
147
+ status=503
148
+ )
149
+
150
+ # Determine which path to check
151
+ check_path = self.config.app_ready_path or self.config.app_health_path
152
+
153
+ app_ok = True
154
+ app_message = "No app check configured"
155
+
156
+ if self.config.app_port:
157
+ if check_path:
158
+ app_ok, app_message = await self._check_app_endpoint(check_path)
159
+ else:
160
+ # Just check if port is open
161
+ try:
162
+ _, writer = await asyncio.wait_for(
163
+ asyncio.open_connection(self.config.app_host, self.config.app_port),
164
+ timeout=self.config.app_check_timeout
165
+ )
166
+ writer.close()
167
+ await writer.wait_closed()
168
+ app_ok = True
169
+ app_message = "App port is accepting connections"
170
+ except Exception as e:
171
+ app_ok = False
172
+ app_message = f"App port not responding: {e}"
173
+
174
+ if not app_ok:
175
+ self._app_check_failures += 1
176
+
177
+ # Decide if we should report not ready
178
+ if not app_ok and self.config.ready_requires_app:
179
+ return web.json_response(
180
+ {
181
+ "status": "not_ready",
182
+ "ready": False,
183
+ "app_status": app_message,
184
+ "sidecar": True,
185
+ },
186
+ status=503
187
+ )
188
+
189
+ return web.json_response({
190
+ "status": "ready",
191
+ "ready": True,
192
+ "app_status": app_message,
193
+ "app_ok": app_ok,
194
+ "sidecar": True,
195
+ })
196
+
197
+ async def handle_prestop(self, request: web.Request) -> web.Response:
198
+ """
199
+ PreStop hook handler - signals graceful shutdown.
200
+
201
+ If app_prestop_path is configured, calls that endpoint to give
202
+ the main app a chance to clean up. Otherwise just marks as shutting down.
203
+
204
+ Note: Without SDK integration, no state will be saved.
205
+ """
206
+ self._request_count += 1
207
+ self._shutting_down = True
208
+ self._ready = False
209
+
210
+ logger.info("PreStop hook called - beginning graceful shutdown")
211
+
212
+ # Call app's prestop if configured
213
+ app_prestop_ok, app_prestop_message = await self._call_app_prestop()
214
+
215
+ if not app_prestop_ok:
216
+ logger.warning(f"App prestop failed: {app_prestop_message}")
217
+
218
+ return web.json_response({
219
+ "status": "shutting_down",
220
+ "app_prestop": app_prestop_message,
221
+ "state_saved": False, # No state save without SDK
222
+ "sidecar": True,
223
+ })
224
+
225
+ async def handle_metrics(self, request: web.Request) -> web.Response:
226
+ """Prometheus metrics endpoint."""
227
+ self._request_count += 1
228
+
229
+ uptime = time.time() - self._start_time
230
+
231
+ metrics = f"""# HELP dory_sidecar_up Sidecar is running
232
+ # TYPE dory_sidecar_up gauge
233
+ dory_sidecar_up 1
234
+
235
+ # HELP dory_sidecar_uptime_seconds Sidecar uptime in seconds
236
+ # TYPE dory_sidecar_uptime_seconds gauge
237
+ dory_sidecar_uptime_seconds {uptime:.2f}
238
+
239
+ # HELP dory_sidecar_requests_total Total requests handled
240
+ # TYPE dory_sidecar_requests_total counter
241
+ dory_sidecar_requests_total {self._request_count}
242
+
243
+ # HELP dory_sidecar_app_check_failures_total App health check failures
244
+ # TYPE dory_sidecar_app_check_failures_total counter
245
+ dory_sidecar_app_check_failures_total {self._app_check_failures}
246
+
247
+ # HELP dory_sidecar_shutting_down Sidecar is shutting down
248
+ # TYPE dory_sidecar_shutting_down gauge
249
+ dory_sidecar_shutting_down {1 if self._shutting_down else 0}
250
+
251
+ # HELP dory_sidecar_ready Sidecar ready status
252
+ # TYPE dory_sidecar_ready gauge
253
+ dory_sidecar_ready {1 if self._ready and not self._shutting_down else 0}
254
+ """
255
+ return web.Response(text=metrics, content_type="text/plain")
256
+
257
+ async def handle_state(self, request: web.Request) -> web.Response:
258
+ """
259
+ State endpoint - returns empty state for non-SDK apps.
260
+
261
+ This endpoint exists for compatibility but returns no state
262
+ since the app doesn't use the SDK for state management.
263
+ """
264
+ self._request_count += 1
265
+
266
+ if request.method == "GET":
267
+ return web.json_response({
268
+ "state": None,
269
+ "message": "No state available - app does not use Dory SDK",
270
+ "sidecar": True,
271
+ })
272
+ elif request.method == "POST":
273
+ return web.json_response({
274
+ "restored": False,
275
+ "message": "Cannot restore state - app does not use Dory SDK",
276
+ "sidecar": True,
277
+ })
278
+ else:
279
+ return web.json_response(
280
+ {"error": "Method not allowed"},
281
+ status=405
282
+ )
283
+
284
+ def _create_app(self) -> web.Application:
285
+ """Create the aiohttp application."""
286
+ app = web.Application()
287
+
288
+ app.router.add_get("/healthz", self.handle_healthz)
289
+ app.router.add_get("/ready", self.handle_ready)
290
+ app.router.add_get("/prestop", self.handle_prestop)
291
+ app.router.add_get("/metrics", self.handle_metrics)
292
+ app.router.add_get("/state", self.handle_state)
293
+ app.router.add_post("/state", self.handle_state)
294
+
295
+ return app
296
+
297
+ async def start(self) -> None:
298
+ """Start the sidecar server."""
299
+ self._app = self._create_app()
300
+ self._runner = web.AppRunner(self._app)
301
+ await self._runner.setup()
302
+
303
+ site = web.TCPSite(self._runner, "0.0.0.0", self.config.health_port)
304
+ await site.start()
305
+
306
+ logger.info(f"Dory sidecar started on port {self.config.health_port}")
307
+
308
+ if self.config.app_port:
309
+ logger.info(f"Monitoring app on {self.config.app_host}:{self.config.app_port}")
310
+ else:
311
+ logger.info("No app port configured - running in standalone mode")
312
+
313
+ async def stop(self) -> None:
314
+ """Stop the sidecar server."""
315
+ if self._runner:
316
+ await self._runner.cleanup()
317
+ logger.info("Dory sidecar stopped")
318
+
319
+ async def run_forever(self) -> None:
320
+ """Run the sidecar server until interrupted."""
321
+ await self.start()
322
+
323
+ try:
324
+ while True:
325
+ await asyncio.sleep(3600)
326
+ except asyncio.CancelledError:
327
+ pass
328
+ finally:
329
+ await self.stop()
dory/simple.py ADDED
@@ -0,0 +1,342 @@
1
+ """
2
+ Simple function-based API for Dory processors.
3
+
4
+ For applications that don't need the full class-based API,
5
+ this module provides a simpler decorator-based approach.
6
+
7
+ Usage:
8
+ from dory.simple import processor, state
9
+
10
+ counter = state(0)
11
+
12
+ @processor
13
+ async def main(ctx):
14
+ async for _ in ctx.run_loop(interval=1):
15
+ counter.value += 1
16
+ """
17
+
18
+ import asyncio
19
+ import inspect
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Callable, Awaitable, TypeVar, Generic
22
+
23
+ from dory.core.app import DoryApp
24
+ from dory.core.processor import BaseProcessor
25
+ from dory.core.context import ExecutionContext
26
+
27
+ T = TypeVar('T')
28
+
29
+
30
+ @dataclass
31
+ class StateVar(Generic[T]):
32
+ """
33
+ A state variable for function-based processors.
34
+
35
+ Usage:
36
+ counter = state(0)
37
+ sessions = state(dict) # Factory for mutable defaults
38
+
39
+ @processor
40
+ async def main(ctx):
41
+ counter.value += 1
42
+ sessions.value["user1"] = data
43
+ """
44
+ _default: T | Callable[[], T]
45
+ _value: T = field(default=None, init=False, repr=False)
46
+ _initialized: bool = field(default=False, init=False)
47
+
48
+ @property
49
+ def value(self) -> T:
50
+ """Get the current value."""
51
+ if not self._initialized:
52
+ if callable(self._default):
53
+ self._value = self._default()
54
+ else:
55
+ self._value = self._default
56
+ self._initialized = True
57
+ return self._value
58
+
59
+ @value.setter
60
+ def value(self, new_value: T) -> None:
61
+ """Set a new value."""
62
+ self._value = new_value
63
+ self._initialized = True
64
+
65
+ def reset(self) -> None:
66
+ """Reset to default value."""
67
+ self._initialized = False
68
+
69
+ def get(self) -> T:
70
+ """Alias for value property."""
71
+ return self.value
72
+
73
+ def set(self, new_value: T) -> None:
74
+ """Alias for value setter."""
75
+ self.value = new_value
76
+
77
+
78
+ def state(default: T | Callable[[], T] = None) -> StateVar[T]:
79
+ """
80
+ Create a state variable for function-based processors.
81
+
82
+ State variables are automatically saved and restored during migrations.
83
+
84
+ Args:
85
+ default: Default value or factory function for mutable defaults
86
+
87
+ Usage:
88
+ # Simple values
89
+ counter = state(0)
90
+ name = state("default")
91
+
92
+ # Mutable values (use factory)
93
+ data = state(dict) # Creates new dict each time
94
+ items = state(list) # Creates new list each time
95
+
96
+ @processor
97
+ async def main(ctx):
98
+ counter.value += 1
99
+ data.value["key"] = "value"
100
+ """
101
+ return StateVar(_default=default)
102
+
103
+
104
+ # Global registry of state variables for current processor
105
+ _state_registry: dict[str, StateVar] = {}
106
+
107
+
108
+ def _register_state(name: str, var: StateVar) -> None:
109
+ """Register a state variable (called by processor decorator)."""
110
+ _state_registry[name] = var
111
+
112
+
113
+ def _get_all_state() -> dict[str, Any]:
114
+ """Get all state values."""
115
+ return {name: var.value for name, var in _state_registry.items()}
116
+
117
+
118
+ def _set_all_state(values: dict[str, Any]) -> None:
119
+ """Set all state values."""
120
+ for name, value in values.items():
121
+ if name in _state_registry:
122
+ _state_registry[name].value = value
123
+
124
+
125
+ class FunctionProcessor(BaseProcessor):
126
+ """
127
+ Wrapper that converts a function into a BaseProcessor.
128
+
129
+ Internal use by @processor decorator.
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ func: Callable[[ExecutionContext], Awaitable[None]],
135
+ state_vars: dict[str, StateVar],
136
+ context: ExecutionContext | None = None,
137
+ ):
138
+ super().__init__(context)
139
+ self._func = func
140
+ self._state_vars = state_vars
141
+
142
+ async def run(self) -> None:
143
+ """Run the wrapped function."""
144
+ await self._func(self.context)
145
+
146
+ def get_state(self) -> dict:
147
+ """Get state from registered state variables."""
148
+ return {name: var.value for name, var in self._state_vars.items()}
149
+
150
+ async def restore_state(self, state: dict) -> None:
151
+ """Restore state to registered state variables."""
152
+ for name, value in state.items():
153
+ if name in self._state_vars:
154
+ self._state_vars[name].value = value
155
+
156
+
157
+ class ContextWrapper:
158
+ """
159
+ Wrapper around ExecutionContext with additional helpers for function-based API.
160
+
161
+ Provides run_loop() and other conveniences directly on ctx.
162
+ """
163
+
164
+ def __init__(self, context: ExecutionContext):
165
+ self._context = context
166
+
167
+ def __getattr__(self, name: str) -> Any:
168
+ """Delegate to underlying context."""
169
+ return getattr(self._context, name)
170
+
171
+ async def run_loop(
172
+ self,
173
+ interval: float = 1.0,
174
+ check_migration: bool = True,
175
+ ):
176
+ """
177
+ Async iterator that yields until shutdown is requested.
178
+
179
+ Usage:
180
+ @processor
181
+ async def main(ctx):
182
+ async for i in ctx.run_loop(interval=1):
183
+ counter.value += 1
184
+ """
185
+ iteration = 0
186
+ while not self._context.is_shutdown_requested():
187
+ yield iteration
188
+ iteration += 1
189
+
190
+ if check_migration and self._context.is_migration_imminent():
191
+ self._context.logger().info(
192
+ f"Migration imminent, completing iteration {iteration}"
193
+ )
194
+
195
+ await asyncio.sleep(interval)
196
+
197
+ @property
198
+ def shutdown_requested(self) -> bool:
199
+ """Check if shutdown is requested (simpler than is_shutdown_requested())."""
200
+ return self._context.is_shutdown_requested()
201
+
202
+ @property
203
+ def migration_imminent(self) -> bool:
204
+ """Check if migration is imminent (simpler than is_migration_imminent())."""
205
+ return self._context.is_migration_imminent()
206
+
207
+
208
+ def processor(
209
+ func: Callable[[ContextWrapper], Awaitable[None]] | None = None,
210
+ *,
211
+ config_file: str | None = None,
212
+ log_level: str | None = None,
213
+ ):
214
+ """
215
+ Decorator to create a Dory processor from a simple async function.
216
+
217
+ This is the simplest way to create a Dory processor. Just decorate
218
+ your main async function and it handles everything else.
219
+
220
+ Args:
221
+ func: The async function to wrap
222
+ config_file: Optional path to config file
223
+ log_level: Optional log level override
224
+
225
+ Usage:
226
+ # Minimal stateless processor
227
+ from dory.simple import processor
228
+
229
+ @processor
230
+ async def main(ctx):
231
+ while not ctx.shutdown_requested:
232
+ print("Working...")
233
+ await asyncio.sleep(1)
234
+
235
+ # With state
236
+ from dory.simple import processor, state
237
+
238
+ counter = state(0)
239
+ data = state(dict)
240
+
241
+ @processor
242
+ async def main(ctx):
243
+ async for i in ctx.run_loop(interval=1):
244
+ counter.value += 1
245
+ print(f"Count: {counter.value}")
246
+
247
+ # With config
248
+ @processor(config_file="dory.yaml", log_level="DEBUG")
249
+ async def main(ctx):
250
+ ...
251
+ """
252
+ def decorator(fn: Callable[[ContextWrapper], Awaitable[None]]):
253
+ # Collect state variables from the module
254
+ frame = inspect.currentframe()
255
+ if frame and frame.f_back and frame.f_back.f_back:
256
+ module_globals = frame.f_back.f_back.f_globals
257
+ state_vars = {
258
+ name: var
259
+ for name, var in module_globals.items()
260
+ if isinstance(var, StateVar)
261
+ }
262
+ else:
263
+ state_vars = {}
264
+
265
+ # Create wrapper function that wraps context
266
+ async def wrapped_func(context: ExecutionContext) -> None:
267
+ wrapper = ContextWrapper(context)
268
+ await fn(wrapper)
269
+
270
+ # Create processor class dynamically
271
+ class DynamicProcessor(FunctionProcessor):
272
+ def __init__(self, context: ExecutionContext | None = None):
273
+ super().__init__(wrapped_func, state_vars, context)
274
+
275
+ # Run immediately if this is the main module
276
+ if frame and frame.f_back and frame.f_back.f_back:
277
+ if module_globals.get("__name__") == "__main__":
278
+ DoryApp(
279
+ config_file=config_file,
280
+ log_level=log_level,
281
+ ).run(DynamicProcessor)
282
+
283
+ return fn
284
+
285
+ if func is not None:
286
+ # Called without arguments: @processor
287
+ return decorator(func)
288
+ else:
289
+ # Called with arguments: @processor(config_file="...")
290
+ return decorator
291
+
292
+
293
+ def run_processor(
294
+ func: Callable[[ContextWrapper], Awaitable[None]],
295
+ *,
296
+ config_file: str | None = None,
297
+ log_level: str | None = None,
298
+ ) -> None:
299
+ """
300
+ Run a function as a Dory processor.
301
+
302
+ Alternative to @processor decorator when you want explicit control.
303
+
304
+ Usage:
305
+ from dory.simple import run_processor, state
306
+
307
+ counter = state(0)
308
+
309
+ async def main(ctx):
310
+ async for _ in ctx.run_loop(interval=1):
311
+ counter.value += 1
312
+
313
+ if __name__ == "__main__":
314
+ run_processor(main)
315
+ """
316
+ # Collect state variables from caller's module
317
+ frame = inspect.currentframe()
318
+ if frame and frame.f_back:
319
+ module_globals = frame.f_back.f_globals
320
+ state_vars = {
321
+ name: var
322
+ for name, var in module_globals.items()
323
+ if isinstance(var, StateVar)
324
+ }
325
+ else:
326
+ state_vars = {}
327
+
328
+ # Create wrapper function
329
+ async def wrapped_func(context: ExecutionContext) -> None:
330
+ wrapper = ContextWrapper(context)
331
+ await func(wrapper)
332
+
333
+ # Create processor class
334
+ class DynamicProcessor(FunctionProcessor):
335
+ def __init__(self, context: ExecutionContext | None = None):
336
+ super().__init__(wrapped_func, state_vars, context)
337
+
338
+ # Run
339
+ DoryApp(
340
+ config_file=config_file,
341
+ log_level=log_level,
342
+ ).run(DynamicProcessor)