slashpeak 0.1.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.
slashpeak/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """SlashPeak Job Router: run each warehouse job where it costs less.
2
+
3
+ pip install "slashpeak[bigquery]"
4
+
5
+ import slashpeak
6
+ slashpeak.install() # hooks every supported library that is installed
7
+
8
+ Reads ``SLASHPEAK_API_KEY``, ``SLASHPEAK_MODE`` (off | shadow | active),
9
+ ``SLASHPEAK_ENDPOINT`` and ``SLASHPEAK_SEND_PARAM_VALUES``. Without a key it
10
+ does nothing. On any error, or no answer within 500 ms, jobs run unchanged.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ from typing import List, Optional
17
+
18
+ from slashpeak._core.settings import Settings
19
+ from slashpeak._version import __version__
20
+
21
+ __all__ = ["__version__", "install", "uninstall", "Settings"]
22
+
23
+ #: Warehouse hook → the library it needs.
24
+ _HOOKS = {"bigquery": "google.cloud.bigquery"}
25
+
26
+
27
+ def _has(module: str) -> bool:
28
+ try:
29
+ return importlib.util.find_spec(module) is not None
30
+ except (ImportError, ValueError):
31
+ return False
32
+
33
+
34
+ def install(settings: Optional[Settings] = None) -> List[str]:
35
+ """Hooks every supported library that is installed. Returns the hooks that are on."""
36
+ on = []
37
+ for name, library in _HOOKS.items():
38
+ if not _has(library):
39
+ continue
40
+ hook = importlib.import_module(f"slashpeak.{name}")
41
+ if hook.install(settings):
42
+ on.append(name)
43
+ return on
44
+
45
+
46
+ def uninstall() -> None:
47
+ """Removes every hook and flushes pending events (at most 1 s)."""
48
+ import sys
49
+
50
+ for name in _HOOKS:
51
+ hook = sys.modules.get(f"slashpeak.{name}")
52
+ if hook is not None:
53
+ hook.uninstall()
slashpeak/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from slashpeak.cli import main
4
+
5
+ sys.exit(main())
File without changes
@@ -0,0 +1,85 @@
1
+ """PII masking before any SQL leaves the process.
2
+
3
+ Same rules, same order and same split as SlashPeak's scan (which masks inside
4
+ BigQuery with RE2): built-in email, phone and IP patterns, then the account's
5
+ own patterns from ``/v1/config``. Backtick-quoted names are left alone.
6
+
7
+ Python's ``re`` runs the patterns with ``re.ASCII``, because RE2's ``\\d``,
8
+ ``\\b`` and ``\\s`` are ASCII-only. One known gap: Python's ASCII ``\\s`` also
9
+ matches a vertical tab, RE2's does not. The server masks again with RE2, so a
10
+ gap can only mask more, never send more.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Iterable, List, Optional, Sequence, Tuple
17
+
18
+ #: The router's built-in rules (``spec/mask_vectors.json`` → ``builtinRules``; a test keeps them equal).
19
+ BUILTIN_RULES: Tuple[Tuple[str, str], ...] = (
20
+ (r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b", "<email>"),
21
+ (
22
+ r"\+\d(?:[-.\s()]{0,2}\d){6,14}\b|(?:\b1[-.\s])?(?:\(\d{3}\)[-.\s]?|\b\d{3}[-.\s])\d{3}[-.\s]\d{4}\b",
23
+ "<phone>",
24
+ ),
25
+ (r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b", "<ip>"),
26
+ (
27
+ r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}(?::[0-9a-fA-F]{1,4}){1,6}\b"
28
+ r"|\b(?:[0-9a-fA-F]{1,4}:){1,7}:|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\b",
29
+ "<ip>",
30
+ ),
31
+ )
32
+ CUSTOM_TOKEN = "<redacted>"
33
+ BUILTIN_REVISION = "builtin"
34
+
35
+ #: Same split as the scan's ``REGEXP_EXTRACT_ALL(text, r'`[^`]*`|[^`]+|`')``.
36
+ _SEGMENTS = re.compile(r"`[^`]*`|[^`]+|`")
37
+
38
+
39
+ class MaskError(ValueError):
40
+ """A rule does not compile. The caller then sends nothing."""
41
+
42
+
43
+ class Masker:
44
+ def __init__(self, rules: Iterable[Tuple[str, str]], revision: str) -> None:
45
+ compiled: List[Tuple["re.Pattern[str]", str]] = []
46
+ for pattern, token in rules:
47
+ try:
48
+ compiled.append((re.compile(pattern, re.ASCII), token))
49
+ except (re.error, TypeError, ValueError) as error:
50
+ raise MaskError("mask rule does not compile") from error
51
+ self._rules = tuple(compiled)
52
+ self.revision = revision
53
+
54
+ def _mask_segment(self, text: str) -> str:
55
+ for regex, token in self._rules:
56
+ # A function replacement: the token is used as written, never read for backrefs.
57
+ text = regex.sub(lambda _match, _token=token: _token, text)
58
+ return text
59
+
60
+ def mask(self, text: str) -> str:
61
+ out = []
62
+ for match in _SEGMENTS.finditer(text):
63
+ segment = match.group(0)
64
+ out.append(segment if len(segment) > 1 and segment.startswith("`") else self._mask_segment(segment))
65
+ return "".join(out)
66
+
67
+
68
+ def builtin_masker(owner_patterns: Sequence[str] = ()) -> Masker:
69
+ return Masker(list(BUILTIN_RULES) + [(p, CUSTOM_TOKEN) for p in owner_patterns], BUILTIN_REVISION)
70
+
71
+
72
+ def masker_from_config(mask: object) -> Optional[Masker]:
73
+ """The masker for ``/v1/config``'s ``mask`` block; None when it is missing or malformed."""
74
+ if not isinstance(mask, dict):
75
+ return None
76
+ rules = mask.get("rules")
77
+ revision = mask.get("revision")
78
+ if not isinstance(rules, list) or not isinstance(revision, str):
79
+ return None
80
+ pairs = []
81
+ for rule in rules:
82
+ if not isinstance(rule, dict) or not isinstance(rule.get("pattern"), str) or not isinstance(rule.get("token"), str):
83
+ return None
84
+ pairs.append((rule["pattern"], rule["token"]))
85
+ return Masker(pairs, revision[:64])
@@ -0,0 +1,379 @@
1
+ """The SDK's view of the router: decide (with breaker and cache), config poll, events.
2
+
3
+ Everything here fails open. Any doubt means "no decision", and the job runs as
4
+ the caller wrote it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import atexit
10
+ import hashlib
11
+ import logging
12
+ import math
13
+ import os
14
+ import queue
15
+ import random
16
+ import threading
17
+ import time
18
+ from collections import OrderedDict
19
+ from typing import Any, Callable, Dict, List, Optional, Tuple
20
+
21
+ from slashpeak._version import __version__
22
+ from slashpeak._core.mask import Masker, MaskError, builtin_masker, masker_from_config
23
+ from slashpeak._core.settings import Settings, stricter_mode
24
+ from slashpeak._core.transport import Reply, Transport
25
+
26
+ logger = logging.getLogger("slashpeak")
27
+
28
+ BREAKER_FAILURES = 5
29
+ BREAKER_OPEN_S = 30.0
30
+ CACHE_MAX = 2048
31
+ EVENT_BATCH = 100
32
+ EVENT_INTERVAL_S = 2.0
33
+ EVENT_QUEUE_MAX = 5000
34
+ EXIT_FLUSH_S = 1.0
35
+ POLL_MIN_S = 10.0
36
+ POLL_MAX_S = 600.0
37
+ DEFAULT_TTL_S = 60.0
38
+
39
+
40
+ def _positive_number(value: Any) -> Optional[float]:
41
+ """A finite number above 0, else None. JSON from Python's parser may carry NaN or Infinity."""
42
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
43
+ return None
44
+ number = float(value)
45
+ return number if math.isfinite(number) and number > 0 else None
46
+
47
+
48
+ def _version_tuple(value: str) -> Tuple[int, int, int]:
49
+ parts = []
50
+ for piece in value.replace("+", ".").replace("-", ".").split(".")[:3]:
51
+ digits = "".join(ch for ch in piece if ch.isdigit())
52
+ parts.append(int(digits) if digits else 0)
53
+ while len(parts) < 3:
54
+ parts.append(0)
55
+ return parts[0], parts[1], parts[2]
56
+
57
+
58
+ def client_at_least(version: str, minimum: Optional[str]) -> bool:
59
+ """Same rule as the server's ``clientAtLeast``."""
60
+ return not minimum or _version_tuple(version) >= _version_tuple(minimum)
61
+
62
+
63
+ class Breaker:
64
+ """5 failures in a row → open 30 s → one probe. A good probe closes it."""
65
+
66
+ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
67
+ self._clock = clock
68
+ self._lock = threading.Lock()
69
+ self._failures = 0
70
+ self._opened_at: Optional[float] = None
71
+ self._probing = False
72
+
73
+ def allow(self) -> bool:
74
+ with self._lock:
75
+ if self._opened_at is None:
76
+ return True
77
+ if self._probing or self._clock() - self._opened_at < BREAKER_OPEN_S:
78
+ return False
79
+ self._probing = True
80
+ return True
81
+
82
+ def record(self, ok: bool) -> None:
83
+ with self._lock:
84
+ if ok:
85
+ self._failures = 0
86
+ self._opened_at = None
87
+ else:
88
+ self._failures += 1
89
+ if self._probing or self._failures >= BREAKER_FAILURES:
90
+ self._opened_at = self._clock()
91
+ self._probing = False
92
+
93
+
94
+ class _Flight:
95
+ __slots__ = ("done", "value")
96
+
97
+ def __init__(self) -> None:
98
+ self.done = threading.Event()
99
+ self.value: Optional[Dict[str, Any]] = None
100
+
101
+
102
+ class DecisionCache:
103
+ """Per-process cache of decisions, key = sha256(project, location, SQL). Single-flight per key."""
104
+
105
+ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
106
+ self._clock = clock
107
+ self._lock = threading.Lock()
108
+ self._entries: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
109
+ self._flights: Dict[str, _Flight] = {}
110
+
111
+ @staticmethod
112
+ def key(project: str, location: Optional[str], sql: str) -> str:
113
+ return hashlib.sha256("\x00".join((project, location or "", sql)).encode("utf-8")).hexdigest()
114
+
115
+ def get_or_fetch(self, key: str, fetch: Callable[[], Optional[Dict[str, Any]]], ttl_cap_s: float, wait_s: float) -> Optional[Dict[str, Any]]:
116
+ with self._lock:
117
+ hit = self._entries.get(key)
118
+ if hit is not None:
119
+ if hit[0] > self._clock():
120
+ self._entries.move_to_end(key)
121
+ return hit[1]
122
+ del self._entries[key]
123
+ flight = self._flights.get(key)
124
+ leader = flight is None
125
+ if leader:
126
+ flight = _Flight()
127
+ self._flights[key] = flight
128
+ assert flight is not None
129
+ if not leader:
130
+ # Another thread is asking for the same query: wait for its answer, within our deadline.
131
+ return flight.value if flight.done.wait(wait_s) else None
132
+ value: Optional[Dict[str, Any]] = None
133
+ try:
134
+ value = fetch()
135
+ finally:
136
+ with self._lock:
137
+ self._flights.pop(key, None)
138
+ ttl = _cache_ttl(value, ttl_cap_s)
139
+ if value is not None and ttl > 0:
140
+ self._entries[key] = (self._clock() + ttl, value)
141
+ while len(self._entries) > CACHE_MAX:
142
+ self._entries.popitem(last=False)
143
+ flight.value = value
144
+ flight.done.set()
145
+ return value
146
+
147
+ def clear(self) -> None:
148
+ with self._lock:
149
+ self._entries.clear()
150
+ self._flights.clear()
151
+
152
+
153
+ def _cache_ttl(decision: Optional[Dict[str, Any]], cap_s: float) -> float:
154
+ if not decision or decision.get("cacheScope") != "sql":
155
+ return 0.0
156
+ ttl = _positive_number(decision.get("ttlSeconds"))
157
+ return min(ttl, cap_s) if ttl is not None else 0.0
158
+
159
+
160
+ class Router:
161
+ """Process-wide router state. Thread-safe; ``after_fork_in_child`` rebuilds it in a child."""
162
+
163
+ def __init__(self, settings: Settings, transport: Optional[Transport] = None, clock: Callable[[], float] = time.monotonic) -> None:
164
+ self.settings = settings
165
+ self._clock = clock
166
+ self.transport = transport or Transport(settings.endpoint, settings.api_key or "")
167
+ self.breaker = Breaker(clock)
168
+ self.cache = DecisionCache(clock)
169
+ self._config: Optional[Dict[str, Any]] = None
170
+ self._etag: Optional[str] = None
171
+ self._masker: Optional[Masker] = builtin_masker()
172
+ self._state_lock = threading.Lock()
173
+ self._events: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=EVENT_QUEUE_MAX)
174
+ self._stop = threading.Event()
175
+ self._threads_pid = -1
176
+ self._threads_lock = threading.Lock()
177
+ self._closed = False
178
+
179
+ # -- background threads ----------------------------------------------------------------
180
+ def start(self) -> None:
181
+ """Starts the config and event threads once per process (again in a forked child)."""
182
+ if self._threads_pid == os.getpid() or self._closed:
183
+ return
184
+ with self._threads_lock:
185
+ if self._threads_pid == os.getpid():
186
+ return
187
+ self._threads_pid = os.getpid()
188
+ self._stop = threading.Event()
189
+ threading.Thread(target=self._config_loop, name="slashpeak-config", daemon=True).start()
190
+ threading.Thread(target=self._event_loop, name="slashpeak-events", daemon=True).start()
191
+
192
+ def after_fork_in_child(self) -> None:
193
+ # Locks held by another thread at fork time stay locked forever in the child: make new ones.
194
+ self._state_lock = threading.Lock()
195
+ self._threads_lock = threading.Lock()
196
+ self.breaker = Breaker(self._clock)
197
+ self.cache = DecisionCache(self._clock)
198
+ self._events = queue.Queue(maxsize=EVENT_QUEUE_MAX)
199
+ self._threads_pid = -1
200
+ self.transport.after_fork_in_child()
201
+
202
+ def close(self, flush_s: float = EXIT_FLUSH_S) -> None:
203
+ if self._closed:
204
+ return
205
+ self._closed = True
206
+ self._stop.set()
207
+ if self._threads_pid == os.getpid():
208
+ self.flush_events(deadline_s=flush_s)
209
+ self.transport.close()
210
+
211
+ # -- config ----------------------------------------------------------------------------
212
+ def poll_config(self) -> bool:
213
+ """One ``GET /v1/config`` with ETag. True when the call worked (200 or 304)."""
214
+ headers = {"If-None-Match": self._etag} if self._etag else None
215
+ try:
216
+ reply = self.transport.request("GET", "/v1/config", headers=headers)
217
+ except Exception: # noqa: BLE001
218
+ logger.debug("slashpeak config fetch failed", extra={"code": "network"})
219
+ return False
220
+ if reply.status == 304:
221
+ return True
222
+ if reply.status != 200 or reply.body is None:
223
+ logger.debug("slashpeak config fetch failed", extra={"code": f"http_{reply.status}"})
224
+ return False
225
+ self._apply_config(reply)
226
+ return True
227
+
228
+ def _apply_config(self, reply: Reply) -> None:
229
+ config = reply.body or {}
230
+ masker: Optional[Masker]
231
+ try:
232
+ masker = masker_from_config(config.get("mask"))
233
+ except MaskError:
234
+ masker = None # A rule Python cannot compile: send nothing until it changes.
235
+ else:
236
+ if masker is None:
237
+ masker = builtin_masker()
238
+ with self._state_lock:
239
+ self._config = config
240
+ self._etag = reply.etag
241
+ self._masker = masker
242
+
243
+ def _poll_seconds(self) -> float:
244
+ config = self._config or {}
245
+ value = config.get("pollSeconds")
246
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value):
247
+ return max(POLL_MIN_S, min(POLL_MAX_S, float(value)))
248
+ return 60.0
249
+
250
+ def _config_loop(self) -> None:
251
+ while not self._stop.is_set():
252
+ ok = self.poll_config()
253
+ wait = self._poll_seconds() if ok else POLL_MIN_S
254
+ # Jitter, so a fleet of workers started together does not poll together.
255
+ self._stop.wait(wait * random.uniform(0.9, 1.1))
256
+
257
+ def masker(self) -> Optional[Masker]:
258
+ return self._masker
259
+
260
+ def config(self) -> Optional[Dict[str, Any]]:
261
+ return self._config
262
+
263
+ # -- gating ----------------------------------------------------------------------------
264
+ def _policies_for(self, project: str) -> Optional[List[Dict[str, Any]]]:
265
+ """Policies that may cover ``project``; None when no config has arrived yet."""
266
+ config = self._config
267
+ if config is None:
268
+ return None
269
+ policies = [p for p in config.get("policies") or [] if isinstance(p, dict)]
270
+ out = []
271
+ for policy in policies:
272
+ projects = policy.get("projects") or []
273
+ # An empty list covers every project of that connection (the server's rule).
274
+ if not projects or project in projects:
275
+ out.append(policy)
276
+ return out
277
+
278
+ def may_decide(self, project: str) -> bool:
279
+ if self._closed or not self.settings.usable:
280
+ return False
281
+ config = self._config
282
+ if config is None:
283
+ return True # First calls before the config arrives: the server still applies every rule.
284
+ if config.get("killSwitch") is True:
285
+ return False
286
+ minimum = (config.get("minClient") or {}).get("python") if isinstance(config.get("minClient"), dict) else None
287
+ if not client_at_least(__version__, minimum):
288
+ return False
289
+ policies = self._policies_for(project) or []
290
+ return any(stricter_mode(self.settings.mode, p.get("mode")) != "off" for p in policies)
291
+
292
+ def send_param_values(self, project: str) -> bool:
293
+ if not self.settings.send_param_values:
294
+ return False
295
+ policies = self._policies_for(project)
296
+ # Off if any policy that may cover the project says off.
297
+ return all(p.get("sendParamValues") is not False for p in policies or [])
298
+
299
+ def ttl_cap_s(self) -> float:
300
+ value = _positive_number((self._config or {}).get("decisionTtlSeconds"))
301
+ return value if value is not None else DEFAULT_TTL_S
302
+
303
+ # -- decide ----------------------------------------------------------------------------
304
+ def decide(self, cache_key: str, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
305
+ self.start()
306
+ return self.cache.get_or_fetch(cache_key, lambda: self._decide_call(request), self.ttl_cap_s(), wait_s=0.5)
307
+
308
+ def _decide_call(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
309
+ if not self.breaker.allow():
310
+ return None
311
+ reply = self.transport.request_with_deadline("POST", "/v1/decide", request)
312
+ ok = reply is not None and reply.status == 200 and isinstance(reply.body, dict)
313
+ self.breaker.record(ok)
314
+ if not ok:
315
+ logger.debug("slashpeak decide skipped", extra={"code": "timeout" if reply is None else f"http_{reply.status}"})
316
+ return None
317
+ return reply.body
318
+
319
+ # -- events ----------------------------------------------------------------------------
320
+ def emit(self, event: Dict[str, Any]) -> None:
321
+ if not event.get("decisionId") or self._closed:
322
+ return
323
+ self.start()
324
+ try:
325
+ self._events.put_nowait(event)
326
+ except queue.Full:
327
+ pass # Bounded: events are best effort and never slow a job.
328
+
329
+ def _drain(self, limit: int) -> List[Dict[str, Any]]:
330
+ batch: List[Dict[str, Any]] = []
331
+ while len(batch) < limit:
332
+ try:
333
+ batch.append(self._events.get_nowait())
334
+ except queue.Empty:
335
+ break
336
+ return batch
337
+
338
+ def _send_events(self, batch: List[Dict[str, Any]], timeout: Tuple[float, float]) -> None:
339
+ if not batch:
340
+ return
341
+ try:
342
+ self.transport.request("POST", "/v1/events", {"events": batch}, timeout=timeout)
343
+ except Exception: # noqa: BLE001
344
+ logger.debug("slashpeak events dropped", extra={"code": "network", "count": len(batch)})
345
+
346
+ def _event_loop(self) -> None:
347
+ while not self._stop.wait(EVENT_INTERVAL_S):
348
+ while True:
349
+ batch = self._drain(EVENT_BATCH)
350
+ self._send_events(batch, (2.0, 5.0))
351
+ if len(batch) < EVENT_BATCH:
352
+ break
353
+
354
+ def flush_events(self, deadline_s: float = EXIT_FLUSH_S) -> None:
355
+ end = time.monotonic() + deadline_s
356
+ while time.monotonic() < end:
357
+ batch = self._drain(EVENT_BATCH)
358
+ if not batch:
359
+ return
360
+ left = max(0.05, end - time.monotonic())
361
+ self._send_events(batch, (min(0.5, left), left))
362
+
363
+
364
+ _EXIT_HOOKED = False
365
+
366
+
367
+ def register_exit(router_getter: Callable[[], Optional[Router]]) -> None:
368
+ """Flush events for at most 1 s at interpreter exit."""
369
+ global _EXIT_HOOKED
370
+ if _EXIT_HOOKED:
371
+ return
372
+ _EXIT_HOOKED = True
373
+
374
+ def _on_exit() -> None:
375
+ router = router_getter()
376
+ if router is not None:
377
+ router.close()
378
+
379
+ atexit.register(_on_exit)
@@ -0,0 +1,69 @@
1
+ """The one router per process, shared by every warehouse hook.
2
+
3
+ A process that uses BigQuery and Snowflake keeps one key, one connection pool,
4
+ one config poll and one event queue.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import threading
11
+ from typing import Callable, List, Optional, Set
12
+
13
+ from slashpeak._core.router import Router, register_exit
14
+ from slashpeak._core.settings import Settings, load_settings
15
+
16
+ _lock = threading.Lock()
17
+ _router: Optional[Router] = None
18
+ _users: Set[str] = set()
19
+ _fork_callbacks: List[Callable[[], None]] = []
20
+ _fork_hooked = False
21
+
22
+
23
+ def current() -> Optional[Router]:
24
+ return _router
25
+
26
+
27
+ def _after_fork_in_child() -> None:
28
+ global _lock
29
+ # A lock held by another thread at fork time stays locked in the child: rebuild every one.
30
+ _lock = threading.Lock()
31
+ for callback in list(_fork_callbacks):
32
+ callback()
33
+ router = _router
34
+ if router is not None:
35
+ router.after_fork_in_child()
36
+
37
+
38
+ def on_fork(callback: Callable[[], None]) -> None:
39
+ """Runs ``callback`` in a forked child, before the router resets."""
40
+ if callback not in _fork_callbacks:
41
+ _fork_callbacks.append(callback)
42
+
43
+
44
+ def acquire(user: str, settings: Optional[Settings] = None) -> Router:
45
+ """The process router for one hook (``bigquery``, …). New settings replace the router."""
46
+ global _router, _fork_hooked
47
+ with _lock:
48
+ # Same settings from a second hook keep the router (one pool, one poll, one queue).
49
+ if _router is None or (settings is not None and settings != _router.settings):
50
+ if _router is not None:
51
+ _router.close(flush_s=0.0)
52
+ _router = Router(settings or load_settings())
53
+ register_exit(current)
54
+ if not _fork_hooked and hasattr(os, "register_at_fork"):
55
+ os.register_at_fork(after_in_child=_after_fork_in_child)
56
+ _fork_hooked = True
57
+ _users.add(user)
58
+ return _router
59
+
60
+
61
+ def release(user: str) -> None:
62
+ """Closes the router (flushing events, at most 1 s) when its last hook goes."""
63
+ global _router
64
+ with _lock:
65
+ _users.discard(user)
66
+ if _users or _router is None:
67
+ return
68
+ router, _router = _router, None
69
+ router.close()
@@ -0,0 +1,61 @@
1
+ """SDK settings, read once from the environment (or passed to ``install``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Mapping, Optional
8
+ from urllib.parse import urlsplit
9
+
10
+ MODES = ("off", "shadow", "active")
11
+ DEFAULT_ENDPOINT = "https://router.slashpeak.com"
12
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Settings:
17
+ api_key: Optional[str]
18
+ endpoint: str = DEFAULT_ENDPOINT
19
+ #: The SDK's own ceiling. The stricter of this and the server's mode wins.
20
+ mode: str = "active"
21
+ #: Send query parameter values (masked first). Off if the SDK or the server says off.
22
+ send_param_values: bool = True
23
+
24
+ @property
25
+ def usable(self) -> bool:
26
+ """A key, a mode other than off, and an endpoint the key may be sent to."""
27
+ return bool(self.api_key) and self.mode != "off" and endpoint_is_safe(self.endpoint)
28
+
29
+
30
+ def endpoint_is_safe(endpoint: str) -> bool:
31
+ """HTTPS only. Plain HTTP is allowed for a local test server, never for a remote host."""
32
+ try:
33
+ parts = urlsplit(endpoint)
34
+ except ValueError:
35
+ return False
36
+ if parts.scheme == "https" and parts.hostname:
37
+ return True
38
+ return parts.scheme == "http" and (parts.hostname or "") in _LOCAL_HOSTS
39
+
40
+
41
+ def _flag(value: Optional[str], default: bool) -> bool:
42
+ if value is None or value.strip() == "":
43
+ return default
44
+ return value.strip().lower() not in ("0", "false", "off", "no")
45
+
46
+
47
+ def load_settings(env: Mapping[str, str] = os.environ) -> Settings:
48
+ mode = (env.get("SLASHPEAK_MODE") or "active").strip().lower()
49
+ return Settings(
50
+ api_key=(env.get("SLASHPEAK_API_KEY") or "").strip() or None,
51
+ endpoint=(env.get("SLASHPEAK_ENDPOINT") or DEFAULT_ENDPOINT).strip().rstrip("/"),
52
+ # An unknown mode is a typo: do nothing rather than guess.
53
+ mode=mode if mode in MODES else "off",
54
+ send_param_values=_flag(env.get("SLASHPEAK_SEND_PARAM_VALUES"), True),
55
+ )
56
+
57
+
58
+ def stricter_mode(a: Optional[str], b: Optional[str]) -> str:
59
+ """off < shadow < active; an unknown value counts as off."""
60
+ rank = {mode: i for i, mode in enumerate(MODES)}
61
+ return MODES[min(rank.get(a or "off", 0), rank.get(b or "off", 0))]