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,164 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping, Sequence
5
+
6
+
7
+ def build_otel_request_span_id(trace_id: str, span_id: str) -> str:
8
+ return f"otel:{trace_id}:{span_id}"
9
+
10
+
11
+ def resolve_current_otel_span_context(
12
+ expected_kind: str | None = None,
13
+ ) -> dict[str, str] | None:
14
+ try:
15
+ from opentelemetry import trace
16
+ except ImportError:
17
+ return None
18
+
19
+ try:
20
+ span = trace.get_current_span()
21
+ except Exception:
22
+ return None
23
+
24
+ if span is None:
25
+ return None
26
+
27
+ span_context_getter = getattr(span, "get_span_context", None)
28
+ if not callable(span_context_getter):
29
+ return None
30
+
31
+ try:
32
+ span_context = span_context_getter()
33
+ except Exception:
34
+ return None
35
+
36
+ if not getattr(span_context, "is_valid", False):
37
+ return None
38
+
39
+ kind_name = _span_kind_name(getattr(span, "kind", None))
40
+ if expected_kind is not None and kind_name != expected_kind:
41
+ return None
42
+
43
+ trace_id_raw = getattr(span_context, "trace_id", 0)
44
+ span_id_raw = getattr(span_context, "span_id", 0)
45
+ if not isinstance(trace_id_raw, int) or trace_id_raw <= 0:
46
+ return None
47
+ if not isinstance(span_id_raw, int) or span_id_raw <= 0:
48
+ return None
49
+
50
+ parent_context = getattr(span, "parent", None)
51
+ parent_span_id = None
52
+ if getattr(parent_context, "is_valid", False):
53
+ parent_span_id_raw = getattr(parent_context, "span_id", 0)
54
+ if isinstance(parent_span_id_raw, int) and parent_span_id_raw > 0:
55
+ parent_span_id = f"{parent_span_id_raw:016x}"
56
+
57
+ return {
58
+ "trace_id": f"{trace_id_raw:032x}",
59
+ "span_id": f"{span_id_raw:016x}",
60
+ "parent_span_id": parent_span_id or "",
61
+ "kind": kind_name or "",
62
+ }
63
+
64
+
65
+ def merge_current_otel_span_context(
66
+ context: Mapping[str, object],
67
+ *,
68
+ expected_kind: str | None = None,
69
+ use_otel_request_span_id: bool = False,
70
+ ) -> dict[str, object]:
71
+ otel_context = resolve_current_otel_span_context(expected_kind)
72
+ if otel_context is None:
73
+ return dict(context)
74
+
75
+ merged = dict(context)
76
+ trace_id = otel_context["trace_id"]
77
+ span_id = otel_context["span_id"]
78
+ parent_span_id = otel_context.get("parent_span_id") or None
79
+ merged["trace_id"] = trace_id
80
+ merged["span_id"] = span_id
81
+ merged["parent_span_id"] = parent_span_id
82
+ if use_otel_request_span_id and not _non_empty_string(merged.get("request_span_id")):
83
+ merged["request_span_id"] = build_otel_request_span_id(trace_id, span_id)
84
+ return merged
85
+
86
+
87
+ def annotate_current_otel_span(
88
+ attributes: Mapping[str, object],
89
+ *,
90
+ expected_kind: str | None = None,
91
+ ) -> bool:
92
+ try:
93
+ from opentelemetry import trace
94
+ except ImportError:
95
+ return False
96
+
97
+ otel_context = resolve_current_otel_span_context(expected_kind)
98
+ if otel_context is None:
99
+ return False
100
+
101
+ try:
102
+ span = trace.get_current_span()
103
+ except Exception:
104
+ return False
105
+
106
+ if span is None:
107
+ return False
108
+
109
+ set_attribute = getattr(span, "set_attribute", None)
110
+ if not callable(set_attribute):
111
+ return False
112
+
113
+ wrote = False
114
+ for key, value in attributes.items():
115
+ if not isinstance(key, str) or not key.strip() or value is None:
116
+ continue
117
+ normalized = _normalize_attribute_value(value)
118
+ if normalized is None:
119
+ continue
120
+ try:
121
+ set_attribute(key, normalized)
122
+ wrote = True
123
+ except Exception:
124
+ continue
125
+
126
+ return wrote
127
+
128
+
129
+ def _span_kind_name(kind: object) -> str | None:
130
+ name = getattr(kind, "name", None)
131
+ if isinstance(name, str) and name.strip():
132
+ return name.strip().upper()
133
+ if isinstance(kind, str) and kind.strip():
134
+ return kind.strip().upper()
135
+ return None
136
+
137
+
138
+ def _non_empty_string(value: object) -> str | None:
139
+ return value if isinstance(value, str) and value else None
140
+
141
+
142
+ def _normalize_attribute_value(value: object) -> str | bool | int | float | Sequence[str | bool | int | float] | None:
143
+ if isinstance(value, bool):
144
+ return value
145
+ if isinstance(value, int):
146
+ return value
147
+ if isinstance(value, float):
148
+ return value if value == value else None
149
+ if isinstance(value, str):
150
+ return value
151
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
152
+ items: list[str | bool | int | float] = []
153
+ for entry in value:
154
+ normalized = _normalize_attribute_value(entry)
155
+ if normalized is None or isinstance(normalized, Sequence) and not isinstance(normalized, str):
156
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
157
+ items.append(normalized)
158
+ return tuple(items)
159
+ if isinstance(value, Mapping):
160
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
161
+ try:
162
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
163
+ except Exception:
164
+ return None
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping, Sequence
5
+
6
+ from .contracts import (
7
+ CAPTURE_MODE_ALLOWED_PLAINTEXT,
8
+ CAPTURE_MODE_FORBIDDEN,
9
+ CAPTURE_MODE_MASKED_FINGERPRINT,
10
+ )
11
+ from .http_extraction import _to_json_value
12
+ from .privacy import JsonValue, fingerprint_value, infer_value_type, is_denied_key
13
+
14
+ FORBIDDEN_PARAMETER_VALUE = "__forbidden__"
15
+ PARAMETER_FINGERPRINT_SECRET = "clue-backend-sdk"
16
+
17
+
18
+ def _normalize_parameter_lookup_path(path: str) -> str:
19
+ return re.sub(r"\[\d+\]", "[]", path)
20
+
21
+
22
+ def _parameter_leaf(value: object, *, capture_mode: str) -> dict[str, JsonValue]:
23
+ normalized = _to_json_value(value)
24
+ if capture_mode == CAPTURE_MODE_ALLOWED_PLAINTEXT:
25
+ leaf_value: JsonValue = normalized
26
+ elif capture_mode == CAPTURE_MODE_MASKED_FINGERPRINT:
27
+ leaf_value = fingerprint_value(normalized, PARAMETER_FINGERPRINT_SECRET)
28
+ else:
29
+ leaf_value = FORBIDDEN_PARAMETER_VALUE
30
+
31
+ return {
32
+ "type": infer_value_type(normalized),
33
+ "value": leaf_value,
34
+ "capture_mode": capture_mode,
35
+ }
36
+
37
+
38
+ def _build_parameter_snapshot(
39
+ value: object,
40
+ *,
41
+ allowed_paths: Sequence[str],
42
+ denied_keys: Sequence[str],
43
+ path: str = "",
44
+ ) -> tuple[JsonValue, list[str]]:
45
+ normalized_allowed_paths = {
46
+ _normalize_parameter_lookup_path(str(entry))
47
+ for entry in allowed_paths
48
+ if str(entry).strip()
49
+ }
50
+
51
+ if isinstance(value, Mapping):
52
+ snapshot: dict[str, JsonValue] = {}
53
+ allowed_value_keys: list[str] = []
54
+ for key, child in value.items():
55
+ key_text = str(key)
56
+ child_path = f"{path}.{key_text}" if path else key_text
57
+ if is_denied_key(key_text, denied_keys):
58
+ snapshot[key_text] = _parameter_leaf(
59
+ child,
60
+ capture_mode=CAPTURE_MODE_FORBIDDEN,
61
+ )
62
+ continue
63
+
64
+ child_snapshot, child_allowed_value_keys = _build_parameter_snapshot(
65
+ child,
66
+ allowed_paths=allowed_paths,
67
+ denied_keys=denied_keys,
68
+ path=child_path,
69
+ )
70
+ snapshot[key_text] = child_snapshot
71
+ allowed_value_keys.extend(child_allowed_value_keys)
72
+
73
+ return snapshot, allowed_value_keys
74
+
75
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
76
+ items = list(value)
77
+ if not items:
78
+ return [], []
79
+
80
+ representative_snapshot, allowed_value_keys = _build_parameter_snapshot(
81
+ items[0],
82
+ allowed_paths=allowed_paths,
83
+ denied_keys=denied_keys,
84
+ path=f"{path}[]",
85
+ )
86
+ return [representative_snapshot], allowed_value_keys
87
+
88
+ normalized_path = _normalize_parameter_lookup_path(path)
89
+ capture_mode = (
90
+ CAPTURE_MODE_ALLOWED_PLAINTEXT
91
+ if normalized_path in normalized_allowed_paths
92
+ else CAPTURE_MODE_MASKED_FINGERPRINT
93
+ )
94
+ allowed_value_keys = [path] if capture_mode == CAPTURE_MODE_ALLOWED_PLAINTEXT and path else []
95
+ return _parameter_leaf(value, capture_mode=capture_mode), allowed_value_keys
96
+
97
+
98
+ def _build_request_parameter_snapshot(
99
+ *,
100
+ query: Mapping[str, JsonValue],
101
+ body: JsonValue | None,
102
+ allowed_value_paths: Sequence[str],
103
+ denied_keys: Sequence[str],
104
+ ) -> tuple[dict[str, JsonValue], list[str], str, str]:
105
+ snapshot: dict[str, JsonValue] = {}
106
+ allowed_value_keys: list[str] = []
107
+
108
+ if body is not None:
109
+ body_snapshot, body_allowed_value_keys = _build_parameter_snapshot(
110
+ body,
111
+ allowed_paths=allowed_value_paths,
112
+ denied_keys=denied_keys,
113
+ path="body",
114
+ )
115
+ snapshot["body"] = body_snapshot
116
+ allowed_value_keys.extend(body_allowed_value_keys)
117
+
118
+ if query:
119
+ query_snapshot, query_allowed_value_keys = _build_parameter_snapshot(
120
+ query,
121
+ allowed_paths=allowed_value_paths,
122
+ denied_keys=denied_keys,
123
+ path="query",
124
+ )
125
+ snapshot["query"] = query_snapshot
126
+ allowed_value_keys.extend(query_allowed_value_keys)
127
+
128
+ privacy_level = "allowlisted" if allowed_value_keys else "default_masked"
129
+ masking_state = "partially_unmasked" if allowed_value_keys else "masked"
130
+ return snapshot, sorted(dict.fromkeys(allowed_value_keys)), privacy_level, masking_state
131
+
132
+
133
+ def _build_response_parameter_snapshot(
134
+ *,
135
+ body: JsonValue | None,
136
+ allowed_value_paths: Sequence[str],
137
+ denied_keys: Sequence[str],
138
+ ) -> tuple[dict[str, JsonValue], list[str], str, str]:
139
+ if body is None:
140
+ return {}, [], "default_masked", "masked"
141
+
142
+ response_snapshot, allowed_value_keys = _build_parameter_snapshot(
143
+ body,
144
+ allowed_paths=allowed_value_paths,
145
+ denied_keys=denied_keys,
146
+ path="response",
147
+ )
148
+ privacy_level = "allowlisted" if allowed_value_keys else "default_masked"
149
+ masking_state = "partially_unmasked" if allowed_value_keys else "masked"
150
+ return {
151
+ "response": response_snapshot,
152
+ }, sorted(dict.fromkeys(allowed_value_keys)), privacy_level, masking_state
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Dict, List, Union
9
+
10
+ JsonScalar = Union[str, int, float, bool, None]
11
+ JsonValue = Union[JsonScalar, List["JsonValue"], Dict[str, "JsonValue"]]
12
+
13
+ MASK_TOKEN_KEY = "__clue_masked__"
14
+
15
+ DEFAULT_DENIED_KEYS = frozenset(
16
+ {
17
+ "authorization",
18
+ "cookie",
19
+ "set-cookie",
20
+ "password",
21
+ "passwd",
22
+ "secret",
23
+ "token",
24
+ "session",
25
+ "session_token",
26
+ "refresh_token",
27
+ "api_key",
28
+ "apikey",
29
+ "private_key",
30
+ }
31
+ )
32
+
33
+
34
+ def _normalize_lookup_key(key: str) -> str:
35
+ with_underscores = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key.strip())
36
+ return re.sub(r"[^a-z0-9]+", "_", with_underscores.lower()).strip("_")
37
+
38
+
39
+ def is_denied_key(key: str, denied_keys: Sequence[str] = ()) -> bool:
40
+ normalized = _normalize_lookup_key(key)
41
+ if not normalized:
42
+ return False
43
+
44
+ denied = {
45
+ _normalize_lookup_key(entry)
46
+ for entry in [*DEFAULT_DENIED_KEYS, *denied_keys]
47
+ if _normalize_lookup_key(entry)
48
+ }
49
+ if normalized in denied:
50
+ return True
51
+
52
+ padded = f"_{normalized}_"
53
+ return any(f"_{entry}_" in padded for entry in denied)
54
+
55
+
56
+ def _to_json_value(value: object) -> JsonValue:
57
+ if value is None:
58
+ return None
59
+ if isinstance(value, bool):
60
+ return value
61
+ if isinstance(value, (int, float)):
62
+ return value
63
+ if isinstance(value, str):
64
+ return value
65
+ if isinstance(value, bytes):
66
+ return value.decode("utf-8", errors="replace")
67
+ if isinstance(value, Mapping):
68
+ return {str(key): _to_json_value(child) for key, child in value.items()}
69
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
70
+ return [_to_json_value(child) for child in value]
71
+ return str(value)
72
+
73
+
74
+ def safe_sanitize_object(value: object, denied_keys: Sequence[str] = ()) -> JsonValue:
75
+ if isinstance(value, Mapping):
76
+ sanitized: dict[str, JsonValue] = {}
77
+ for key, child in value.items():
78
+ key_text = str(key)
79
+ if is_denied_key(key_text, denied_keys):
80
+ continue
81
+ sanitized[key_text] = safe_sanitize_object(child, denied_keys)
82
+ return sanitized
83
+
84
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
85
+ return [safe_sanitize_object(child, denied_keys) for child in value]
86
+
87
+ return _to_json_value(value)
88
+
89
+
90
+ def infer_value_type(value: object) -> str:
91
+ if value is None:
92
+ return "null"
93
+ if isinstance(value, bool):
94
+ return "boolean"
95
+ if isinstance(value, (int, float)):
96
+ return "number"
97
+ if isinstance(value, str):
98
+ return "string"
99
+ if isinstance(value, bytes):
100
+ return "bytes"
101
+ if isinstance(value, Mapping):
102
+ return "object"
103
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
104
+ return "array"
105
+ return type(value).__name__
106
+
107
+
108
+ def build_mask_token(mode: str, value: object) -> dict[str, JsonValue]:
109
+ return {
110
+ MASK_TOKEN_KEY: True,
111
+ "mode": mode,
112
+ "valueType": infer_value_type(value),
113
+ }
114
+
115
+
116
+ def fingerprint_value(value: object, secret: str) -> str:
117
+ normalized = json.dumps(_to_json_value(value), sort_keys=True, separators=(",", ":"))
118
+ digest = hmac.new(
119
+ secret.encode("utf-8"),
120
+ normalized.encode("utf-8"),
121
+ hashlib.sha256,
122
+ ).hexdigest()
123
+ return f"hmac:{digest}"
124
+