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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- 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
|
planar/routers/event.py
ADDED
@@ -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
|
+
]
|
planar/routers/files.py
ADDED
@@ -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
|