reqlo-sdk 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.
reqlo/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """ReQlo: authorization for AI agents. Server-side client; keep your project API key on your server."""
2
+ from . import webhooks
3
+ from ._client import AsyncReqlo, Reqlo
4
+ from ._errors import (ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloError, ReqloNetworkError,
5
+ ReqloResponseError, ReqloSignatureError, ReqloTimeoutError)
6
+ from ._version import VERSION
7
+
8
+ __version__ = VERSION
9
+ __all__ = ["Reqlo", "AsyncReqlo", "webhooks", "ReqloError", "ReqloConfigurationError", "ReqloAPIError", "ReqloTimeoutError",
10
+ "ReqloNetworkError", "ReqloResponseError", "ReqloDeniedError", "ReqloSignatureError", "__version__"]
reqlo/_client.py ADDED
@@ -0,0 +1,225 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ import re
5
+ import socket
6
+ import time
7
+ from email.utils import parsedate_to_datetime
8
+ from typing import Any, Dict, List, Optional
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.parse import urlencode, urlsplit
11
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
12
+
13
+ from ._errors import (ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloError, ReqloNetworkError,
14
+ ReqloResponseError, ReqloTimeoutError)
15
+ from ._validate import approval, approval_list, decision, delegation, delegation_list
16
+ from ._version import VERSION
17
+
18
+ # Only these codes are surfaced; anything else becomes None so response bodies never leak into logs.
19
+ _CODES = {"method_not_allowed", "parameter_invalid", "attribute_invalid", "action_unknown", "request_too_large",
20
+ "invalid_json", "invalid_request", "api_key_required", "api_key_invalid", "api_key_revoked", "scope_missing",
21
+ "authentication_required", "user_session_required", "resource_missing", "rate_limited", "internal_error",
22
+ "service_unavailable"}
23
+ _STATUSES = ("pending", "approved", "rejected", "expired", "consumed")
24
+ _APPROVAL_ID = re.compile(r"^APR_[A-Za-z0-9]{16}$")
25
+ _DELEGATION_ID = re.compile(r"^DEL_[A-Za-z0-9]{16}$")
26
+
27
+
28
+ class _NoRedirect(HTTPRedirectHandler):
29
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None: # never follow redirects with a credential
30
+ return None
31
+
32
+
33
+ def _base_url(value: str) -> str:
34
+ parts = urlsplit(value)
35
+ local = parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")
36
+ if (parts.scheme != "https" and not local) or parts.username or parts.password or parts.query or parts.fragment \
37
+ or not re.match(r"^/v1/?$", parts.path or ""):
38
+ raise ReqloConfigurationError("base_url must be HTTPS with path /v1 and no credentials, query or fragment. "
39
+ "Loopback HTTP is allowed for development.")
40
+ return "%s://%s/v1" % (parts.scheme, parts.netloc)
41
+
42
+
43
+ def _retry_delay(headers: Any, attempt: int) -> Optional[float]:
44
+ delay = min(5.0, 0.25 * 2 ** attempt * (0.75 + random.random() * 0.5))
45
+ header = headers.get("Retry-After") if headers is not None else None
46
+ if header:
47
+ header = header.strip()
48
+ try:
49
+ delay = max(0.0, float(header)) if re.match(r"^\d+(\.\d+)?$", header) else max(0.0, parsedate_to_datetime(header).timestamp() - time.time())
50
+ except (TypeError, ValueError):
51
+ pass
52
+ # Surface the error rather than wait a long, server-directed time.
53
+ return delay if delay <= 10.0 else None
54
+
55
+
56
+ class _Approvals:
57
+ def __init__(self, client: "Reqlo") -> None:
58
+ self._client = client
59
+
60
+ def list(self, status: Optional[str] = None) -> Dict[str, Any]:
61
+ """Approvals in the key's environment, newest first: {"data": [...], "environment_id": ...}."""
62
+ if status is not None and status not in _STATUSES:
63
+ raise ReqloConfigurationError("status must be one of: %s." % ", ".join(_STATUSES))
64
+ return approval_list(self._client._request("GET", "/approvals" + ("?status=" + status if status else "")))
65
+
66
+ def retrieve(self, approval_id: str) -> Dict[str, Any]:
67
+ if not isinstance(approval_id, str) or not _APPROVAL_ID.match(approval_id):
68
+ raise ReqloConfigurationError("Use an approval ID returned by ReQlo.")
69
+ return approval(self._client._request("GET", "/approvals/" + approval_id))
70
+
71
+
72
+ class _Delegations:
73
+ def __init__(self, client: "Reqlo") -> None:
74
+ self._client = client
75
+
76
+ def create(self, agent_id: str, subject: Dict[str, str], actions: List[str], expires_at: str) -> Dict[str, Any]:
77
+ """Record a person's grant. Call it from your backend after they consent. Never retried automatically."""
78
+ if not isinstance(agent_id, str) or not isinstance(subject, dict) or not isinstance(actions, list) or not isinstance(expires_at, str):
79
+ raise ReqloConfigurationError("Delegations need agent_id, subject, actions and expires_at.")
80
+ body = {"agent_id": agent_id, "subject": subject, "actions": actions, "expires_at": expires_at}
81
+ return delegation(self._client._request("POST", "/delegations", body, retry=False))
82
+
83
+ def list(self, agent_id: Optional[str] = None, subject_id: Optional[str] = None) -> List[Dict[str, Any]]:
84
+ query = {k: v for k, v in (("agent_id", agent_id), ("subject_id", subject_id)) if v is not None}
85
+ return delegation_list(self._client._request("GET", "/delegations" + ("?" + urlencode(query) if query else "")))
86
+
87
+ def retrieve(self, delegation_id: str) -> Dict[str, Any]:
88
+ if not isinstance(delegation_id, str) or not _DELEGATION_ID.match(delegation_id):
89
+ raise ReqloConfigurationError("Use a delegation ID returned by ReQlo.")
90
+ return delegation(self._client._request("GET", "/delegations/" + delegation_id))
91
+
92
+ def revoke(self, delegation_id: str) -> Dict[str, Any]:
93
+ if not isinstance(delegation_id, str) or not _DELEGATION_ID.match(delegation_id):
94
+ raise ReqloConfigurationError("Use a delegation ID returned by ReQlo.")
95
+ return delegation(self._client._request("POST", "/delegations/%s/revoke" % delegation_id, {}))
96
+
97
+
98
+ class Reqlo:
99
+ """Server-side ReQlo client. Keep the project API key on your server; never ship it to a browser or a prompt."""
100
+
101
+ def __init__(self, api_key: str, base_url: str = "https://api.reqlo.io/v1", timeout: float = 3.0, max_retries: int = 2) -> None:
102
+ if not isinstance(api_key, str) or not re.match(r"^rq_\S+$", api_key):
103
+ raise ReqloConfigurationError("A secret project API key (rq_...) is required.")
104
+ if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or not 0 < timeout <= 120:
105
+ raise ReqloConfigurationError("timeout must be between 0 and 120 seconds.")
106
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or not 0 <= max_retries <= 5:
107
+ raise ReqloConfigurationError("max_retries must be between 0 and 5.")
108
+ self._api_key = api_key
109
+ self._base_url = _base_url(base_url)
110
+ self._timeout = float(timeout)
111
+ self._max_retries = max_retries
112
+ self._opener = build_opener(_NoRedirect())
113
+ self.approvals = _Approvals(self)
114
+ self.delegations = _Delegations(self)
115
+
116
+ def __repr__(self) -> str: # never print the key
117
+ return "Reqlo(base_url=%r)" % self._base_url
118
+
119
+ def authorize(self, *, agent: Dict[str, str], action: str, resource: Dict[str, Any], subject: Optional[Dict[str, str]] = None,
120
+ context: Optional[Dict[str, Any]] = None, delegation_id: Optional[str] = None, approval_id: Optional[str] = None) -> Dict[str, Any]:
121
+ """Returns the decision. Only act when decision["decision"] == "allow"."""
122
+ body: Dict[str, Any] = {"agent": agent, "action": action, "resource": resource}
123
+ for key, value in (("subject", subject), ("context", context), ("delegation_id", delegation_id), ("approval_id", approval_id)):
124
+ if value is not None:
125
+ body[key] = value
126
+ return decision(self._request("POST", "/authorize", body))
127
+
128
+ def enforce(self, **request: Any) -> Dict[str, Any]:
129
+ """Returns the decision only on allow. Raises ReqloDeniedError otherwise, and ReqloError on any failure."""
130
+ result = self.authorize(**request)
131
+ if result["decision"] != "allow":
132
+ raise ReqloDeniedError(result)
133
+ return result
134
+
135
+ def _request(self, method: str, path: str, body: Any = None, retry: bool = True) -> Any:
136
+ try:
137
+ data = None if body is None else json.dumps(body, allow_nan=False).encode("utf-8")
138
+ except (TypeError, ValueError):
139
+ raise ReqloConfigurationError("The request must be JSON serializable.") from None
140
+ headers = {"Authorization": "Bearer " + self._api_key, "Accept": "application/json", "User-Agent": "reqlo-python/" + VERSION}
141
+ if data is not None:
142
+ headers["Content-Type"] = "application/json"
143
+ attempt = 0
144
+ while True:
145
+ request = Request(self._base_url + path, data=data, method=method, headers=headers)
146
+ try:
147
+ with self._opener.open(request, timeout=self._timeout) as response:
148
+ raw = response.read()
149
+ try:
150
+ return json.loads(raw)
151
+ except ValueError:
152
+ raise ReqloResponseError() from None
153
+ except HTTPError as error:
154
+ status = error.code
155
+ try:
156
+ payload = json.loads(error.read())
157
+ except Exception:
158
+ payload = None
159
+ finally:
160
+ error.close()
161
+ err = payload.get("error") if isinstance(payload, dict) and isinstance(payload.get("error"), dict) else {}
162
+ code = err.get("code") if err.get("code") in _CODES else None
163
+ rid = err.get("request_id") or (error.headers.get("Reqlo-Request-Id") if error.headers else None)
164
+ rid = rid if isinstance(rid, str) and re.match(r"^[A-Za-z0-9_-]{1,128}$", rid) else None
165
+ delay = _retry_delay(error.headers, attempt) if retry and (status == 429 or 500 <= status <= 599) and attempt < self._max_retries else None
166
+ if delay is None:
167
+ raise ReqloAPIError(status, code, rid) from None
168
+ time.sleep(delay)
169
+ attempt += 1
170
+ except ReqloError:
171
+ raise
172
+ except (socket.timeout, TimeoutError):
173
+ raise ReqloTimeoutError() from None
174
+ except URLError as error:
175
+ if isinstance(error.reason, (socket.timeout, TimeoutError)):
176
+ raise ReqloTimeoutError() from None
177
+ raise ReqloNetworkError() from None
178
+ except OSError:
179
+ raise ReqloNetworkError() from None
180
+
181
+
182
+ class _AsyncApprovals:
183
+ def __init__(self, sync: _Approvals) -> None:
184
+ self._sync = sync
185
+
186
+ async def list(self, status: Optional[str] = None) -> Dict[str, Any]:
187
+ return await asyncio.to_thread(self._sync.list, status)
188
+
189
+ async def retrieve(self, approval_id: str) -> Dict[str, Any]:
190
+ return await asyncio.to_thread(self._sync.retrieve, approval_id)
191
+
192
+
193
+ class _AsyncDelegations:
194
+ def __init__(self, sync: _Delegations) -> None:
195
+ self._sync = sync
196
+
197
+ async def create(self, agent_id: str, subject: Dict[str, str], actions: List[str], expires_at: str) -> Dict[str, Any]:
198
+ return await asyncio.to_thread(self._sync.create, agent_id, subject, actions, expires_at)
199
+
200
+ async def list(self, agent_id: Optional[str] = None, subject_id: Optional[str] = None) -> List[Dict[str, Any]]:
201
+ return await asyncio.to_thread(self._sync.list, agent_id, subject_id)
202
+
203
+ async def retrieve(self, delegation_id: str) -> Dict[str, Any]:
204
+ return await asyncio.to_thread(self._sync.retrieve, delegation_id)
205
+
206
+ async def revoke(self, delegation_id: str) -> Dict[str, Any]:
207
+ return await asyncio.to_thread(self._sync.revoke, delegation_id)
208
+
209
+
210
+ class AsyncReqlo:
211
+ """The same client for asyncio (FastAPI, agent frameworks). Requests run in a worker thread."""
212
+
213
+ def __init__(self, api_key: str, base_url: str = "https://api.reqlo.io/v1", timeout: float = 3.0, max_retries: int = 2) -> None:
214
+ self._sync = Reqlo(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
215
+ self.approvals = _AsyncApprovals(self._sync.approvals)
216
+ self.delegations = _AsyncDelegations(self._sync.delegations)
217
+
218
+ def __repr__(self) -> str:
219
+ return "AsyncReqlo(base_url=%r)" % self._sync._base_url
220
+
221
+ async def authorize(self, **request: Any) -> Dict[str, Any]:
222
+ return await asyncio.to_thread(lambda: self._sync.authorize(**request))
223
+
224
+ async def enforce(self, **request: Any) -> Dict[str, Any]:
225
+ return await asyncio.to_thread(lambda: self._sync.enforce(**request))
reqlo/_errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """Every error means the protected action must stay stopped. None of them is ever an allow."""
2
+ from typing import Any, Dict, Optional
3
+
4
+
5
+ class ReqloError(Exception):
6
+ """Base class for every ReQlo error."""
7
+
8
+
9
+ class ReqloConfigurationError(ReqloError):
10
+ """Invalid client options or arguments. Fix your code or configuration."""
11
+
12
+
13
+ class ReqloAPIError(ReqloError):
14
+ """ReQlo returned an HTTP error. `code` is a stable error code when ReQlo sent a known one."""
15
+
16
+ def __init__(self, status: int, code: Optional[str], request_id: Optional[str]) -> None:
17
+ super().__init__("ReQlo returned HTTP %d. The protected action must remain stopped." % status)
18
+ self.status = status
19
+ self.code = code
20
+ self.request_id = request_id
21
+
22
+
23
+ class ReqloTimeoutError(ReqloError):
24
+ def __init__(self) -> None:
25
+ super().__init__("The ReQlo request timed out. The protected action must remain stopped.")
26
+
27
+
28
+ class ReqloNetworkError(ReqloError):
29
+ def __init__(self) -> None:
30
+ super().__init__("The ReQlo request could not be completed. The protected action must remain stopped.")
31
+
32
+
33
+ class ReqloResponseError(ReqloError):
34
+ def __init__(self) -> None:
35
+ super().__init__("ReQlo returned an invalid response. The protected action must remain stopped.")
36
+
37
+
38
+ class ReqloDeniedError(ReqloError):
39
+ """`enforce` got deny or approval_required. `decision` holds the full decision."""
40
+
41
+ def __init__(self, decision: Dict[str, Any]) -> None:
42
+ super().__init__("This action requires human approval." if decision.get("decision") == "approval_required" else "This action was denied.")
43
+ self.decision = decision
44
+
45
+
46
+ class ReqloSignatureError(ReqloError):
47
+ """A webhook's signature is missing, invalid or too old."""
reqlo/_validate.py ADDED
@@ -0,0 +1,85 @@
1
+ """Checks every response shape, so a malformed or unexpected response is an error, never an allow."""
2
+ import re
3
+ from datetime import datetime
4
+ from typing import Any, Dict, List
5
+
6
+ from ._errors import ReqloResponseError
7
+
8
+ _STATUSES = ("pending", "approved", "rejected", "expired", "consumed")
9
+
10
+
11
+ def _text(value: Any) -> bool:
12
+ return isinstance(value, str) and len(value) > 0
13
+
14
+
15
+ def _nullable_text(value: Any) -> bool:
16
+ return value is None or isinstance(value, str)
17
+
18
+
19
+ def _date(value: Any) -> bool:
20
+ """An ISO 8601 timestamp with date and time, as ReQlo returns (with or without fractions and offset)."""
21
+ if not isinstance(value, str) or not re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
22
+ return False
23
+ try:
24
+ datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
25
+ return True
26
+ except ValueError:
27
+ return False
28
+
29
+
30
+ def _approval_id(value: Any) -> bool:
31
+ return isinstance(value, str) and re.match(r"^APR_[A-Za-z0-9]{16}$", value) is not None
32
+
33
+
34
+ def decision(value: Any) -> Dict[str, Any]:
35
+ if not isinstance(value, dict) or not _text(value.get("id")) or value.get("object") != "decision" or not _text(value.get("reason_code")) \
36
+ or not isinstance(value.get("matched_rules"), list) or not all(_text(r) for r in value["matched_rules"]) \
37
+ or not _nullable_text(value.get("policy_version")) or not _text(value.get("environment")) \
38
+ or not isinstance(value.get("livemode"), bool) or not _date(value.get("created_at")) or not _text(value.get("request_id")):
39
+ raise ReqloResponseError()
40
+ if value.get("decision") == "approval_required":
41
+ a = value.get("approval")
42
+ if value.get("allowed") is not False or not isinstance(a, dict) or not _approval_id(a.get("id")) or a.get("status") != "pending" or not _date(a.get("expires_at")):
43
+ raise ReqloResponseError()
44
+ elif value.get("decision") not in ("allow", "deny") or value.get("allowed") is not (value["decision"] == "allow") or value.get("approval") is not None:
45
+ raise ReqloResponseError()
46
+ return value
47
+
48
+
49
+ def approval(value: Any) -> Dict[str, Any]:
50
+ summary = value.get("summary") if isinstance(value, dict) else None
51
+ if not isinstance(value, dict) or not _approval_id(value.get("id")) or value.get("object") != "approval" or value.get("status") not in _STATUSES \
52
+ or not _nullable_text(value.get("decision_id")) or not isinstance(value.get("request_digest"), str) \
53
+ or not re.match(r"^sha256:[a-f0-9]{64}$", value["request_digest"]) \
54
+ or not isinstance(summary, dict) or not all(summary.get(k) is None or _text(summary.get(k)) for k in ("agent_id", "subject_id", "action", "resource")) \
55
+ or not _nullable_text(value.get("channel_id")) or not _date(value.get("expires_at")) or not _nullable_text(value.get("resolved_by")) \
56
+ or not (value.get("resolved_at") is None or _date(value.get("resolved_at"))) or not _nullable_text(value.get("note")) \
57
+ or not _date(value.get("created_at")):
58
+ raise ReqloResponseError()
59
+ return value
60
+
61
+
62
+ def approval_list(value: Any) -> Dict[str, Any]:
63
+ if not isinstance(value, dict) or not isinstance(value.get("data"), list) or not _text(value.get("environment_id")):
64
+ raise ReqloResponseError()
65
+ return {"data": [approval(item) for item in value["data"]], "environment_id": value["environment_id"]}
66
+
67
+
68
+ def delegation(value: Any) -> Dict[str, Any]:
69
+ subject = value.get("subject") if isinstance(value, dict) else None
70
+ verification = value.get("verification") if isinstance(value, dict) else None
71
+ if not isinstance(value, dict) or not isinstance(value.get("id"), str) or not re.match(r"^DEL_[A-Za-z0-9]{16}$", value["id"]) \
72
+ or value.get("object") != "delegation" or not _text(value.get("agent_id")) \
73
+ or not isinstance(subject, dict) or not _text(subject.get("id")) or not _text(subject.get("kind")) \
74
+ or not isinstance(value.get("actions"), list) or not all(_text(a) for a in value["actions"]) \
75
+ or not isinstance(verification, dict) or verification.get("method") != "server_session" \
76
+ or value.get("status") not in ("active", "revoked", "expired") or not _date(value.get("expires_at")) \
77
+ or not _date(value.get("created_at")) or not (value.get("revoked_at") is None or _date(value.get("revoked_at"))):
78
+ raise ReqloResponseError()
79
+ return value
80
+
81
+
82
+ def delegation_list(value: Any) -> List[Dict[str, Any]]:
83
+ if not isinstance(value, dict) or not isinstance(value.get("data"), list):
84
+ raise ReqloResponseError()
85
+ return [delegation(item) for item in value["data"]]
reqlo/_version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "0.1.0"
reqlo/py.typed ADDED
File without changes
reqlo/webhooks.py ADDED
@@ -0,0 +1,39 @@
1
+ """Verify ReQlo webhook signatures. An event never authorizes an action; it only tells you to look."""
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import re
6
+ import time
7
+ from typing import Any, Dict, Optional, Union
8
+
9
+ from ._errors import ReqloSignatureError
10
+
11
+ TOLERANCE_SECONDS = 300
12
+ _HEADER = re.compile(r"^t=(\d{1,12}),v1=([a-f0-9]{64})$")
13
+ _SECRET = re.compile(r"^whsec_[a-f0-9]{64}$")
14
+
15
+
16
+ def verify(payload: Union[bytes, str], header: Optional[str], secret: str, tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> bool:
17
+ """True only if `header` (Reqlo-Signature) signs the exact raw body with `secret`, within the tolerance."""
18
+ match = _HEADER.match(header or "")
19
+ if not match or not isinstance(secret, str) or not _SECRET.match(secret):
20
+ return False
21
+ current = int(time.time()) if now is None else now
22
+ if abs(current - int(match.group(1))) > tolerance:
23
+ return False
24
+ body = payload.encode("utf-8") if isinstance(payload, str) else payload
25
+ expected = hmac.new(secret.encode("utf-8"), match.group(1).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
26
+ return hmac.compare_digest(expected, match.group(2))
27
+
28
+
29
+ def construct_event(payload: Union[bytes, str], header: Optional[str], secret: str, tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> Dict[str, Any]:
30
+ """Verifies the signature and returns the parsed event. Raises ReqloSignatureError if it isn't valid."""
31
+ if not verify(payload, header, secret, tolerance, now):
32
+ raise ReqloSignatureError("The webhook signature is missing, invalid or too old.")
33
+ try:
34
+ event = json.loads(payload)
35
+ except ValueError:
36
+ raise ReqloSignatureError("The webhook body isn't valid JSON.") from None
37
+ if not isinstance(event, dict) or event.get("object") != "event" or not isinstance(event.get("type"), str):
38
+ raise ReqloSignatureError("The webhook body isn't a ReQlo event.")
39
+ return event
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: reqlo-sdk
3
+ Version: 0.1.0
4
+ Summary: Server-side Python client for ReQlo: authorization for AI agents.
5
+ Project-URL: Homepage, https://reqlo.io
6
+ Project-URL: Documentation, https://reqlo-web.vercel.app/docs/sdks/python
7
+ Author: ReQlo
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ReQlo Python SDK
17
+
18
+ Server-side client for [ReQlo](https://reqlo.io), authorization for AI agents (`pip install reqlo-sdk`, `import reqlo`). Python 3.10+, standard library only,
19
+ sync and asyncio. Keep your project API key on your server.
20
+
21
+ ```bash
22
+ pip install reqlo-sdk
23
+ ```
24
+
25
+ ```python
26
+ import os
27
+ from reqlo import Reqlo, ReqloDeniedError
28
+
29
+ reqlo = Reqlo(api_key=os.environ["REQLO_API_KEY"])
30
+
31
+ try:
32
+ reqlo.enforce(
33
+ agent={"id": "agt_7Hq2mK9xR4vTq8Lp"},
34
+ subject={"id": session.user_id, "kind": "user"}, # from your verified session
35
+ action="documents.read",
36
+ resource={"type": "document", "id": doc.id, "attributes": {"owner_id": doc.owner_id}},
37
+ context={"document_classification": doc.classification},
38
+ )
39
+ except ReqloDeniedError as denied:
40
+ return {"error": "Not allowed", "reason": denied.decision["reason_code"]}
41
+ # Any other ReqloError (timeout, network, HTTP error) propagates: the action does not run.
42
+ ```
43
+
44
+ - `authorize(...)` returns the decision dict; act only when `decision["decision"] == "allow"`.
45
+ - `enforce(...)` returns only on allow and raises `ReqloDeniedError` on deny or `approval_required`.
46
+ - `AsyncReqlo` has the same methods for asyncio.
47
+ - `approvals.list(status=)`, `approvals.retrieve(id)`.
48
+ - `delegations.create(agent_id, subject, actions, expires_at)`, `list`, `retrieve`, `revoke`. Create is never retried.
49
+ - `reqlo.webhooks.verify(body, signature_header, secret)` and `construct_event(...)` check webhook signatures.
50
+
51
+ Retries `429` and `5xx` twice with backoff (3-second timeout). Timeouts and network errors are never retried and
52
+ never mean allow. Documentation: <https://reqlo-web.vercel.app/docs/sdks/python>
@@ -0,0 +1,11 @@
1
+ reqlo/__init__.py,sha256=Y65zLVIZdfDIsjBCohDjYZ9xB1K07B2hDbvntGp6UGc,649
2
+ reqlo/_client.py,sha256=IzquieZ118vyMLSbyj3FVXedlwk54GFGnDn3nXlzg94,12063
3
+ reqlo/_errors.py,sha256=KTgeU4ax_OI7YtuQwJ5JBOykHNb9omtGksUtr-GzNes,1755
4
+ reqlo/_validate.py,sha256=8LWU8zIy0MUib8a4bnHOZHToN9SKKCK0ocsdh7sDohs,4676
5
+ reqlo/_version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
6
+ reqlo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ reqlo/webhooks.py,sha256=tqSEhnOU_BirGnL-Z2lzScMj9eYsSwNP056_Ebg2Kxo,1949
8
+ reqlo_sdk-0.1.0.dist-info/METADATA,sha256=lSzr2_8nD5xJRehM6lepoVNfCL8sCMrxOtgpe4UzpaE,2170
9
+ reqlo_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ reqlo_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=NpWA6wHpNld2N1vVzJ0uPeVKqhV3F7E-iEmCqm_-HaI,1062
11
+ reqlo_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ReQlo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.