devora-python 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.
@@ -0,0 +1,158 @@
1
+ """Outbound calls to the Devora API: no redirects (signed headers and bodies
2
+ must never be forwarded to another origin), a total deadline, and a bounded
3
+ JSON object response."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import threading
9
+ import time
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from concurrent.futures import TimeoutError as FutureTimeout
12
+ from typing import Any, Mapping, Optional
13
+ from urllib import error, request
14
+
15
+ #: Largest control-plane response the SDK will buffer.
16
+ MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
17
+ #: Worker threads performing control-plane I/O, and the most calls in flight
18
+ #: (running or queued) before new calls fail fast instead of piling up.
19
+ _WORKERS = 8
20
+ _MAX_IN_FLIGHT = 32
21
+
22
+
23
+ class ControlPlaneError(Exception):
24
+ """Transport failure, redirect, oversized or malformed response."""
25
+
26
+
27
+ class _NoRedirect(request.HTTPRedirectHandler):
28
+ def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None:
29
+ # Returning None makes urllib raise HTTPError for the 3xx instead of following it.
30
+ return None
31
+
32
+
33
+ _OPENER = request.build_opener(_NoRedirect)
34
+
35
+
36
+ def _read_bounded(response: Any, max_bytes: int, deadline: float) -> bytes:
37
+ declared = response.headers.get("content-length")
38
+ if declared is not None and (not declared.isascii() or not declared.isdigit() or int(declared) > max_bytes):
39
+ raise ControlPlaneError("Response too large")
40
+ body = bytearray()
41
+ while True:
42
+ if time.monotonic() > deadline:
43
+ raise ControlPlaneError("Response deadline exceeded")
44
+ # read(n) can perform many socket reads before returning n bytes, so a
45
+ # peer trickling data defeats the deadline between chunks. read1 makes
46
+ # at most one underlying read and lets us check elapsed time again.
47
+ read = getattr(response, "read1", response.read)
48
+ chunk = read(min(8192, max_bytes + 1 - len(body)))
49
+ if time.monotonic() > deadline:
50
+ raise ControlPlaneError("Response deadline exceeded")
51
+ if not chunk:
52
+ return bytes(body)
53
+ body.extend(chunk)
54
+ if len(body) > max_bytes:
55
+ raise ControlPlaneError("Response too large")
56
+
57
+
58
+ def _json_object(raw: bytes) -> dict[str, Any]:
59
+ try:
60
+ parsed = json.loads(raw.decode("utf-8"))
61
+ except (UnicodeDecodeError, ValueError) as exc:
62
+ raise ControlPlaneError("Invalid JSON response") from exc
63
+ if not isinstance(parsed, dict):
64
+ raise ControlPlaneError("Unexpected response shape")
65
+ return parsed
66
+
67
+
68
+ class _Pool:
69
+ """Bounded I/O workers. urllib's timeout applies per socket operation and
70
+ not at all to DNS resolution, so the caller's total deadline is enforced by
71
+ waiting on the worker's future; a stalled worker is abandoned (it still ends
72
+ at its own socket timeouts) and in-flight calls are capped."""
73
+
74
+ def __init__(self) -> None:
75
+ self.executor = ThreadPoolExecutor(max_workers=_WORKERS, thread_name_prefix="devora-control-plane")
76
+ self.slots = threading.BoundedSemaphore(_MAX_IN_FLIGHT)
77
+
78
+
79
+ _POOL = _Pool()
80
+
81
+
82
+ def _reset_pool_after_fork() -> None:
83
+ # A forked worker (gunicorn, uWSGI) inherits the parent's executor without
84
+ # its threads; start a fresh one.
85
+ global _POOL
86
+ _POOL = _Pool()
87
+
88
+
89
+ if hasattr(os, "register_at_fork"):
90
+ os.register_at_fork(after_in_child=_reset_pool_after_fork)
91
+
92
+
93
+ def control_plane_request(
94
+ url: str,
95
+ method: str,
96
+ headers: Mapping[str, str],
97
+ body: Optional[bytes] = None,
98
+ timeout: float = 5.0,
99
+ max_bytes: int = MAX_CONTROL_RESPONSE_BYTES,
100
+ ) -> tuple[int, Optional[dict[str, Any]], Mapping[str, str]]:
101
+ """Return ``(status, json_object_or_None, headers)``.
102
+
103
+ Redirects, network errors, oversized or slow responses raise
104
+ :class:`ControlPlaneError`. A non-2xx response returns its status and, when
105
+ it is a bounded JSON object, its body. ``timeout`` is a hard total deadline
106
+ covering DNS, connect, headers and body.
107
+ """
108
+ deadline = time.monotonic() + timeout
109
+ pool = _POOL
110
+ if not pool.slots.acquire(blocking=False):
111
+ raise ControlPlaneError("Too many outstanding Devora requests")
112
+ try:
113
+ future = pool.executor.submit(_perform, url, method, headers, body, timeout, max_bytes, deadline)
114
+ except RuntimeError as exc:
115
+ pool.slots.release()
116
+ raise ControlPlaneError("Devora is unreachable") from exc
117
+ future.add_done_callback(lambda _: pool.slots.release())
118
+ try:
119
+ return future.result(timeout=max(0.0, deadline - time.monotonic()))
120
+ except FutureTimeout as exc:
121
+ future.cancel()
122
+ raise ControlPlaneError("Devora request deadline exceeded") from exc
123
+
124
+
125
+ def _perform(
126
+ url: str,
127
+ method: str,
128
+ headers: Mapping[str, str],
129
+ body: Optional[bytes],
130
+ timeout: float,
131
+ max_bytes: int,
132
+ deadline: float,
133
+ ) -> tuple[int, Optional[dict[str, Any]], Mapping[str, str]]:
134
+ req = request.Request(url, data=body, method=method)
135
+ for name, value in headers.items():
136
+ req.add_header(name, value)
137
+ try:
138
+ remaining = deadline - time.monotonic()
139
+ if remaining <= 0:
140
+ raise ControlPlaneError("Devora request deadline exceeded")
141
+ with _OPENER.open(req, timeout=remaining) as response:
142
+ raw = _read_bounded(response, max_bytes, deadline)
143
+ return response.status, _json_object(raw), response.headers
144
+ except error.HTTPError as exc:
145
+ try:
146
+ if 300 <= exc.code < 400 and exc.code != 304:
147
+ raise ControlPlaneError("Redirects are not followed") from exc
148
+ try:
149
+ payload: Optional[dict[str, Any]] = _json_object(_read_bounded(exc, max_bytes, deadline))
150
+ except ControlPlaneError:
151
+ payload = None
152
+ return exc.code, payload, exc.headers
153
+ finally:
154
+ exc.close()
155
+ except ControlPlaneError:
156
+ raise
157
+ except Exception as exc:
158
+ raise ControlPlaneError("Devora is unreachable") from exc
devora_sdk/utils.py ADDED
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Mapping, Optional, Union
6
+ from urllib.parse import quote, unquote
7
+
8
+
9
+ def create_success_response(data: Any) -> dict[str, Any]:
10
+ return {"success": True, "data": data, "timestamp": now_ms()}
11
+
12
+
13
+ def create_error_response(error: str, error_code: Optional[str] = None) -> dict[str, Any]:
14
+ response: dict[str, Any] = {"success": False, "error": error, "timestamp": now_ms()}
15
+ if error_code:
16
+ response["errorCode"] = error_code
17
+ return response
18
+
19
+
20
+ def now_ms() -> int:
21
+ import time
22
+
23
+ return int(time.time() * 1000)
24
+
25
+
26
+ def match_path(pattern: str, path: str) -> tuple[bool, dict[str, str]]:
27
+ pattern_parts = [part for part in pattern.split("/") if part]
28
+ path_parts = [part for part in path.split("/") if part]
29
+ if len(pattern_parts) != len(path_parts):
30
+ return False, {}
31
+
32
+ params: dict[str, str] = {}
33
+ for pattern_part, path_part in zip(pattern_parts, path_parts):
34
+ if pattern_part.startswith(":"):
35
+ params[pattern_part[1:]] = safe_decode_path_part(path_part)
36
+ elif pattern_part != path_part:
37
+ return False, {}
38
+ return True, params
39
+
40
+
41
+ def get_header(headers: Mapping[str, Any], key: str) -> Optional[str]:
42
+ value = headers.get(key)
43
+ if value is None:
44
+ value = headers.get(key.lower())
45
+ if value is None:
46
+ value = headers.get(key.upper())
47
+ if value is None:
48
+ lower_key = key.lower()
49
+ for header_key, header_value in headers.items():
50
+ if str(header_key).lower() == lower_key:
51
+ value = header_value
52
+ break
53
+ if isinstance(value, (list, tuple)):
54
+ value = value[0] if value else None
55
+ return str(value) if value is not None else None
56
+
57
+
58
+ def get_error_status_code(error_code: Optional[str]) -> int:
59
+ if not error_code:
60
+ return 400
61
+ if error_code in (
62
+ "INVALID_IMPERSONATION_CONTEXT",
63
+ "IMPERSONATION_EXPIRED",
64
+ "IMPERSONATION_SESSION_ENDED",
65
+ ):
66
+ return 401
67
+ if error_code in ("IMPERSONATION_POLICY_UNAVAILABLE", "REPLAY_STORE_UNAVAILABLE"):
68
+ return 503
69
+ if error_code == "REPLAYED_REQUEST":
70
+ return 401
71
+ if error_code == "UNSUPPORTED_CONTENT_ENCODING":
72
+ return 415
73
+ if error_code in ("IMPERSONATION_ENDPOINT_BLOCKED", "IMPERSONATION_SCOPE_VIOLATION"):
74
+ return 403
75
+ if any(
76
+ token in error_code
77
+ for token in ("SECURITY", "SIGNATURE", "TIMESTAMP", "MISSING_HEADERS", "ORG_MISMATCH")
78
+ ):
79
+ return 401
80
+ if error_code == "NOT_FOUND":
81
+ return 404
82
+ if "SCOPE" in error_code or "WRITE_BLOCKED" in error_code:
83
+ return 403
84
+ if "BODY_TOO_LARGE" in error_code or "PAYLOAD_TOO_LARGE" in error_code:
85
+ return 413
86
+ if "RATE_LIMITED" in error_code or "TOO_MANY_REQUESTS" in error_code:
87
+ return 429
88
+ if error_code == "INTERNAL_ERROR":
89
+ return 500
90
+ return 400
91
+
92
+
93
+ def safe_decode_path_part(value: str) -> str:
94
+ try:
95
+ return unquote(value)
96
+ except Exception:
97
+ return value
98
+
99
+
100
+ def encode_uri_component(value: str) -> str:
101
+ return quote(value, safe="-_.!~*'()")
102
+
103
+
104
+ def normalize_request_path(raw_target: str) -> str:
105
+ """Path of a raw origin-form target (``/path?query``), percent-decoded once."""
106
+ path = (raw_target or "/").split("?", 1)[0].split("#", 1)[0]
107
+ return unquote(path, errors="strict")
108
+
109
+
110
+ def is_ambiguous_request_path(path: str) -> bool:
111
+ """Reject paths whose segment boundaries could differ between routers.
112
+
113
+ ``path`` is a path only, never a target with a query: either the raw
114
+ percent-encoded path or the decoded path the framework routes on. A decoded
115
+ path is never split at ``?`` or ``#``; those are ordinary characters there
116
+ (``%3F`` in the wire path), and cutting at them would judge a different path
117
+ from the one the router dispatches.
118
+ """
119
+ path = path or "/"
120
+ # Adapters hand over already-decoded paths, so a space or a bare "%" is
121
+ # ordinary parameter content; control characters and backslashes are not.
122
+ if len(path) > 8192 or not path.startswith("/") or re.search(r"[\\\x00-\x1f\x7f]", path):
123
+ return True
124
+ try:
125
+ decoded = unquote(path, errors="strict")
126
+ except (ValueError, UnicodeError):
127
+ return True
128
+ # A still-encoded sequence after one decode means double encoding.
129
+ return bool(
130
+ re.search(r"[\\;\x00-\x1f\x7f]|%[0-9a-fA-F]{2}", decoded)
131
+ or re.search(r"%(2f|5c)", path, re.I)
132
+ or "//" in decoded
133
+ or any(part in (".", "..") for part in decoded.split("/"))
134
+ )
135
+
136
+
137
+ def match_endpoint_pattern(pattern: str, path: str, *, case_sensitive: bool = True, ignore_trailing_slash: bool = False) -> bool:
138
+ """Same bounded segment glob as JS core; no regex backtracking or recursion."""
139
+ if len(pattern) > 500 or len(path) > 8192:
140
+ return False
141
+ pattern = pattern if pattern.startswith("/") else "/" + pattern
142
+ path = path if path.startswith("/") else "/" + path
143
+ if not case_sensitive:
144
+ pattern, path = pattern.lower(), path.lower()
145
+ if ignore_trailing_slash:
146
+ pattern, path = pattern.rstrip("/") or "/", path.rstrip("/") or "/"
147
+
148
+ def segment_matches(token: str, value: str) -> bool:
149
+ previous = [True] + [False] * len(value)
150
+ for char in token:
151
+ current = [previous[0] if char == "*" else False] + [False] * len(value)
152
+ for j in range(1, len(value) + 1):
153
+ current[j] = (previous[j] or current[j - 1]) if char == "*" else (previous[j - 1] and char == value[j - 1])
154
+ previous = current
155
+ return previous[-1]
156
+
157
+ parts = path[1:].split("/")
158
+ previous = [True] + [False] * len(parts)
159
+ for token in pattern[1:].split("/"):
160
+ current = [previous[0] if token == "**" else False] + [False] * len(parts)
161
+ for j in range(1, len(parts) + 1):
162
+ current[j] = (previous[j] or current[j - 1]) if token == "**" else (previous[j - 1] and segment_matches(token, parts[j - 1]))
163
+ previous = current
164
+ return previous[-1]