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.
- clue_python_sdk_core/__init__.py +135 -0
- clue_python_sdk_core/adapters.py +2177 -0
- clue_python_sdk_core/bootstrap.py +166 -0
- clue_python_sdk_core/celery.py +734 -0
- clue_python_sdk_core/client.py +335 -0
- clue_python_sdk_core/contracts.py +224 -0
- clue_python_sdk_core/event_size.py +96 -0
- clue_python_sdk_core/http_extraction.py +146 -0
- clue_python_sdk_core/orm.py +534 -0
- clue_python_sdk_core/otel_bridge.py +164 -0
- clue_python_sdk_core/parameter_snapshot.py +152 -0
- clue_python_sdk_core/privacy.py +124 -0
- clue_python_sdk_core/requests_instrumentation.py +341 -0
- clue_python_sdk_core/resources.py +44 -0
- clue_python_sdk_core/runtime.py +894 -0
- clue_python_sdk_core-0.0.1.dist-info/METADATA +11 -0
- clue_python_sdk_core-0.0.1.dist-info/RECORD +19 -0
- clue_python_sdk_core-0.0.1.dist-info/WHEEL +5 -0
- clue_python_sdk_core-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|