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
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
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)
|