planar 0.5.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 (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
planar/sse/hub.py ADDED
@@ -0,0 +1,216 @@
1
+ import argparse
2
+ import atexit
3
+ import logging
4
+ import logging.config
5
+ import re
6
+ import shutil
7
+ import time
8
+ from asyncio import CancelledError, Queue, create_task, sleep
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass, field
11
+ from fnmatch import translate
12
+ from typing import Any, Optional
13
+ from uuid import UUID, uuid4
14
+ from weakref import WeakSet
15
+
16
+ import uvicorn
17
+ from fastapi import FastAPI, Request
18
+ from starlette.responses import StreamingResponse
19
+
20
+ from planar.config import PlanarConfig
21
+ from planar.logging import get_logger
22
+ from planar.sse.constants import SSE_ENDPOINT
23
+ from planar.sse.model import Event
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ @dataclass(kw_only=True)
29
+ class EventWithTime:
30
+ # Since event storage is volatile, "time" also uniquely identifies the event
31
+ time: int = field(default_factory=time.monotonic_ns)
32
+ event: Event
33
+
34
+
35
+ @dataclass(kw_only=True)
36
+ class Client:
37
+ event_index: int = 0
38
+ patterns: list[str] = field(default_factory=list)
39
+ compiled_patterns: list[re.Pattern] = field(default_factory=list)
40
+ queue: Queue[EventWithTime] = field(default_factory=Queue)
41
+ id: UUID = field(default_factory=uuid4)
42
+
43
+ def __post_init__(self):
44
+ # Compile glob patterns to regex patterns
45
+ self.compiled_patterns = []
46
+ for pattern in self.patterns:
47
+ translated = translate(pattern)
48
+ logger.debug(
49
+ "compiling glob pattern to regex", pattern=pattern, regex=translated
50
+ )
51
+ self.compiled_patterns.append(re.compile(translated))
52
+
53
+ def forward(self, events: list[EventWithTime]):
54
+ while self.event_index < len(events):
55
+ event_time = events[self.event_index]
56
+ event = event_time.event
57
+ self.event_index += 1
58
+ if not self.compiled_patterns:
59
+ self.queue.put_nowait(event_time)
60
+ continue
61
+ for i, compiled_pattern in enumerate(self.compiled_patterns):
62
+ logger.debug(
63
+ "matching event against pattern",
64
+ event_name=event.name,
65
+ pattern=self.patterns[i],
66
+ )
67
+ if compiled_pattern.fullmatch(event.name):
68
+ logger.debug(
69
+ "matched event against pattern",
70
+ event_name=event.name,
71
+ pattern=self.patterns[i],
72
+ )
73
+ self.queue.put_nowait(event_time)
74
+ break
75
+
76
+ def __eq__(self, other):
77
+ return self.id == other.id
78
+
79
+ def __hash__(self):
80
+ return hash(self.id)
81
+
82
+
83
+ events: list[EventWithTime] = []
84
+ clients: WeakSet[Client] = WeakSet()
85
+
86
+
87
+ # Periodically delete events older than 30 seconds.
88
+ async def cleanup_old_events():
89
+ global events
90
+ while True:
91
+ await sleep(1)
92
+ cutoff = time.monotonic_ns() - 30 * 1_000_000_000
93
+ prune_index = None
94
+ # find the index of the first event that is older than 30 seconds,
95
+ # starting from the latest events
96
+ prune_index = -1
97
+ for i in range(len(events) - 1, -1, -1):
98
+ event = events[i]
99
+ if event.time < cutoff:
100
+ prune_index = i
101
+ break
102
+ remove_count = prune_index + 1
103
+ if remove_count > 0:
104
+ for client in clients:
105
+ client.event_index = max(0, client.event_index - remove_count)
106
+ events = events[remove_count:]
107
+ logger.debug("removed old events", count=remove_count)
108
+
109
+
110
+ @asynccontextmanager
111
+ async def lifespan(_: FastAPI):
112
+ prune_task = create_task(cleanup_old_events())
113
+ yield
114
+ prune_task.cancel()
115
+ try:
116
+ await prune_task
117
+ except CancelledError:
118
+ pass
119
+
120
+
121
+ app = FastAPI(title="Planar SSE Hub", lifespan=lifespan)
122
+
123
+
124
+ @app.post("/push")
125
+ def push(event: Event):
126
+ events.append(EventWithTime(event=event))
127
+ for client in clients:
128
+ client.forward(events)
129
+
130
+
131
+ @app.get(SSE_ENDPOINT)
132
+ async def sse(
133
+ request: Request, subscribe: Optional[str] = None, new_events_only: bool = False
134
+ ):
135
+ # Get Last-Event-ID from headers
136
+ last_event_id = request.headers.get("Last-Event-ID")
137
+
138
+ client = Client(patterns=subscribe.split(",") if subscribe else [])
139
+ clients.add(client)
140
+ logger.debug("client connected", client_id=client.id)
141
+
142
+ # Handle Last-Event-ID so that reconnects are handled correctly
143
+ if last_event_id and not new_events_only:
144
+ try:
145
+ last_id = int(last_event_id)
146
+ # Find the index of the event with the matching ID or the first one after it
147
+ for i, event_time in enumerate(events):
148
+ if event_time.time > last_id:
149
+ client.event_index = i
150
+ break
151
+ logger.debug(
152
+ "client resuming from event index",
153
+ event_index=client.event_index,
154
+ last_event_id=last_event_id,
155
+ )
156
+ except ValueError:
157
+ logger.warning("invalid last-event-id format", last_event_id=last_event_id)
158
+
159
+ if not new_events_only:
160
+ client.forward(events)
161
+
162
+ async def event_stream():
163
+ try:
164
+ while True:
165
+ event_time = await client.queue.get()
166
+ # I don't think ordering matters, but in the docs ids always
167
+ # come last, so repeat that here
168
+ yield f"data: {event_time.event.model_dump_json()}\nid: {event_time.time}\n\n"
169
+ except CancelledError:
170
+ logger.debug("client disconnected", client_id=client.id)
171
+
172
+ return StreamingResponse(
173
+ event_stream(),
174
+ media_type="text/event-stream",
175
+ headers={
176
+ "Cache-Control": "no-cache",
177
+ "X-Accel-Buffering": "no",
178
+ },
179
+ )
180
+
181
+
182
+ def builtin_run(server_url: str, socket_dir: str, planar_config_dict: dict[str, Any]):
183
+ # we only use this to configure logging
184
+ config = PlanarConfig.model_validate(planar_config_dict)
185
+ config.configure_logging()
186
+
187
+ def cleanup():
188
+ shutil.rmtree(socket_dir)
189
+
190
+ # ensure the socket directory is removed on exit
191
+ atexit.register(cleanup)
192
+
193
+ # explicitly set 1 worker since this is going to aggregate all events
194
+ # we have to pass log_config=None to prevent uvicorn from trying to
195
+ # configure logging by itself
196
+ uvicorn.run(app, uds=server_url, workers=1, log_config=None)
197
+
198
+
199
+ def parse_args():
200
+ parser = argparse.ArgumentParser()
201
+ parser.add_argument("--host", default="0.0.0.0")
202
+ parser.add_argument("--port", default=8888, type=int)
203
+ return parser.parse_args()
204
+
205
+
206
+ def main():
207
+ args = parse_args()
208
+ logger.setLevel(logging.DEBUG)
209
+ logging.basicConfig(
210
+ level=logging.DEBUG, format="[%(levelname)s] %(asctime)s - %(message)s"
211
+ )
212
+ uvicorn.run(app, host=args.host, port=args.port, workers=1)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
planar/sse/model.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class Event(BaseModel):
7
+ name: str
8
+ payload: dict[str, Any]
planar/sse/proxy.py ADDED
@@ -0,0 +1,257 @@
1
+ import os
2
+ import tempfile
3
+ import uuid
4
+ from asyncio import (
5
+ CancelledError,
6
+ Queue,
7
+ QueueFull,
8
+ Task,
9
+ create_task,
10
+ current_task,
11
+ sleep,
12
+ )
13
+ from contextlib import asynccontextmanager
14
+ from multiprocessing import Process
15
+ from typing import Any
16
+ from weakref import WeakSet
17
+
18
+ import httpx
19
+ from fastapi import APIRouter, Request
20
+ from starlette.responses import StreamingResponse
21
+
22
+ from planar.config import PlanarConfig
23
+ from planar.logging import get_logger
24
+ from planar.sse.constants import SSE_ENDPOINT
25
+ from planar.sse.hub import builtin_run
26
+ from planar.sse.model import Event
27
+
28
+ logger = get_logger(__name__)
29
+
30
+
31
+ def parse_hub_url(
32
+ hub_url: str,
33
+ ) -> tuple[httpx.AsyncHTTPTransport | None, str]:
34
+ transport = None
35
+ if hub_url[0] == "/":
36
+ transport = httpx.AsyncHTTPTransport(uds=hub_url)
37
+ # Use a dummy server URL for formatting the target URL.
38
+ # The transport will ignore it and connect to the domain socket
39
+ hub_url = "http://planar-sse"
40
+ return transport, hub_url
41
+
42
+
43
+ def get_builtin_hub_socket_path():
44
+ # This env var trick has two purposes:
45
+ #
46
+ # - Generate a unique socket path
47
+ # - Distribute the same socket path to all workers.
48
+ #
49
+ # Initially the env var will not be set, so we generate the uuid
50
+ # and set it. When uvicorn spawns workers, they will inherit the
51
+ # parent environment and use it instead of generating their own.
52
+ builtin_hub_uuid = os.getenv("PLANAR_BUILTIN_SSE_UUID", "")
53
+ if builtin_hub_uuid == "":
54
+ builtin_hub_uuid = str(uuid.uuid4())
55
+ os.environ["PLANAR_BUILTIN_SSE_UUID"] = builtin_hub_uuid
56
+ return f"{tempfile.gettempdir()}/planar-sse-{builtin_hub_uuid}/socket"
57
+
58
+
59
+ class SSEProxy:
60
+ def __init__(
61
+ self,
62
+ config: PlanarConfig,
63
+ ):
64
+ sse_hub = config.sse_hub
65
+ self.config = config
66
+ self.enable_builtin_hub = False
67
+ self.hub_url = ""
68
+ self.transport: httpx.AsyncHTTPTransport | None = None
69
+ self.stream_tasks: WeakSet[Task] = WeakSet()
70
+
71
+ if isinstance(sse_hub, str):
72
+ # Connect to a separate SSE hub listening on a TCP address
73
+ self.hub_url = sse_hub
74
+ elif sse_hub is True:
75
+ # Use builtin hub spawned as a subprocess listening on an UNIX socket.
76
+ self.enable_builtin_hub = True
77
+ self.hub_url = get_builtin_hub_socket_path()
78
+
79
+ self.builtin_process: Process | None = None
80
+ self.router = APIRouter()
81
+ self.queue: Queue[Event] = Queue(maxsize=1000)
82
+ self.forward_task: Task | None = None
83
+
84
+ if self.hub_url:
85
+ self.setup_proxy_endpoint()
86
+
87
+ def push(self, name: str, payload: dict[str, Any]):
88
+ try:
89
+ self.queue.put_nowait(Event(name=name, payload=payload))
90
+ except QueueFull:
91
+ # not processing events fast enough, so just ignore it
92
+ logger.warning("sse proxy queue is full, dropping event", event_name=name)
93
+
94
+ def start_builtin_hub(self):
95
+ assert self.hub_url[0] == "/"
96
+ socket_dir = os.path.dirname(self.hub_url)
97
+ logger.debug("attempting to create socket directory", socket_dir=socket_dir)
98
+ try:
99
+ # try to create the socket directory, only one of the workers will
100
+ # succeed
101
+ os.mkdir(socket_dir)
102
+ logger.debug("socket directory created", socket_dir=socket_dir)
103
+ except FileExistsError:
104
+ # another worker created the directory first, ignore
105
+ logger.debug("socket directory already exists", socket_dir=socket_dir)
106
+ return
107
+
108
+ # start the builtin SSE hub in a separate process
109
+ logger.info(
110
+ "starting builtin sse hub process for socket", socket_url=self.hub_url
111
+ )
112
+ self.builtin_process = Process(
113
+ target=builtin_run,
114
+ args=(self.hub_url, socket_dir, self.config.model_dump()),
115
+ )
116
+ self.builtin_process.start()
117
+
118
+ def start(self):
119
+ logger.debug(
120
+ "sseproxy start called",
121
+ hub_url=self.hub_url,
122
+ enable_builtin=self.enable_builtin_hub,
123
+ )
124
+ if not self.hub_url:
125
+ raise ValueError("hub_url is not set")
126
+
127
+ if self.enable_builtin_hub:
128
+ self.start_builtin_hub()
129
+
130
+ self.transport, self.hub_url = parse_hub_url(self.hub_url)
131
+ forward_url = f"{self.hub_url}/push"
132
+
133
+ async def forward():
134
+ logger.debug("sse event forwarding task started", url=forward_url)
135
+ async with httpx.AsyncClient(transport=self.transport) as client:
136
+ while True:
137
+ event = await self.queue.get()
138
+ logger.debug(
139
+ "got event from queue to forward", event_name=event.name
140
+ )
141
+ while True:
142
+ try:
143
+ await client.post(
144
+ forward_url,
145
+ content=event.model_dump_json(),
146
+ headers={"Content-Type": "application/json"},
147
+ )
148
+ logger.info(
149
+ "successfully forwarded event",
150
+ event_name=event.name,
151
+ url=forward_url,
152
+ )
153
+ break
154
+ except Exception:
155
+ logger.exception(
156
+ "exception while forwarding sse event to hub, will retry"
157
+ )
158
+ await sleep(5)
159
+
160
+ self.forward_task = create_task(forward())
161
+ logger.info("sse event forwarding task created")
162
+
163
+ async def stop(self):
164
+ logger.debug("sseproxy stop called")
165
+ if self.forward_task:
166
+ logger.info("cancelling sse event forwarding task")
167
+ self.forward_task.cancel()
168
+ try:
169
+ await self.forward_task
170
+ except CancelledError:
171
+ logger.debug("sse event forwarding task cancelled")
172
+ pass
173
+
174
+ for stream_task in self.stream_tasks:
175
+ logger.debug("cancelling sse stream task", task=stream_task.get_name())
176
+ stream_task.cancel()
177
+
178
+ if self.builtin_process:
179
+ # when using multiple workers, only one worker will have started the
180
+ # builtin hub. That's why we check for self.builtin_process instead of
181
+ # self.enable_builtin_hub. Also helps with type checking.
182
+ logger.info("terminating builtin sse hub process")
183
+ self.builtin_process.terminate()
184
+
185
+ def setup_proxy_endpoint(self):
186
+ @self.router.get("/")
187
+ async def proxy(request: Request):
188
+ logger.debug(
189
+ "sse proxy endpoint called",
190
+ query=request.url.query,
191
+ headers=dict(request.headers),
192
+ )
193
+ async with self.connect(request.url.query, dict(request.headers)) as (
194
+ status,
195
+ headers,
196
+ stream,
197
+ ):
198
+ return StreamingResponse(
199
+ stream(),
200
+ status_code=status,
201
+ headers=headers,
202
+ )
203
+
204
+ return proxy # dummy return to prevent unused warning
205
+
206
+ @asynccontextmanager
207
+ async def connect(self, query: str = "", headers: dict[str, str] = {}):
208
+ logger.debug("sseproxy connect called", query=query, headers=headers)
209
+ if not self.hub_url or not self.transport:
210
+ raise ValueError("hub_url is not set")
211
+
212
+ hub_url = self.hub_url
213
+ transport = self.transport
214
+
215
+ client = httpx.AsyncClient(
216
+ transport=transport, base_url=hub_url, timeout=httpx.Timeout(None)
217
+ )
218
+
219
+ while True:
220
+ try:
221
+ # Construct the target URL
222
+ url = httpx.URL(
223
+ path=SSE_ENDPOINT,
224
+ query=query.encode(),
225
+ )
226
+
227
+ # Build the outgoing request
228
+ proxy_request = client.build_request(
229
+ method="GET",
230
+ url=url,
231
+ headers=headers,
232
+ )
233
+
234
+ # Send the request and stream the response
235
+ response = await client.send(proxy_request, stream=True)
236
+ logger.debug("connected to sse hub", hub_url=hub_url)
237
+
238
+ async def stream(lines: bool = False):
239
+ stream_task = current_task()
240
+ assert stream_task
241
+ self.stream_tasks.add(stream_task)
242
+ try:
243
+ async for chunk in (
244
+ response.aiter_lines() if lines else response.aiter_bytes()
245
+ ):
246
+ yield chunk
247
+ except CancelledError:
248
+ logger.debug("sse stream task cancelled")
249
+ raise
250
+ finally:
251
+ await client.aclose()
252
+
253
+ yield response.status_code, dict(response.headers), stream
254
+ break
255
+ except Exception:
256
+ logger.exception("exception while connecting to sse hub, will retry")
257
+ await sleep(5)
planar/task_local.py ADDED
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+ from typing import Generic, TypeVar
3
+ from weakref import WeakKeyDictionary
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class TaskLocal(Generic[T]):
9
+ def __init__(self):
10
+ self._data = WeakKeyDictionary()
11
+
12
+ def set(self, context: T):
13
+ current_task = asyncio.current_task()
14
+ if not current_task:
15
+ raise RuntimeError("No current task")
16
+ self._data[current_task] = context
17
+
18
+ def get(self) -> T:
19
+ current_task = asyncio.current_task()
20
+ if not current_task:
21
+ raise RuntimeError("No current task")
22
+ context = self._data.get(current_task)
23
+ if context is None:
24
+ raise RuntimeError("No execution context")
25
+ return context
26
+
27
+ def clear(self):
28
+ current_task = asyncio.current_task()
29
+ if not current_task:
30
+ raise RuntimeError("No current task")
31
+ del self._data[current_task]
32
+
33
+ def is_set(self) -> bool:
34
+ current_task = asyncio.current_task()
35
+ if not current_task:
36
+ raise RuntimeError("No current task")
37
+ return self._data.get(current_task) is not None
planar/test_app.py ADDED
@@ -0,0 +1,51 @@
1
+ from dotenv import load_dotenv
2
+ from fastapi import APIRouter
3
+ from pydantic import BaseModel
4
+
5
+ from examples.simple_service.models import (
6
+ Invoice,
7
+ )
8
+ from planar import PlanarApp, sqlite_config
9
+
10
+ load_dotenv()
11
+
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ class InvoiceRequest(BaseModel):
17
+ message: str
18
+
19
+
20
+ class InvoiceResponse(BaseModel):
21
+ status: str
22
+ echo: str
23
+
24
+
25
+ app = PlanarApp(
26
+ config=sqlite_config("simple_service.db"),
27
+ title="Sample Invoice API",
28
+ description="API for CRUD'ing invoices",
29
+ )
30
+
31
+
32
+ def test_register_model_deduplication():
33
+ """Test that registering the same model multiple times only adds it once to the registry."""
34
+
35
+ # Ensure Invoice is registered (ObjectRegistry gets reset before each test)
36
+ app.register_entity(Invoice)
37
+ initial_model_count = len(app._object_registry.get_entities())
38
+
39
+ # Register the Invoice model again
40
+ app.register_entity(Invoice)
41
+
42
+ assert len(app._object_registry.get_entities()) == initial_model_count
43
+
44
+ # Register the same model a second time
45
+ app.register_entity(Invoice)
46
+
47
+ assert len(app._object_registry.get_entities()) == initial_model_count
48
+
49
+ # Verify that the model in the registry is the Invoice model
50
+ registered_models = app._object_registry.get_entities()
51
+ assert any(model.__name__ == "Invoice" for model in registered_models)