clue-fastapi-sdk 0.0.1__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.
@@ -0,0 +1,28 @@
1
+ from clue_python_sdk_core import (
2
+ ClueCommand,
3
+ ClueIdentify,
4
+ ClueLogout,
5
+ ClueSetAccount,
6
+ ClueStateTransition,
7
+ ClueTrack,
8
+ ClueToolCall,
9
+ )
10
+
11
+ from .fastapi import (
12
+ ClueFastApiMiddleware,
13
+ clue_init_fastapi,
14
+ instrument_celery,
15
+ )
16
+
17
+ __all__ = [
18
+ "ClueFastApiMiddleware",
19
+ "ClueCommand",
20
+ "ClueIdentify",
21
+ "ClueLogout",
22
+ "ClueSetAccount",
23
+ "ClueStateTransition",
24
+ "ClueTrack",
25
+ "ClueToolCall",
26
+ "clue_init_fastapi",
27
+ "instrument_celery",
28
+ ]
@@ -0,0 +1,398 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import os
5
+ import time
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from types import SimpleNamespace
8
+ from urllib.parse import parse_qs
9
+
10
+ from clue_python_sdk_core import (
11
+ CluePythonBootstrapConfig,
12
+ CluePythonSettings,
13
+ JsonValue,
14
+ build_backend_error_event,
15
+ build_request_failed_event,
16
+ build_request_finished_event,
17
+ build_request_started_event,
18
+ configure_opentelemetry_environment,
19
+ create_client,
20
+ create_request_context,
21
+ default_traces_endpoint_from_ingest_endpoint,
22
+ enqueue_client_events,
23
+ initialize_opentelemetry,
24
+ instrument_celery_signals,
25
+ instrument_sqlalchemy_orm,
26
+ instrument_requests,
27
+ load_settings,
28
+ reset_current_state,
29
+ set_current_state,
30
+ to_path_template,
31
+ )
32
+ from clue_python_sdk_core.runtime import (
33
+ annotate_current_otel_span,
34
+ build_settings,
35
+ configure_settings,
36
+ emit_sdk_initialized,
37
+ )
38
+
39
+ ASGIApp = Callable[[dict[str, object], Callable[[], Awaitable[dict[str, object]]], Callable[[dict[str, object]], Awaitable[None]]], Awaitable[None]]
40
+
41
+
42
+ class ClueFastApiMiddleware:
43
+ def __init__(
44
+ self,
45
+ app: ASGIApp,
46
+ *,
47
+ settings_loader: Callable[[], CluePythonSettings] = load_settings,
48
+ timer: Callable[[], float] = time.perf_counter,
49
+ ) -> None:
50
+ self._app = app
51
+ self._settings_loader = settings_loader
52
+ self._timer = timer
53
+
54
+ def __call__(
55
+ self,
56
+ scope: dict[str, object],
57
+ receive: Callable[[], Awaitable[dict[str, object]]],
58
+ send: Callable[[dict[str, object]], Awaitable[None]],
59
+ ) -> Awaitable[None]:
60
+ return self._dispatch(scope, receive, send)
61
+
62
+ async def _dispatch(
63
+ self,
64
+ scope: dict[str, object],
65
+ receive: Callable[[], Awaitable[dict[str, object]]],
66
+ send: Callable[[dict[str, object]], Awaitable[None]],
67
+ ) -> None:
68
+ if scope.get("type") != "http":
69
+ await self._app(scope, receive, send)
70
+ return
71
+
72
+ settings = self._settings_loader()
73
+ if not settings.enabled:
74
+ await self._app(scope, receive, send)
75
+ return
76
+
77
+ client = create_client(settings)
78
+ if client is None:
79
+ await self._app(scope, receive, send)
80
+ return
81
+
82
+ request_messages, request_body = await _buffer_request_messages(receive)
83
+ request = _FastApiRequest(scope, request_body)
84
+ context = create_request_context(settings=settings, request=request)
85
+ annotate_current_otel_span(
86
+ {
87
+ "clue.request_id": context.get("request_id"),
88
+ "clue.request_span_id": context.get("request_span_id"),
89
+ "clue.interaction_id": context.get("interaction_id"),
90
+ "clue.anonymous_id": context.get("anonymous_id"),
91
+ "clue.user_id": context.get("user_id"),
92
+ "clue.account_id": context.get("account_id"),
93
+ "clue.session_id": context.get("session_id"),
94
+ "clue.tab_id": context.get("tab_id"),
95
+ },
96
+ expected_kind="SERVER",
97
+ )
98
+ handler_name = _resolve_handler_name(scope)
99
+ client.add_event(
100
+ build_request_started_event(
101
+ request=request,
102
+ context=context,
103
+ handler_name=handler_name,
104
+ allowed_value_paths=settings.allowed_value_paths,
105
+ denied_keys=settings.denied_keys,
106
+ )
107
+ )
108
+
109
+ started_at = self._timer()
110
+ response_capture = _ResponseCapture()
111
+ client_token, context_token = set_current_state(client=client, context=context)
112
+ replay_receive = _build_replay_receive(request_messages)
113
+
114
+ try:
115
+ await self._app(scope, replay_receive, response_capture.capture(send))
116
+ except Exception as exc:
117
+ client.add_event(
118
+ build_backend_error_event(
119
+ context=context,
120
+ handler_name=handler_name,
121
+ failure_type="unhandled_exception",
122
+ error_class=exc.__class__.__name__,
123
+ message=str(exc),
124
+ exception=exc,
125
+ )
126
+ )
127
+ client.add_event(
128
+ build_request_failed_event(
129
+ request=request,
130
+ context=context,
131
+ handler_name=handler_name,
132
+ response=_ErrorResponse(),
133
+ status_code=500,
134
+ duration_ms=int((self._timer() - started_at) * 1000),
135
+ allowed_value_paths=settings.allowed_value_paths,
136
+ denied_keys=settings.denied_keys,
137
+ )
138
+ )
139
+ enqueue_client_events(settings, client)
140
+ raise
141
+ finally:
142
+ reset_current_state(client_token, context_token)
143
+
144
+ client.add_event(
145
+ build_request_finished_event(
146
+ request=request,
147
+ response=response_capture.to_response(),
148
+ context=context,
149
+ handler_name=handler_name,
150
+ duration_ms=int((self._timer() - started_at) * 1000),
151
+ allowed_value_paths=settings.allowed_value_paths,
152
+ denied_keys=settings.denied_keys,
153
+ )
154
+ )
155
+ enqueue_client_events(settings, client)
156
+
157
+
158
+ def clue_init_fastapi(
159
+ app: object,
160
+ *,
161
+ project_key: str,
162
+ environment: str,
163
+ api_key: str | None = None,
164
+ service_name: str = "python-service",
165
+ producer_id: str | None = None,
166
+ service_key: str | None = None,
167
+ instrument_outbound_requests: bool = True,
168
+ instrument_celery_if_available: bool = True,
169
+ instrument_orm_if_available: bool = False,
170
+ state_fields: Sequence[str] = (),
171
+ ) -> bool:
172
+ add_middleware = getattr(app, "add_middleware", None)
173
+ if not callable(add_middleware):
174
+ return False
175
+
176
+ settings = build_settings(
177
+ project_key=project_key,
178
+ environment=environment,
179
+ api_key=api_key,
180
+ service_name=service_name,
181
+ service_key=service_key,
182
+ producer_id=producer_id,
183
+ capture_outbound_requests=instrument_outbound_requests,
184
+ capture_celery=instrument_celery_if_available,
185
+ capture_orm=instrument_orm_if_available,
186
+ state_fields=state_fields,
187
+ )
188
+ configure_settings(settings)
189
+ emit_sdk_initialized(settings)
190
+ bootstrap_config = CluePythonBootstrapConfig(
191
+ service_name=service_name,
192
+ producer_id=settings.producer_id,
193
+ project_key=project_key,
194
+ environment=environment,
195
+ deployment_environment=environment,
196
+ service_type="api",
197
+ traces_endpoint=(
198
+ os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
199
+ or os.environ.get("CLUE_OTLP_TRACES_ENDPOINT")
200
+ or default_traces_endpoint_from_ingest_endpoint(settings.endpoint or "")
201
+ ),
202
+ api_key=api_key,
203
+ service_version=(
204
+ os.environ.get("OTEL_SERVICE_VERSION")
205
+ or os.environ.get("CLUE_SERVICE_VERSION")
206
+ ),
207
+ service_namespace=(
208
+ os.environ.get("OTEL_SERVICE_NAMESPACE")
209
+ or os.environ.get("CLUE_SERVICE_NAMESPACE")
210
+ ),
211
+ )
212
+ configure_opentelemetry_environment(bootstrap_config)
213
+ initialize_opentelemetry(bootstrap_config)
214
+ add_middleware(ClueFastApiMiddleware)
215
+ updated = True
216
+
217
+ if instrument_outbound_requests and instrument_requests():
218
+ updated = True
219
+
220
+ if instrument_celery_if_available and instrument_celery():
221
+ updated = True
222
+
223
+ if instrument_orm_if_available and instrument_sqlalchemy_orm():
224
+ updated = True
225
+
226
+ return updated
227
+
228
+
229
+ def instrument_celery() -> bool:
230
+ try:
231
+ from celery import signals
232
+ except ImportError:
233
+ try:
234
+ signals = importlib.import_module("celery.signals")
235
+ except ImportError:
236
+ return False
237
+
238
+ return instrument_celery_signals(
239
+ task_prerun=signals.task_prerun,
240
+ task_postrun=signals.task_postrun,
241
+ task_failure=signals.task_failure,
242
+ before_task_publish=getattr(signals, "before_task_publish", None),
243
+ after_task_publish=getattr(signals, "after_task_publish", None),
244
+ task_retry=getattr(signals, "task_retry", None),
245
+ task_revoked=getattr(signals, "task_revoked", None),
246
+ )
247
+
248
+
249
+ class _FastApiRequest:
250
+ def __init__(self, scope: dict[str, object], body: bytes) -> None:
251
+ self.method = str(scope.get("method", "GET"))
252
+ self.path = str(scope.get("path", "/"))
253
+ self.body = body
254
+ self.scheme = str(scope.get("scheme", "http"))
255
+ self.headers = _decode_headers(scope.get("headers"))
256
+ self.GET = _decode_query(scope.get("query_string"))
257
+ self.COOKIES = _parse_cookies(self.headers.get("cookie"))
258
+ self.user = scope.get(
259
+ "user",
260
+ SimpleNamespace(is_authenticated=False),
261
+ )
262
+ self.resolver_match = SimpleNamespace(route=_resolve_path_template(scope, self.path))
263
+
264
+ def get_full_path(self) -> str:
265
+ if not self.GET:
266
+ return self.path
267
+ query_pairs: list[str] = []
268
+ for key, value in self.GET.items():
269
+ if isinstance(value, list):
270
+ for entry in value:
271
+ query_pairs.append(f"{key}={entry}")
272
+ else:
273
+ query_pairs.append(f"{key}={value}")
274
+ return f"{self.path}?{'&'.join(query_pairs)}"
275
+
276
+
277
+ class _ResponseCapture:
278
+ def __init__(self) -> None:
279
+ self.status_code = 200
280
+ self.headers: dict[str, str] = {}
281
+ self.body_parts: list[bytes] = []
282
+
283
+ def capture(
284
+ self,
285
+ send: Callable[[dict[str, object]], Awaitable[None]],
286
+ ) -> Callable[[dict[str, object]], Awaitable[None]]:
287
+ async def _send(message: dict[str, object]) -> None:
288
+ if message.get("type") == "http.response.start":
289
+ self.status_code = int(message.get("status", 200))
290
+ self.headers = _decode_headers(message.get("headers"))
291
+ elif message.get("type") == "http.response.body":
292
+ body = message.get("body", b"")
293
+ if isinstance(body, bytes):
294
+ self.body_parts.append(body)
295
+ await send(message)
296
+
297
+ return _send
298
+
299
+ def to_response(self) -> object:
300
+ return SimpleNamespace(
301
+ status_code=self.status_code,
302
+ body=b"".join(self.body_parts),
303
+ headers=self.headers,
304
+ )
305
+
306
+
307
+ class _ErrorResponse:
308
+ status_code = 500
309
+ body = b""
310
+ headers: dict[str, JsonValue] = {}
311
+
312
+
313
+ async def _buffer_request_messages(
314
+ receive: Callable[[], Awaitable[dict[str, object]]],
315
+ ) -> tuple[list[dict[str, object]], bytes]:
316
+ messages: list[dict[str, object]] = []
317
+ body_parts: list[bytes] = []
318
+ while True:
319
+ message = await receive()
320
+ messages.append(message)
321
+ if message.get("type") != "http.request":
322
+ break
323
+ body = message.get("body", b"")
324
+ if isinstance(body, bytes):
325
+ body_parts.append(body)
326
+ if not message.get("more_body", False):
327
+ break
328
+ return messages, b"".join(body_parts)
329
+
330
+
331
+ def _build_replay_receive(
332
+ messages: list[dict[str, object]],
333
+ ) -> Callable[[], Awaitable[dict[str, object]]]:
334
+ queue = list(messages)
335
+
336
+ async def _receive() -> dict[str, object]:
337
+ if queue:
338
+ return queue.pop(0)
339
+ return {"type": "http.request", "body": b"", "more_body": False}
340
+
341
+ return _receive
342
+
343
+
344
+ def _decode_headers(raw_headers: object) -> dict[str, str]:
345
+ if isinstance(raw_headers, dict):
346
+ return {str(key).lower(): str(value) for key, value in raw_headers.items()}
347
+ if not isinstance(raw_headers, list):
348
+ return {}
349
+ result: dict[str, str] = {}
350
+ for entry in raw_headers:
351
+ if not isinstance(entry, tuple) or len(entry) != 2:
352
+ continue
353
+ key, value = entry
354
+ key_text = key.decode("latin-1") if isinstance(key, bytes) else str(key)
355
+ value_text = value.decode("latin-1") if isinstance(value, bytes) else str(value)
356
+ result[key_text.lower()] = value_text
357
+ return result
358
+
359
+
360
+ def _decode_query(raw_query: object) -> dict[str, JsonValue]:
361
+ if isinstance(raw_query, bytes):
362
+ parsed = parse_qs(raw_query.decode("utf-8"), keep_blank_values=True)
363
+ return {
364
+ key: values[0] if len(values) == 1 else values
365
+ for key, values in parsed.items()
366
+ }
367
+ return {}
368
+
369
+
370
+ def _parse_cookies(raw_cookie: str | None) -> dict[str, str]:
371
+ if not raw_cookie:
372
+ return {}
373
+ result: dict[str, str] = {}
374
+ for entry in raw_cookie.split(";"):
375
+ key, _, value = entry.strip().partition("=")
376
+ if key:
377
+ result[key] = value
378
+ return result
379
+
380
+
381
+ def _resolve_handler_name(scope: dict[str, object]) -> str | None:
382
+ route = scope.get("route")
383
+ route_name = getattr(route, "name", None)
384
+ if isinstance(route_name, str) and route_name.strip():
385
+ return route_name
386
+ endpoint = scope.get("endpoint")
387
+ name = getattr(endpoint, "__name__", None)
388
+ if isinstance(name, str) and name.strip():
389
+ return name
390
+ return None
391
+
392
+
393
+ def _resolve_path_template(scope: dict[str, object], path: str) -> str:
394
+ route = scope.get("route")
395
+ path_format = getattr(route, "path_format", None)
396
+ if isinstance(path_format, str) and path_format.strip():
397
+ return path_format
398
+ return to_path_template(path)
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: clue-fastapi-sdk
3
+ Version: 0.0.1
4
+ Summary: Clue FastAPI SDK with built-in backend runtime
5
+ Home-page: UNKNOWN
6
+ License: UNKNOWN
7
+ Platform: UNKNOWN
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: clue-python-sdk-core (==0.0.1)
10
+
11
+ UNKNOWN
12
+
@@ -0,0 +1,6 @@
1
+ clue_fastapi_sdk/__init__.py,sha256=GnVawxmRWyQVeUn9J6xvi34qarHcxyz1fiYKct3z6gs,442
2
+ clue_fastapi_sdk/fastapi.py,sha256=GePPWM2lge_BS1wi88z3lTBnxR2APfH9udr3Dsfq0JY,11432
3
+ clue_fastapi_sdk-0.0.1.dist-info/METADATA,sha256=X9gG_E8JwcGAmsIP-Eom4JuXlYXykI7YbkELIpIdbW0,249
4
+ clue_fastapi_sdk-0.0.1.dist-info/WHEEL,sha256=ewwEueio1C2XeHTvT17n8dZUJgOvyCWCt0WVNLClP9o,92
5
+ clue_fastapi_sdk-0.0.1.dist-info/top_level.txt,sha256=ll7k_BH8VqU3IR0Xt00fWy5WYVezb9qSgIv--pWvMf8,17
6
+ clue_fastapi_sdk-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.37.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ clue_fastapi_sdk