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.
- devora_python-0.1.0.dist-info/METADATA +132 -0
- devora_python-0.1.0.dist-info/RECORD +20 -0
- devora_python-0.1.0.dist-info/WHEEL +4 -0
- devora_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- devora_sdk/__init__.py +92 -0
- devora_sdk/api_url_gen.py +2 -0
- devora_sdk/browser_session.py +45 -0
- devora_sdk/constants.py +49 -0
- devora_sdk/guard.py +485 -0
- devora_sdk/handler.py +298 -0
- devora_sdk/hmac.py +67 -0
- devora_sdk/models.py +79 -0
- devora_sdk/policy.py +208 -0
- devora_sdk/py.typed +1 -0
- devora_sdk/replay.py +41 -0
- devora_sdk/sdk.py +390 -0
- devora_sdk/security.py +37 -0
- devora_sdk/signing.py +240 -0
- devora_sdk/transport.py +158 -0
- devora_sdk/utils.py +164 -0
devora_sdk/transport.py
ADDED
|
@@ -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]
|