clue-python-sdk-core 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,146 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Mapping, Sequence
6
+ from urllib.parse import parse_qs, urlsplit
7
+
8
+ from .privacy import JsonValue
9
+
10
+ NUMBER_SEGMENT = re.compile(r"^\d+$")
11
+ UUID_SEGMENT = re.compile(
12
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
13
+ )
14
+ HEX_SEGMENT = re.compile(r"^[0-9a-fA-F]{16,}$")
15
+
16
+
17
+ def _to_json_value(value: object) -> JsonValue:
18
+ if value is None:
19
+ return None
20
+ if isinstance(value, bool):
21
+ return value
22
+ if isinstance(value, (int, float)):
23
+ return value
24
+ if isinstance(value, str):
25
+ return value
26
+ if isinstance(value, bytes):
27
+ text = value.decode("utf-8", errors="replace").strip()
28
+ if text.startswith("{") or text.startswith("["):
29
+ try:
30
+ return json.loads(text)
31
+ except json.JSONDecodeError:
32
+ return text
33
+ return text
34
+ if isinstance(value, Mapping):
35
+ return {str(key): _to_json_value(child) for key, child in value.items()}
36
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
37
+ return [_to_json_value(child) for child in value]
38
+ return str(value)
39
+
40
+
41
+ def _normalize_headers(headers: object) -> dict[str, JsonValue]:
42
+ if isinstance(headers, Mapping):
43
+ return {str(key).lower(): _to_json_value(value) for key, value in headers.items()}
44
+ if hasattr(headers, "items"):
45
+ items = getattr(headers, "items")
46
+ if callable(items):
47
+ return {
48
+ str(key).lower(): _to_json_value(value)
49
+ for key, value in items()
50
+ }
51
+ return {}
52
+
53
+
54
+ def _extract_request_headers(request: object) -> dict[str, JsonValue]:
55
+ headers = getattr(request, "headers", None)
56
+ normalized = _normalize_headers(headers)
57
+ if normalized:
58
+ return normalized
59
+
60
+ meta = getattr(request, "META", None)
61
+ if isinstance(meta, Mapping):
62
+ out: dict[str, JsonValue] = {}
63
+ for key, value in meta.items():
64
+ key_text = str(key)
65
+ if key_text.startswith("HTTP_"):
66
+ header_name = key_text[5:].replace("_", "-").lower()
67
+ out[header_name] = _to_json_value(value)
68
+ elif key_text in {"CONTENT_TYPE", "CONTENT_LENGTH"}:
69
+ out[key_text.replace("_", "-").lower()] = _to_json_value(value)
70
+ return out
71
+
72
+ return {}
73
+
74
+
75
+ def _extract_response_headers(response: object) -> dict[str, JsonValue]:
76
+ headers = getattr(response, "headers", None)
77
+ return _normalize_headers(headers)
78
+
79
+
80
+ def _extract_query(raw_query: object) -> dict[str, JsonValue]:
81
+ if raw_query is None:
82
+ return {}
83
+ if isinstance(raw_query, Mapping):
84
+ return {str(key): _to_json_value(value) for key, value in raw_query.items()}
85
+ if hasattr(raw_query, "lists"):
86
+ lists_method = getattr(raw_query, "lists")
87
+ if callable(lists_method):
88
+ out: dict[str, JsonValue] = {}
89
+ for key, values in lists_method():
90
+ out[str(key)] = [_to_json_value(value) for value in values]
91
+ return out
92
+ return {}
93
+
94
+
95
+ def _request_body(request: object) -> JsonValue | None:
96
+ for attr in ("body", "data", "json_body"):
97
+ if hasattr(request, attr):
98
+ return _to_json_value(getattr(request, attr))
99
+ return None
100
+
101
+
102
+ def _response_body(response: object) -> JsonValue | None:
103
+ for attr in ("content", "body", "data"):
104
+ if hasattr(response, attr):
105
+ return _to_json_value(getattr(response, attr))
106
+ return None
107
+
108
+
109
+ def _safe_urlsplit(raw_url: str) -> tuple[str, str, str, dict[str, JsonValue]]:
110
+ try:
111
+ parsed = urlsplit(raw_url)
112
+ path = parsed.path or "/"
113
+ host = "" if "%" in (parsed.netloc or "") else (parsed.netloc or "")
114
+ query_map = {
115
+ key: values[0] if len(values) == 1 else values
116
+ for key, values in parse_qs(parsed.query, keep_blank_values=True).items()
117
+ }
118
+ return parsed.scheme or "", host, path, {
119
+ key: _to_json_value(value) for key, value in query_map.items()
120
+ }
121
+ except ValueError:
122
+ return "", "", "/", {}
123
+
124
+
125
+ def to_path_template(path: str) -> str:
126
+ if not path:
127
+ return "/"
128
+
129
+ segments = [segment for segment in path.split("/") if segment]
130
+ if not segments:
131
+ return "/"
132
+
133
+ templated: list[str] = []
134
+ for segment in segments:
135
+ if UUID_SEGMENT.match(segment):
136
+ templated.append(":uuid")
137
+ continue
138
+ if NUMBER_SEGMENT.match(segment):
139
+ templated.append(":id")
140
+ continue
141
+ if HEX_SEGMENT.match(segment):
142
+ templated.append(":hex")
143
+ continue
144
+ templated.append(segment)
145
+
146
+ return "/" + "/".join(templated)
@@ -0,0 +1,534 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+
7
+ from .adapters import (
8
+ JsonValue,
9
+ build_repository_mutation_event,
10
+ build_state_transition_event,
11
+ build_summary_event,
12
+ )
13
+ from .contracts import (
14
+ BACKEND_STATUS_FINISHED,
15
+ BACKEND_STATUS_STARTED,
16
+ REPOSITORY_MUTATION_SUMMARY_EVENT,
17
+ SDK_COLLECTION_MODE_STANDARD,
18
+ )
19
+ from .otel_bridge import annotate_current_otel_span, resolve_current_otel_span_context
20
+ from .runtime import add_event, get_current_client, get_current_context, load_settings
21
+
22
+ _sqlalchemy_instrumented = False
23
+ _django_orm_instrumented = False
24
+
25
+
26
+ def _normalize_dot_case(value: object | None, fallback: str) -> str:
27
+ text = str(value or fallback).strip()
28
+ if not text:
29
+ text = fallback
30
+ text = re.sub(r"([a-z0-9])([A-Z])", r"\1.\2", text)
31
+ text = re.sub(r"[^a-zA-Z0-9]+", ".", text).strip(".").lower()
32
+ text = re.sub(r"\.{2,}", ".", text)
33
+ return text or fallback
34
+
35
+
36
+ def _string_sequence(values: Iterable[object] | None) -> tuple[str, ...]:
37
+ if values is None:
38
+ return ()
39
+ return tuple(
40
+ dict.fromkeys(str(value).strip() for value in values if str(value).strip())
41
+ )
42
+
43
+
44
+ def _record_summary_metric(client: object, count: int = 1) -> None:
45
+ record = getattr(client, "record_events_summarized", None)
46
+ if callable(record):
47
+ record(count)
48
+
49
+
50
+ def _emit_repository_mutation_pair(
51
+ *,
52
+ mutation_kind: str,
53
+ repository_name: str,
54
+ changed_fields_schema: Sequence[str] = (),
55
+ entity_id: object | None = None,
56
+ timer: object = time.perf_counter,
57
+ ) -> bool:
58
+ client = get_current_client()
59
+ context = get_current_context()
60
+ if client is None or context is None:
61
+ return False
62
+
63
+ normalized_repository_name = _normalize_dot_case(repository_name, "repository")
64
+ mutation_key = _normalize_dot_case(
65
+ f"{normalized_repository_name}.{mutation_kind}",
66
+ f"repository.{mutation_kind}",
67
+ )
68
+ settings = load_settings()
69
+ if settings.sdk_collection_mode == SDK_COLLECTION_MODE_STANDARD:
70
+ summary = build_summary_event(
71
+ context=context,
72
+ event_name=REPOSITORY_MUTATION_SUMMARY_EVENT,
73
+ collector_name="orm_write",
74
+ aggregation_kind="request",
75
+ summary_window_ms=0,
76
+ summary_count=1,
77
+ budget_window_ms=60_000,
78
+ rate_limit_key=f"repository:{normalized_repository_name}:{mutation_kind}",
79
+ properties={
80
+ "mutation_key": mutation_key,
81
+ "mutation_kind": mutation_kind,
82
+ "repository_name": normalized_repository_name,
83
+ "changed_fields_schema": list(changed_fields_schema),
84
+ },
85
+ metrics={
86
+ "normal_success_count": 1,
87
+ },
88
+ denied_keys=settings.denied_keys,
89
+ )
90
+ added = add_event(summary)
91
+ if added:
92
+ _record_summary_metric(client)
93
+ return added
94
+
95
+ started_at = timer()
96
+ otel_span = resolve_current_otel_span_context("CLIENT")
97
+ span_id = otel_span["span_id"] if otel_span is not None and otel_span.get("span_id") else None
98
+ parent_span_id = (
99
+ otel_span.get("parent_span_id") or None if otel_span is not None else None
100
+ )
101
+ annotate_current_otel_span(
102
+ {
103
+ "clue.flow.kind": "repository_mutation",
104
+ "clue.mutation.key": mutation_key,
105
+ "clue.mutation.kind": mutation_kind,
106
+ "clue.repository.name": normalized_repository_name,
107
+ "clue.changed_fields_schema": list(changed_fields_schema),
108
+ },
109
+ expected_kind="CLIENT",
110
+ )
111
+ started = build_repository_mutation_event(
112
+ context=context,
113
+ status=BACKEND_STATUS_STARTED,
114
+ mutation_key=mutation_key,
115
+ mutation_kind=mutation_kind,
116
+ repository_name=normalized_repository_name,
117
+ changed_fields_schema=changed_fields_schema,
118
+ entity_id=entity_id,
119
+ span_id=span_id,
120
+ parent_span_id=parent_span_id,
121
+ )
122
+ finished = build_repository_mutation_event(
123
+ context=context,
124
+ status=BACKEND_STATUS_FINISHED,
125
+ mutation_key=mutation_key,
126
+ mutation_kind=mutation_kind,
127
+ repository_name=normalized_repository_name,
128
+ changed_fields_schema=changed_fields_schema,
129
+ entity_id=entity_id,
130
+ duration_ms=max(0, int((timer() - started_at) * 1000)),
131
+ span_id=span_id,
132
+ parent_span_id=parent_span_id,
133
+ )
134
+
135
+ return add_event(started) and add_event(finished)
136
+
137
+
138
+ def _configured_state_fields() -> tuple[str, ...]:
139
+ settings = load_settings()
140
+ return tuple(
141
+ dict.fromkeys(
142
+ _normalize_dot_case(field, "")
143
+ for field in settings.state_fields
144
+ if str(field).strip()
145
+ )
146
+ )
147
+
148
+
149
+ def _state_field_matches(field_name: str, configured_fields: Sequence[str]) -> bool:
150
+ normalized = _normalize_dot_case(field_name, "")
151
+ snake_name = str(field_name).strip().lower()
152
+ return normalized in configured_fields or snake_name in configured_fields
153
+
154
+
155
+ def _state_value(value: object | None) -> str | None:
156
+ if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
157
+ return None
158
+ text = str(value).strip()
159
+ return text if text else None
160
+
161
+
162
+ def _emit_state_transition_pair(
163
+ *,
164
+ repository_name: str,
165
+ field_name: str,
166
+ from_value: object | None,
167
+ to_value: object | None,
168
+ entity_id: object | None = None,
169
+ timer: object = time.perf_counter,
170
+ ) -> bool:
171
+ client = get_current_client()
172
+ context = get_current_context()
173
+ from_state = _state_value(from_value)
174
+ to_state = _state_value(to_value)
175
+ if client is None or context is None or from_state == to_state or to_state is None:
176
+ return False
177
+
178
+ normalized_repository_name = _normalize_dot_case(repository_name, "repository")
179
+ transition_axis = _normalize_dot_case(field_name, "state")
180
+ transition_key = _normalize_dot_case(
181
+ f"{normalized_repository_name}.{transition_axis}",
182
+ "repository.state",
183
+ )
184
+ started_at = timer()
185
+ otel_span = resolve_current_otel_span_context("INTERNAL")
186
+ span_id = otel_span["span_id"] if otel_span is not None and otel_span.get("span_id") else None
187
+ parent_span_id = (
188
+ otel_span.get("parent_span_id") or None if otel_span is not None else None
189
+ )
190
+ annotate_current_otel_span(
191
+ {
192
+ "clue.flow.kind": "state_transition",
193
+ "clue.transition.key": transition_key,
194
+ "clue.transition.from_state": from_state or "unknown",
195
+ "clue.transition.to_state": to_state,
196
+ "clue.transition.axis": transition_axis,
197
+ },
198
+ expected_kind="INTERNAL",
199
+ )
200
+ started = build_state_transition_event(
201
+ context=context,
202
+ status=BACKEND_STATUS_STARTED,
203
+ transition_key=transition_key,
204
+ from_state=from_state or "unknown",
205
+ to_state=to_state,
206
+ transition_axis=transition_axis,
207
+ entity_id=entity_id,
208
+ span_id=span_id,
209
+ parent_span_id=parent_span_id,
210
+ )
211
+ finished = build_state_transition_event(
212
+ context=context,
213
+ status=BACKEND_STATUS_FINISHED,
214
+ transition_key=transition_key,
215
+ from_state=from_state or "unknown",
216
+ to_state=to_state,
217
+ transition_axis=transition_axis,
218
+ entity_id=entity_id,
219
+ duration_ms=max(0, int((timer() - started_at) * 1000)),
220
+ span_id=span_id,
221
+ parent_span_id=parent_span_id,
222
+ )
223
+
224
+ return add_event(started) and add_event(finished)
225
+
226
+
227
+ def _sqlalchemy_repository_name(mapper: object, target: object) -> str:
228
+ local_table = getattr(mapper, "local_table", None)
229
+ table_name = getattr(local_table, "name", None)
230
+ if table_name:
231
+ return str(table_name)
232
+ return target.__class__.__name__
233
+
234
+
235
+ def _sqlalchemy_entity_id(mapper: object, target: object) -> object | None:
236
+ primary_keys = getattr(mapper, "primary_key", None)
237
+ if not primary_keys:
238
+ return None
239
+ values: list[object] = []
240
+ for column in primary_keys:
241
+ key = getattr(column, "key", None)
242
+ if key and hasattr(target, str(key)):
243
+ values.append(getattr(target, str(key)))
244
+ if len(values) == 1:
245
+ return values[0]
246
+ if values:
247
+ return tuple(values)
248
+ return None
249
+
250
+
251
+ def _sqlalchemy_changed_fields(target: object) -> tuple[str, ...]:
252
+ try:
253
+ from sqlalchemy import inspect
254
+ except ImportError:
255
+ return ()
256
+
257
+ try:
258
+ state = inspect(target)
259
+ except Exception:
260
+ return ()
261
+
262
+ fields: list[str] = []
263
+ for attr in getattr(state, "attrs", ()):
264
+ key = getattr(attr, "key", None)
265
+ history = getattr(attr, "history", None)
266
+ if key and history is not None and callable(getattr(history, "has_changes", None)):
267
+ if history.has_changes():
268
+ fields.append(str(key))
269
+ return tuple(dict.fromkeys(fields))
270
+
271
+
272
+ def _sqlalchemy_state_transitions(
273
+ target: object,
274
+ configured_fields: Sequence[str],
275
+ ) -> tuple[tuple[str, object | None, object | None], ...]:
276
+ if not configured_fields:
277
+ return ()
278
+ try:
279
+ from sqlalchemy import inspect
280
+ except ImportError:
281
+ return ()
282
+
283
+ try:
284
+ state = inspect(target)
285
+ except Exception:
286
+ return ()
287
+
288
+ transitions: list[tuple[str, object | None, object | None]] = []
289
+ for attr in getattr(state, "attrs", ()):
290
+ key = getattr(attr, "key", None)
291
+ history = getattr(attr, "history", None)
292
+ if not key or history is None or not _state_field_matches(str(key), configured_fields):
293
+ continue
294
+ if not callable(getattr(history, "has_changes", None)) or not history.has_changes():
295
+ continue
296
+ deleted = tuple(getattr(history, "deleted", ()) or ())
297
+ added = tuple(getattr(history, "added", ()) or ())
298
+ from_value = deleted[0] if deleted else None
299
+ to_value = added[0] if added else getattr(target, str(key), None)
300
+ if _state_value(to_value) is None:
301
+ continue
302
+ transitions.append((str(key), from_value, to_value))
303
+ return tuple(transitions)
304
+
305
+
306
+ def instrument_sqlalchemy_orm() -> bool:
307
+ global _sqlalchemy_instrumented
308
+ if _sqlalchemy_instrumented:
309
+ return False
310
+
311
+ try:
312
+ from sqlalchemy import event
313
+ from sqlalchemy.orm import Mapper, Session
314
+ except ImportError:
315
+ return False
316
+
317
+ def after_insert(mapper: object, _connection: object, target: object) -> None:
318
+ _emit_repository_mutation_pair(
319
+ mutation_kind="create",
320
+ repository_name=_sqlalchemy_repository_name(mapper, target),
321
+ entity_id=_sqlalchemy_entity_id(mapper, target),
322
+ )
323
+
324
+ def after_update(mapper: object, _connection: object, target: object) -> None:
325
+ repository_name = _sqlalchemy_repository_name(mapper, target)
326
+ entity_id = _sqlalchemy_entity_id(mapper, target)
327
+ _emit_repository_mutation_pair(
328
+ mutation_kind="update",
329
+ repository_name=repository_name,
330
+ changed_fields_schema=_sqlalchemy_changed_fields(target),
331
+ entity_id=entity_id,
332
+ )
333
+ for field_name, from_value, to_value in _sqlalchemy_state_transitions(
334
+ target,
335
+ _configured_state_fields(),
336
+ ):
337
+ _emit_state_transition_pair(
338
+ repository_name=repository_name,
339
+ field_name=field_name,
340
+ from_value=from_value,
341
+ to_value=to_value,
342
+ entity_id=entity_id,
343
+ )
344
+
345
+ def after_delete(mapper: object, _connection: object, target: object) -> None:
346
+ _emit_repository_mutation_pair(
347
+ mutation_kind="delete",
348
+ repository_name=_sqlalchemy_repository_name(mapper, target),
349
+ entity_id=_sqlalchemy_entity_id(mapper, target),
350
+ )
351
+
352
+ def after_bulk_update(update_context: object) -> None:
353
+ mapper = getattr(update_context, "mapper", None)
354
+ values = getattr(update_context, "values", None)
355
+ repository_name = _sqlalchemy_repository_name(mapper, mapper) if mapper else "repository"
356
+ changed_fields = tuple(str(key) for key in getattr(values, "keys", lambda: ())())
357
+ _emit_repository_mutation_pair(
358
+ mutation_kind="bulk_write",
359
+ repository_name=repository_name,
360
+ changed_fields_schema=changed_fields,
361
+ )
362
+
363
+ def after_bulk_delete(delete_context: object) -> None:
364
+ mapper = getattr(delete_context, "mapper", None)
365
+ repository_name = _sqlalchemy_repository_name(mapper, mapper) if mapper else "repository"
366
+ _emit_repository_mutation_pair(
367
+ mutation_kind="bulk_write",
368
+ repository_name=repository_name,
369
+ )
370
+
371
+ try:
372
+ event.listen(Mapper, "after_insert", after_insert)
373
+ event.listen(Mapper, "after_update", after_update)
374
+ event.listen(Mapper, "after_delete", after_delete)
375
+ event.listen(Session, "after_bulk_update", after_bulk_update)
376
+ event.listen(Session, "after_bulk_delete", after_bulk_delete)
377
+ except Exception:
378
+ return False
379
+
380
+ _sqlalchemy_instrumented = True
381
+ return True
382
+
383
+
384
+ def _django_repository_name(sender: object) -> str:
385
+ meta = getattr(sender, "_meta", None)
386
+ db_table = getattr(meta, "db_table", None)
387
+ model_name = getattr(meta, "model_name", None)
388
+ return str(db_table or model_name or getattr(sender, "__name__", "model"))
389
+
390
+
391
+ def _django_entity_id(instance: object) -> object | None:
392
+ if hasattr(instance, "pk"):
393
+ return getattr(instance, "pk")
394
+ return getattr(instance, "id", None)
395
+
396
+
397
+ def _is_framework_django_model(sender: object) -> bool:
398
+ module_name = str(getattr(sender, "__module__", ""))
399
+ return module_name.startswith("django.")
400
+
401
+
402
+ def _django_state_values(instance: object, fields: Sequence[str]) -> dict[str, object | None]:
403
+ return {field: getattr(instance, field, None) for field in fields}
404
+
405
+
406
+ def _django_state_fields_for_sender(sender: object) -> tuple[str, ...]:
407
+ configured_fields = _configured_state_fields()
408
+ if not configured_fields:
409
+ return ()
410
+ meta = getattr(sender, "_meta", None)
411
+ fields = []
412
+ for field in getattr(meta, "fields", ()) or ():
413
+ name = getattr(field, "name", None)
414
+ if name and _state_field_matches(str(name), configured_fields):
415
+ fields.append(str(name))
416
+ return tuple(dict.fromkeys(fields))
417
+
418
+
419
+ def _django_previous_state_values(
420
+ sender: object,
421
+ instance: object,
422
+ fields: Sequence[str],
423
+ ) -> Mapping[str, object | None]:
424
+ if not fields:
425
+ return {}
426
+ primary_key = _django_entity_id(instance)
427
+ if primary_key is None:
428
+ return {}
429
+ manager = getattr(sender, "_default_manager", None)
430
+ get = getattr(manager, "get", None)
431
+ if not callable(get):
432
+ return {}
433
+ try:
434
+ previous = get(pk=primary_key)
435
+ except Exception:
436
+ return {}
437
+ return _django_state_values(previous, fields)
438
+
439
+
440
+ def instrument_django_orm() -> bool:
441
+ global _django_orm_instrumented
442
+ if _django_orm_instrumented:
443
+ return False
444
+
445
+ try:
446
+ from django.db.models.signals import post_delete, post_save, pre_save
447
+ except ImportError:
448
+ return False
449
+
450
+ def pre_save_handler(
451
+ sender: object,
452
+ instance: object,
453
+ raw: bool = False,
454
+ **_kwargs: JsonValue,
455
+ ) -> None:
456
+ if raw or _is_framework_django_model(sender):
457
+ return
458
+ fields = _django_state_fields_for_sender(sender)
459
+ setattr(
460
+ instance,
461
+ "__clue_previous_state_values__",
462
+ dict(_django_previous_state_values(sender, instance, fields)),
463
+ )
464
+
465
+ def post_save_handler(
466
+ sender: object,
467
+ instance: object,
468
+ created: bool,
469
+ raw: bool = False,
470
+ update_fields: object | None = None,
471
+ **_kwargs: JsonValue,
472
+ ) -> None:
473
+ if raw or _is_framework_django_model(sender):
474
+ return
475
+ repository_name = _django_repository_name(sender)
476
+ entity_id = _django_entity_id(instance)
477
+ _emit_repository_mutation_pair(
478
+ mutation_kind="create" if created else "update",
479
+ repository_name=repository_name,
480
+ changed_fields_schema=() if created else _string_sequence(update_fields),
481
+ entity_id=entity_id,
482
+ )
483
+ if created:
484
+ return
485
+ configured_state_fields = _django_state_fields_for_sender(sender)
486
+ update_field_names = _string_sequence(update_fields)
487
+ previous_values = getattr(instance, "__clue_previous_state_values__", {})
488
+ if not isinstance(previous_values, Mapping):
489
+ previous_values = {}
490
+ for field_name in configured_state_fields:
491
+ if update_field_names and field_name not in update_field_names:
492
+ continue
493
+ _emit_state_transition_pair(
494
+ repository_name=repository_name,
495
+ field_name=field_name,
496
+ from_value=previous_values.get(field_name),
497
+ to_value=getattr(instance, field_name, None),
498
+ entity_id=entity_id,
499
+ )
500
+
501
+ def post_delete_handler(
502
+ sender: object,
503
+ instance: object,
504
+ **_kwargs: JsonValue,
505
+ ) -> None:
506
+ if _is_framework_django_model(sender):
507
+ return
508
+ _emit_repository_mutation_pair(
509
+ mutation_kind="delete",
510
+ repository_name=_django_repository_name(sender),
511
+ entity_id=_django_entity_id(instance),
512
+ )
513
+
514
+ try:
515
+ pre_save.connect(
516
+ pre_save_handler,
517
+ dispatch_uid="clue_python_sdk_core.django_orm.pre_save",
518
+ weak=False,
519
+ )
520
+ post_save.connect(
521
+ post_save_handler,
522
+ dispatch_uid="clue_python_sdk_core.django_orm.post_save",
523
+ weak=False,
524
+ )
525
+ post_delete.connect(
526
+ post_delete_handler,
527
+ dispatch_uid="clue_python_sdk_core.django_orm.post_delete",
528
+ weak=False,
529
+ )
530
+ except Exception:
531
+ return False
532
+
533
+ _django_orm_instrumented = True
534
+ return True