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
@@ -0,0 +1,197 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
5
+ from fastapi.responses import StreamingResponse
6
+ from pydantic import BaseModel
7
+
8
+ from planar.ai.agent_utils import AgentEventEmitter, AgentEventType, agent_configuration
9
+ from planar.ai.models import AgentConfig
10
+ from planar.ai.utils import AgentSerializeable, serialize_agent
11
+ from planar.logging import get_logger
12
+ from planar.object_config.object_config import ConfigValidationError
13
+ from planar.object_registry import ObjectRegistry
14
+ from planar.security.authorization import (
15
+ AgentAction,
16
+ AgentResource,
17
+ validate_authorization_for,
18
+ )
19
+ from planar.session import get_engine, session_context
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class AgentSimulationRequestBody(BaseModel):
25
+ input_value: str | dict[str, Any]
26
+
27
+
28
+ class AgentSimulationData[T](BaseModel):
29
+ input_value: str | T
30
+
31
+
32
+ class AgentEvent(BaseModel):
33
+ """Model representing a single event emitted by the agent."""
34
+
35
+ event: str
36
+ data: dict
37
+
38
+
39
+ class AgentErrorData(BaseModel):
40
+ detail: str
41
+
42
+
43
+ class AgentUpdateRequest(BaseModel):
44
+ """Model for updating agent information."""
45
+
46
+ system_prompt: str | None = None
47
+ user_prompt: str | None = None
48
+
49
+
50
+ def create_agent_router(object_registry: ObjectRegistry) -> APIRouter:
51
+ router = APIRouter(tags=["Agents"])
52
+
53
+ @router.get("/", response_model=list[AgentSerializeable])
54
+ async def get_agents():
55
+ """Get all agents."""
56
+ validate_authorization_for(AgentResource(), AgentAction.AGENT_LIST)
57
+ registered_agents = object_registry.get_agents()
58
+ serialized_agents: list[AgentSerializeable] = []
59
+
60
+ for reg_agent in registered_agents:
61
+ agent_serializable = await serialize_agent(
62
+ agent_obj=reg_agent,
63
+ )
64
+
65
+ if agent_serializable:
66
+ serialized_agents.append(agent_serializable)
67
+
68
+ return serialized_agents
69
+
70
+ @router.patch("/{agent_name}", response_model=AgentSerializeable)
71
+ async def update_agent(agent_name: str, request: AgentUpdateRequest):
72
+ """Update agent information (system prompt and user prompt)."""
73
+ validate_authorization_for(
74
+ AgentResource(id=agent_name), AgentAction.AGENT_UPDATE
75
+ )
76
+ agents = object_registry.get_agents()
77
+ agent = next((a for a in agents if a.name == agent_name), None)
78
+ if not agent:
79
+ logger.warning("agent not found for update", agent_name=agent_name)
80
+ raise HTTPException(status_code=404, detail="Agent not found")
81
+
82
+ update = AgentConfig(
83
+ model=str(agent.model),
84
+ max_turns=agent.max_turns,
85
+ model_parameters=agent.model_parameters,
86
+ # At the moment, these are the only two fields that can be overridden
87
+ system_prompt=request.system_prompt or agent.system_prompt,
88
+ user_prompt=request.user_prompt or agent.user_prompt,
89
+ )
90
+
91
+ try:
92
+ await agent_configuration.write_config(agent.name, update)
93
+ except ConfigValidationError as e:
94
+ raise HTTPException(
95
+ status_code=400,
96
+ detail=e.to_api_response().model_dump(mode="json", by_alias=True),
97
+ )
98
+
99
+ logger.info(
100
+ "configuration updated for agent",
101
+ agent_name=agent.name,
102
+ )
103
+
104
+ agent_serializable = await serialize_agent(
105
+ agent_obj=agent,
106
+ )
107
+
108
+ if not agent_serializable:
109
+ logger.warning(
110
+ "failed to create serializable representation for agent after update",
111
+ agent_name=agent.name,
112
+ )
113
+ raise HTTPException(
114
+ status_code=500, detail="Failed to create agent representation"
115
+ )
116
+
117
+ return agent_serializable
118
+
119
+ @router.post(
120
+ "/{agent_name}/simulate",
121
+ response_model=None, # No standard response model for SSE
122
+ responses={
123
+ 200: {
124
+ "description": "Stream of agent events",
125
+ "content": {
126
+ "text/event-stream": {
127
+ "schema": {
128
+ "type": "object",
129
+ }
130
+ }
131
+ },
132
+ }
133
+ },
134
+ )
135
+ async def simulate_agent(
136
+ agent_name: str,
137
+ request: AgentSimulationRequestBody,
138
+ background_tasks: BackgroundTasks,
139
+ ):
140
+ """Simulate an agent."""
141
+ validate_authorization_for(
142
+ AgentResource(id=agent_name), AgentAction.AGENT_SIMULATE
143
+ )
144
+ agents = object_registry.get_agents()
145
+ agent = next((a for a in agents if a.name == agent_name), None)
146
+ if not agent:
147
+ logger.warning("agent not found for simulation", agent_name=agent_name)
148
+ raise HTTPException(status_code=404, detail="Agent not found")
149
+
150
+ emitter = AgentEventEmitter()
151
+
152
+ # Create a copy of the request data to avoid sharing data between tasks
153
+ request_copy = request.model_copy()
154
+
155
+ # Create the background task with its own session context
156
+ async def run_agent_with_session():
157
+ logger.debug(
158
+ "background task started for agent simulation", agent_name=agent_name
159
+ )
160
+ try:
161
+ async with session_context(get_engine()):
162
+ data_model = (
163
+ AgentSimulationData[agent.input_type]
164
+ if agent.input_type
165
+ else AgentSimulationData
166
+ )
167
+ parsed_data = data_model.model_validate(request_copy.model_dump())
168
+ await agent(parsed_data.input_value, event_emitter=emitter)
169
+ logger.debug(
170
+ "background task finished for agent simulation",
171
+ agent_name=agent_name,
172
+ )
173
+ except Exception as e:
174
+ logger.error(
175
+ "background task failed for agent simulation",
176
+ agent_name=agent_name,
177
+ error=e,
178
+ )
179
+ emitter.emit(AgentEventType.ERROR, AgentErrorData(detail=str(e)))
180
+
181
+ # Cancel the agent task when the response is closed
182
+ agent_task = asyncio.create_task(run_agent_with_session())
183
+
184
+ async def cancel_agent_task():
185
+ if not agent_task.done():
186
+ agent_task.cancel()
187
+ logger.debug("agent task cancelled", agent_name=agent_name)
188
+
189
+ background_tasks.add_task(cancel_agent_task)
190
+
191
+ return StreamingResponse(
192
+ emitter.get_events(),
193
+ media_type="text/event-stream",
194
+ background=background_tasks,
195
+ )
196
+
197
+ return router
@@ -0,0 +1,143 @@
1
+ from uuid import UUID
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from sqlalchemy import func
5
+ from sqlmodel import select
6
+
7
+ from planar.logging import get_logger
8
+ from planar.modeling.orm.query_filter_builder import build_paginated_query
9
+ from planar.object_registry import ObjectRegistry
10
+ from planar.routers.models import EntityInstance, EntityInstanceList, EntityMetadata
11
+ from planar.session import get_session
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ def create_entities_router(object_registry: ObjectRegistry) -> APIRouter:
17
+ router = APIRouter(tags=["Entities"])
18
+
19
+ @router.get("/", response_model=list[EntityMetadata])
20
+ async def get_entities():
21
+ entities = object_registry.get_entities()
22
+ session = get_session()
23
+
24
+ result = []
25
+ for entity in entities:
26
+ instance_count = 0
27
+
28
+ # Get count of instances for this entity
29
+ count_query = select(func.count()).select_from(entity)
30
+ instance_count = await session.scalar(count_query) or 0
31
+
32
+ result.append(
33
+ EntityMetadata(
34
+ name=entity.__name__,
35
+ description=entity.__doc__,
36
+ json_schema=entity.model_json_schema(),
37
+ instance_count=instance_count,
38
+ )
39
+ )
40
+ return result
41
+
42
+ @router.get("/{entity_name}/instances", response_model=EntityInstanceList)
43
+ async def get_entity_instances(
44
+ entity_name: str,
45
+ skip: int = 0,
46
+ limit: int = 100,
47
+ ):
48
+ """
49
+ Get instances of a domain model entity from the database.
50
+ Only works for entities that have a database table.
51
+ """
52
+ entities = object_registry.get_entities()
53
+
54
+ # Find the entity class by name
55
+ entity_class = next(
56
+ (entity for entity in entities if entity.__name__ == entity_name), None
57
+ )
58
+
59
+ if not entity_class:
60
+ logger.warning("entity not found", entity_name=entity_name)
61
+ raise HTTPException(
62
+ status_code=404, detail=f"Entity {entity_name} not found"
63
+ )
64
+
65
+ # Check if entity is a DB table (not an enum)
66
+ if not hasattr(entity_class, "__tablename__"):
67
+ logger.warning("entity is not a database table", entity_name=entity_name)
68
+ raise HTTPException(
69
+ status_code=400,
70
+ detail=f"Entity {entity_name} is not stored in database",
71
+ )
72
+
73
+ # Fetch instances from DB
74
+ session = get_session()
75
+ base_query = select(entity_class)
76
+
77
+ # Build paginated query and count query
78
+ paginated_query, count_query = build_paginated_query(
79
+ base_query, offset=skip, limit=limit
80
+ )
81
+
82
+ # Count total matching records
83
+ total_count = await session.scalar(count_query) or 0
84
+
85
+ # Execute query
86
+ results = (await session.exec(paginated_query)).all()
87
+
88
+ # Convert to EntityInstance objects
89
+ instances = [
90
+ EntityInstance(
91
+ id=str(result.id), entity_name=entity_name, data=result.model_dump()
92
+ )
93
+ for result in results
94
+ ]
95
+
96
+ return EntityInstanceList(
97
+ items=instances, total=total_count, offset=skip, limit=limit
98
+ )
99
+
100
+ @router.get("/{entity_name}/instances/{instance_id}", response_model=EntityInstance)
101
+ async def get_entity_instance_by_id(entity_name: str, instance_id: UUID):
102
+ """
103
+ Get a specific entity instance by its ID.
104
+ Only works for entities that have a database table.
105
+ """
106
+ entities = object_registry.get_entities()
107
+
108
+ # Find the entity class by name
109
+ entity_class = next(
110
+ (entity for entity in entities if entity.__name__ == entity_name), None
111
+ )
112
+
113
+ if not entity_class:
114
+ logger.warning(
115
+ "entity not found when trying to get instance by id",
116
+ entity_name=entity_name,
117
+ )
118
+ raise HTTPException(
119
+ status_code=404, detail=f"Entity {entity_name} not found"
120
+ )
121
+
122
+ # Fetch the specific instance from DB
123
+ session = get_session()
124
+ result = await session.get(entity_class, instance_id)
125
+
126
+ if not result:
127
+ logger.warning(
128
+ "instance with id not found for entity",
129
+ instance_id=instance_id,
130
+ entity_name=entity_name,
131
+ )
132
+ raise HTTPException(
133
+ status_code=404,
134
+ detail=f"Instance with ID {instance_id} not found for entity {entity_name}",
135
+ )
136
+
137
+ # Convert to EntityInstance object
138
+ instance_data = result.model_dump()
139
+ return EntityInstance(
140
+ id=str(result.id), entity_name=entity_name, data=instance_data
141
+ )
142
+
143
+ return router
@@ -0,0 +1,91 @@
1
+ """
2
+ Event API router for Planar workflows.
3
+
4
+ This module provides API routes for emitting events that workflows might be waiting for.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional
8
+ from uuid import UUID
9
+
10
+ from fastapi import APIRouter, Body, HTTPException
11
+ from pydantic import BaseModel
12
+ from sqlmodel import col, select
13
+
14
+ from planar.logging import get_logger
15
+ from planar.session import get_session
16
+ from planar.workflows.events import emit_event as emit_workflow_event
17
+ from planar.workflows.models import WorkflowEvent
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class EventEmitRequest(BaseModel):
23
+ event_key: str
24
+ payload: Optional[Dict[str, Any]] = None
25
+ workflow_id: Optional[UUID] = None
26
+
27
+
28
+ class EventResponse(BaseModel):
29
+ id: UUID
30
+ event_key: str
31
+ timestamp: str
32
+ payload: Optional[Dict[str, Any]] = None
33
+ workflow_id: Optional[UUID] = None
34
+
35
+
36
+ def create_workflow_event_routes(router: APIRouter):
37
+ @router.post("/events/emit", response_model=EventResponse)
38
+ async def emit_event(request: EventEmitRequest = Body(...)):
39
+ """
40
+ Emit an event that workflows might be waiting for.
41
+
42
+ This endpoint allows external systems or APIs to emit events that will
43
+ wake up workflows waiting for those events.
44
+ """
45
+ try:
46
+ event, woken_workflows_count = await emit_workflow_event(
47
+ event_key=request.event_key,
48
+ payload=request.payload,
49
+ workflow_id=request.workflow_id,
50
+ )
51
+ logger.info(
52
+ "event emitted",
53
+ event_key=request.event_key,
54
+ event_id=event.id,
55
+ woken_workflows_count=woken_workflows_count,
56
+ )
57
+ return EventResponse(
58
+ id=event.id,
59
+ event_key=event.event_key,
60
+ timestamp=event.timestamp.isoformat(),
61
+ payload=event.payload,
62
+ workflow_id=event.workflow_id,
63
+ )
64
+ except Exception as e:
65
+ logger.exception("error emitting event", event_key=request.event_key)
66
+ raise HTTPException(status_code=500, detail=str(e))
67
+
68
+ @router.get("/events/list", response_model=List[EventResponse])
69
+ async def list_events(limit: int = 50, event_key: Optional[str] = None):
70
+ """
71
+ List recent events, optionally filtered by event key.
72
+ """
73
+ session = get_session()
74
+
75
+ query = select(WorkflowEvent).order_by(col(WorkflowEvent.timestamp).desc())
76
+
77
+ if event_key:
78
+ query = query.where(WorkflowEvent.event_key == event_key)
79
+
80
+ events = (await session.exec(query.limit(limit))).all()
81
+
82
+ return [
83
+ EventResponse(
84
+ id=event.id,
85
+ event_key=event.event_key,
86
+ timestamp=event.timestamp.isoformat(),
87
+ payload=event.payload,
88
+ workflow_id=event.workflow_id,
89
+ )
90
+ for event in events
91
+ ]
@@ -0,0 +1,142 @@
1
+ import uuid
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter, File, HTTPException, UploadFile
5
+ from fastapi.responses import RedirectResponse, StreamingResponse
6
+
7
+ from planar.files.models import PlanarFile, PlanarFileMetadata
8
+ from planar.files.storage.context import get_storage
9
+ from planar.logging import get_logger
10
+ from planar.session import get_session
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ router = APIRouter(tags=["Files"])
16
+
17
+
18
+ @router.post("/upload", response_model=list[PlanarFile])
19
+ async def upload_files(files: list[UploadFile] = File(...)):
20
+ """
21
+ Uploads one or more files to the configured storage backend and records their metadata.
22
+ Returns a list of file IDs for the successfully uploaded files.
23
+ """
24
+ storage = get_storage()
25
+ session = get_session()
26
+ uploaded_files: list[PlanarFile] = []
27
+
28
+ for file in files:
29
+ # Define an async generator to stream the file content efficiently
30
+ async def file_stream_generator(current_file: UploadFile):
31
+ # Read in chunks to avoid loading the whole file into memory
32
+ chunk_size = 65536 # 64KB
33
+ while chunk := await current_file.read(chunk_size):
34
+ yield chunk
35
+ # Reset file pointer if needed for potential retries or other operations,
36
+ # though not strictly necessary for this single pass upload.
37
+ await current_file.seek(0)
38
+
39
+ try:
40
+ # Store the file content using the storage backend
41
+ storage_ref = await storage.put(
42
+ stream=file_stream_generator(file), mime_type=file.content_type
43
+ )
44
+
45
+ # Create the metadata record in the database
46
+ planar_file = PlanarFileMetadata(
47
+ filename=file.filename
48
+ or str(uuid.uuid4()), # Use filename or default to random UUID
49
+ content_type=file.content_type or "application/octet-stream",
50
+ size=file.size
51
+ if file.size is not None
52
+ else -1, # Store size if available
53
+ storage_ref=storage_ref,
54
+ )
55
+ session.add(planar_file)
56
+ await session.commit()
57
+ await session.refresh(planar_file) # Ensure file_id is populated
58
+
59
+ logger.info(
60
+ "uploaded file",
61
+ filename=planar_file.filename,
62
+ file_id=planar_file.id,
63
+ storage_ref=storage_ref,
64
+ )
65
+ uploaded_files.append(planar_file)
66
+
67
+ except Exception:
68
+ # Log the error for the specific file but continue with others
69
+ logger.exception("failed to upload file", filename=file.filename)
70
+ # Optionally, rollback the session changes for this specific file if needed,
71
+ # though commit happens per file here. If atomicity across all files is desired,
72
+ # collect all PlanarFile objects and commit once outside the loop.
73
+ await (
74
+ session.rollback()
75
+ ) # Rollback potential partial changes for the failed file
76
+
77
+ if not uploaded_files and files:
78
+ # If no files were successfully uploaded but some were provided, raise an error
79
+ raise HTTPException(status_code=500, detail="All file uploads failed")
80
+
81
+ return uploaded_files
82
+
83
+
84
+ @router.get("/{file_id}/content")
85
+ async def get_file_content(file_id: UUID):
86
+ """
87
+ Retrieves the content of a file.
88
+
89
+ If the storage backend provides an external URL, it redirects the client.
90
+ Otherwise, it streams the file content directly.
91
+ """
92
+ storage = get_storage()
93
+ session = get_session()
94
+
95
+ # Retrieve file metadata
96
+ planar_file = await session.get(PlanarFileMetadata, file_id)
97
+ if not planar_file:
98
+ raise HTTPException(status_code=404, detail="File not found")
99
+
100
+ storage_ref = planar_file.storage_ref
101
+
102
+ try:
103
+ # Check for an external URL first
104
+ external_url = await storage.external_url(storage_ref)
105
+ if external_url:
106
+ return RedirectResponse(url=external_url)
107
+
108
+ # If no external URL, get the stream from storage
109
+ stream, mime_type = await storage.get(storage_ref)
110
+
111
+ # Ensure mime_type defaults correctly if storage didn't return one
112
+ media_type = mime_type or planar_file.content_type or "application/octet-stream"
113
+
114
+ return StreamingResponse(stream, media_type=media_type)
115
+
116
+ except FileNotFoundError:
117
+ logger.warning(
118
+ "file content not found in storage",
119
+ file_id=file_id,
120
+ storage_ref=storage_ref,
121
+ )
122
+ raise HTTPException(status_code=404, detail="File content not found in storage")
123
+ except Exception:
124
+ logger.exception("failed to retrieve file content", file_id=file_id)
125
+ raise HTTPException(status_code=500, detail="Failed to retrieve file content")
126
+
127
+
128
+ @router.get("/{file_id}/metadata", response_model=PlanarFile)
129
+ async def get_file_metadata(file_id: UUID):
130
+ session = get_session()
131
+
132
+ # Retrieve file metadata
133
+ planar_file = await session.get(PlanarFileMetadata, file_id)
134
+ if not planar_file:
135
+ logger.warning("file metadata not found", file_id=file_id)
136
+ raise HTTPException(status_code=404, detail="File not found")
137
+ return planar_file
138
+
139
+
140
+ def create_files_router() -> APIRouter:
141
+ """Factory function to create and return the files router."""
142
+ return router