toolbound 0.2.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.
- toolbound/__init__.py +123 -0
- toolbound/adapters/__init__.py +9 -0
- toolbound/adapters/_common.py +84 -0
- toolbound/adapters/anthropic.py +81 -0
- toolbound/adapters/openai.py +115 -0
- toolbound/cli.py +69 -0
- toolbound/core/__init__.py +4 -0
- toolbound/core/approval.py +314 -0
- toolbound/core/budget.py +42 -0
- toolbound/core/context.py +56 -0
- toolbound/core/contracts.py +689 -0
- toolbound/core/credentials.py +41 -0
- toolbound/core/errors.py +117 -0
- toolbound/core/exposure.py +68 -0
- toolbound/core/gateway.py +1461 -0
- toolbound/core/network.py +267 -0
- toolbound/core/observe.py +60 -0
- toolbound/core/paths.py +138 -0
- toolbound/core/ports.py +166 -0
- toolbound/core/pyschema.py +119 -0
- toolbound/core/ratelimit.py +65 -0
- toolbound/core/result.py +100 -0
- toolbound/core/schema.py +201 -0
- toolbound/core/shaping.py +711 -0
- toolbound/core/signing.py +137 -0
- toolbound/core/state.py +102 -0
- toolbound/estimators.py +24 -0
- toolbound/loaders/__init__.py +202 -0
- toolbound/loaders/meta_schema.py +162 -0
- toolbound/py.typed +0 -0
- toolbound/sync.py +76 -0
- toolbound/transport/__init__.py +19 -0
- toolbound/transport/http.py +269 -0
- toolbound/transport/mock.py +89 -0
- toolbound-0.2.0.dist-info/METADATA +547 -0
- toolbound-0.2.0.dist-info/RECORD +39 -0
- toolbound-0.2.0.dist-info/WHEEL +4 -0
- toolbound-0.2.0.dist-info/entry_points.txt +2 -0
- toolbound-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Human approval bound to one exact call.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
|
|
5
|
+
1. A call that needs approval returns ``approval_required`` with a
|
|
6
|
+
:class:`PendingApproval`. Its :attr:`~PendingApproval.token` is an
|
|
7
|
+
**encrypted, authenticated approval request** issued by the gateway. It
|
|
8
|
+
carries the tool, the validated arguments and a digest of the trusted bound
|
|
9
|
+
values, actor and tenant. Store or round-trip the token freely (database,
|
|
10
|
+
browser, LangGraph interrupt): it cannot be altered or forged, and its
|
|
11
|
+
contents cannot be read without the gateway secret.
|
|
12
|
+
2. Host code shows the request to a human (:meth:`Gateway.pending_approval`
|
|
13
|
+
rebuilds the display data from a token) and, on approval, calls
|
|
14
|
+
:meth:`Gateway.grant` or :meth:`Gateway.resume` **with the token**. The
|
|
15
|
+
fields of a ``PendingApproval`` object are never trusted, only its token.
|
|
16
|
+
3. The grant is an HMAC-signed, single-use token committing to the request id
|
|
17
|
+
and digest. :meth:`Gateway.call` verifies it, recomputes the digest from the
|
|
18
|
+
live call and the live :class:`ExecutionContext`, and claims the request id
|
|
19
|
+
in the :class:`~toolbound.core.ports.StateStore` so it runs at most once.
|
|
20
|
+
|
|
21
|
+
Grants are passed to :meth:`Gateway.call` as a keyword argument by host code,
|
|
22
|
+
never through tool arguments, so a model cannot approve its own actions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import secrets
|
|
29
|
+
import time
|
|
30
|
+
import unicodedata
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .context import ExecutionContext
|
|
36
|
+
from .contracts import Effect
|
|
37
|
+
from .errors import ApprovalInvalid
|
|
38
|
+
from .ports import StateStore
|
|
39
|
+
from .signing import Signer, canonical_digest
|
|
40
|
+
from .state import claim_or_fail
|
|
41
|
+
|
|
42
|
+
_GRANT_PREFIX = "tba2"
|
|
43
|
+
_REQUEST_PREFIX = "tbr1"
|
|
44
|
+
_MAX_REQUEST_TOKEN = 512 * 1024
|
|
45
|
+
_SUMMARY_LIMIT = 2000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class PendingApproval:
|
|
50
|
+
"""A call waiting for a human decision.
|
|
51
|
+
|
|
52
|
+
Show the human :attr:`arguments`, :attr:`bound` and :attr:`target` (or at
|
|
53
|
+
least :attr:`summary`, checking :attr:`summary_truncated`). Persist or send
|
|
54
|
+
:attr:`token`; :meth:`to_dict` is JSON-safe and includes it.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
tool: str
|
|
59
|
+
effect: Effect
|
|
60
|
+
actor: str
|
|
61
|
+
tenant: str | None
|
|
62
|
+
arguments: Mapping[str, Any]
|
|
63
|
+
bound: Mapping[str, Any]
|
|
64
|
+
summary: str
|
|
65
|
+
digest: str
|
|
66
|
+
expires_at: float
|
|
67
|
+
token: str = ""
|
|
68
|
+
target: Mapping[str, Any] = field(default_factory=dict)
|
|
69
|
+
summary_truncated: bool = False
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"id": self.id,
|
|
74
|
+
"tool": self.tool,
|
|
75
|
+
"effect": self.effect.value,
|
|
76
|
+
"actor": self.actor,
|
|
77
|
+
"tenant": self.tenant,
|
|
78
|
+
"target": dict(self.target),
|
|
79
|
+
"arguments": dict(self.arguments),
|
|
80
|
+
"bound": dict(self.bound),
|
|
81
|
+
"summary": self.summary,
|
|
82
|
+
"summary_truncated": self.summary_truncated,
|
|
83
|
+
"expires_at": self.expires_at,
|
|
84
|
+
"token": self.token,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def approval_digest(
|
|
89
|
+
*,
|
|
90
|
+
tool: str,
|
|
91
|
+
fingerprint: str,
|
|
92
|
+
ctx: ExecutionContext,
|
|
93
|
+
arguments: Mapping[str, Any],
|
|
94
|
+
bound: Mapping[str, Any],
|
|
95
|
+
) -> str:
|
|
96
|
+
return canonical_digest(
|
|
97
|
+
{
|
|
98
|
+
"tool": tool,
|
|
99
|
+
"contract": fingerprint,
|
|
100
|
+
"actor": ctx.actor,
|
|
101
|
+
"tenant": ctx.tenant,
|
|
102
|
+
"arguments": dict(arguments),
|
|
103
|
+
"bound": dict(bound),
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _display(value: Any) -> str:
|
|
109
|
+
"""JSON for humans: control, format and bidi characters are escaped."""
|
|
110
|
+
text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
111
|
+
return "".join(
|
|
112
|
+
f"\\u{ord(c):04x}" if unicodedata.category(c) in ("Cc", "Cf", "Zl", "Zp") else c
|
|
113
|
+
for c in text
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summary(
|
|
118
|
+
tool: str,
|
|
119
|
+
effect: Effect,
|
|
120
|
+
target: Mapping[str, Any],
|
|
121
|
+
ctx: ExecutionContext,
|
|
122
|
+
arguments: Mapping[str, Any],
|
|
123
|
+
bound: Mapping[str, Any],
|
|
124
|
+
) -> tuple[str, bool]:
|
|
125
|
+
if target.get("kind") == "rest":
|
|
126
|
+
where = f"{target.get('method')} {target.get('url')}"
|
|
127
|
+
else:
|
|
128
|
+
where = f"Python tool {tool}"
|
|
129
|
+
who = f"actor {_display(ctx.actor)}" + (
|
|
130
|
+
f", tenant {_display(ctx.tenant)}" if ctx.tenant is not None else ""
|
|
131
|
+
)
|
|
132
|
+
text = (
|
|
133
|
+
f"[{effect.value.upper()}] {where} (tool {tool}) for {who}. "
|
|
134
|
+
f"Arguments: {_display(dict(arguments))}. Trusted values: {_display(dict(bound))}."
|
|
135
|
+
)
|
|
136
|
+
if len(text) <= _SUMMARY_LIMIT:
|
|
137
|
+
return text, False
|
|
138
|
+
hidden = len(text) - _SUMMARY_LIMIT
|
|
139
|
+
return (
|
|
140
|
+
text[:_SUMMARY_LIMIT]
|
|
141
|
+
+ f" … [{hidden} more characters not shown: review the full arguments before approving]",
|
|
142
|
+
True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ApprovalManager:
|
|
147
|
+
"""Issues approval requests and grants, and verifies grants exactly once."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, signer: Signer, store: StateStore, *, ttl_seconds: float = 600) -> None:
|
|
150
|
+
if not 0 < ttl_seconds <= MAX_APPROVAL_TTL:
|
|
151
|
+
raise ValueError(f"approval_ttl must be between 0 and {MAX_APPROVAL_TTL} seconds")
|
|
152
|
+
self._signer = signer
|
|
153
|
+
self._store = store
|
|
154
|
+
self.ttl_seconds = ttl_seconds
|
|
155
|
+
|
|
156
|
+
def create(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
ctx: ExecutionContext,
|
|
160
|
+
tool: str,
|
|
161
|
+
effect: Effect,
|
|
162
|
+
fingerprint: str,
|
|
163
|
+
arguments: Mapping[str, Any],
|
|
164
|
+
bound: Mapping[str, Any],
|
|
165
|
+
target: Mapping[str, Any] | None = None,
|
|
166
|
+
) -> PendingApproval:
|
|
167
|
+
digest = approval_digest(
|
|
168
|
+
tool=tool, fingerprint=fingerprint, ctx=ctx, arguments=arguments, bound=bound
|
|
169
|
+
)
|
|
170
|
+
request_id = secrets.token_urlsafe(16)
|
|
171
|
+
expires_at = time.time() + self.ttl_seconds
|
|
172
|
+
target_map = dict(target or {})
|
|
173
|
+
summary, truncated = _summary(tool, effect, target_map, ctx, arguments, bound)
|
|
174
|
+
token = self._signer.seal(
|
|
175
|
+
_REQUEST_PREFIX,
|
|
176
|
+
{
|
|
177
|
+
"i": request_id,
|
|
178
|
+
"d": digest,
|
|
179
|
+
"t": tool,
|
|
180
|
+
"e": effect.value,
|
|
181
|
+
"a": dict(arguments),
|
|
182
|
+
"b": dict(bound),
|
|
183
|
+
"u": ctx.actor,
|
|
184
|
+
"n": ctx.tenant,
|
|
185
|
+
"g": target_map,
|
|
186
|
+
"x": expires_at,
|
|
187
|
+
},
|
|
188
|
+
self.ttl_seconds,
|
|
189
|
+
)
|
|
190
|
+
return PendingApproval(
|
|
191
|
+
id=request_id,
|
|
192
|
+
tool=tool,
|
|
193
|
+
effect=effect,
|
|
194
|
+
actor=ctx.actor,
|
|
195
|
+
tenant=ctx.tenant,
|
|
196
|
+
arguments=dict(arguments),
|
|
197
|
+
bound=dict(bound),
|
|
198
|
+
summary=summary,
|
|
199
|
+
digest=digest,
|
|
200
|
+
expires_at=expires_at,
|
|
201
|
+
token=token,
|
|
202
|
+
target=target_map,
|
|
203
|
+
summary_truncated=truncated,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def open(self, request: PendingApproval | str) -> PendingApproval:
|
|
207
|
+
"""Rebuild a :class:`PendingApproval` from its token. Raises :class:`ApprovalInvalid`."""
|
|
208
|
+
token = request.token if isinstance(request, PendingApproval) else request
|
|
209
|
+
payload = self._signer.unseal(_REQUEST_PREFIX, token, max_length=_MAX_REQUEST_TOKEN)
|
|
210
|
+
if payload is None:
|
|
211
|
+
raise ApprovalInvalid("approval request is invalid, forged or expired")
|
|
212
|
+
try:
|
|
213
|
+
effect = Effect(payload["e"])
|
|
214
|
+
arguments = dict(payload["a"])
|
|
215
|
+
bound = dict(payload["b"])
|
|
216
|
+
target = dict(payload["g"])
|
|
217
|
+
tool, actor, tenant = str(payload["t"]), str(payload["u"]), payload["n"]
|
|
218
|
+
request_id, digest = str(payload["i"]), str(payload["d"])
|
|
219
|
+
expires_at = float(payload["x"])
|
|
220
|
+
except (KeyError, TypeError, ValueError):
|
|
221
|
+
raise ApprovalInvalid("approval request is malformed") from None
|
|
222
|
+
ctx = ExecutionContext(actor=actor, tenant=tenant)
|
|
223
|
+
summary, truncated = _summary(tool, effect, target, ctx, arguments, bound)
|
|
224
|
+
return PendingApproval(
|
|
225
|
+
id=request_id,
|
|
226
|
+
tool=tool,
|
|
227
|
+
effect=effect,
|
|
228
|
+
actor=actor,
|
|
229
|
+
tenant=tenant,
|
|
230
|
+
arguments=arguments,
|
|
231
|
+
bound=bound,
|
|
232
|
+
summary=summary,
|
|
233
|
+
digest=digest,
|
|
234
|
+
expires_at=expires_at,
|
|
235
|
+
token=str(token),
|
|
236
|
+
target=target,
|
|
237
|
+
summary_truncated=truncated,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def grant(self, request: PendingApproval | str, *, approved_by: str) -> str:
|
|
241
|
+
"""Issue a single-use grant for a gateway-issued request. Host code only."""
|
|
242
|
+
if not approved_by or not isinstance(approved_by, str):
|
|
243
|
+
raise ValueError("approved_by is required for the audit trail")
|
|
244
|
+
pending = self.open(request) # verifies the token; object fields are ignored
|
|
245
|
+
remaining = pending.expires_at - time.time()
|
|
246
|
+
if remaining <= 0:
|
|
247
|
+
raise ApprovalInvalid("approval request has expired")
|
|
248
|
+
return self._signer.sign(
|
|
249
|
+
_GRANT_PREFIX, {"i": pending.id, "d": pending.digest, "by": approved_by}, remaining
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def verify(
|
|
253
|
+
self,
|
|
254
|
+
token: str,
|
|
255
|
+
*,
|
|
256
|
+
ctx: ExecutionContext,
|
|
257
|
+
tool: str,
|
|
258
|
+
fingerprint: str,
|
|
259
|
+
arguments: Mapping[str, Any],
|
|
260
|
+
bound: Mapping[str, Any],
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""Check a grant against the live call without using it up."""
|
|
263
|
+
payload = self._signer.verify(_GRANT_PREFIX, token)
|
|
264
|
+
if payload is None:
|
|
265
|
+
raise ApprovalInvalid("approval is invalid or has expired")
|
|
266
|
+
expected = approval_digest(
|
|
267
|
+
tool=tool, fingerprint=fingerprint, ctx=ctx, arguments=arguments, bound=bound
|
|
268
|
+
)
|
|
269
|
+
if payload.get("d") != expected:
|
|
270
|
+
raise ApprovalInvalid(
|
|
271
|
+
"approval does not match this call (tool, arguments, actor, tenant or "
|
|
272
|
+
"trusted values changed)"
|
|
273
|
+
)
|
|
274
|
+
return payload
|
|
275
|
+
|
|
276
|
+
async def claim(self, payload: Mapping[str, Any], ctx: ExecutionContext) -> None:
|
|
277
|
+
"""Use a verified grant (once). Called immediately before execution."""
|
|
278
|
+
if not await claim_or_fail(self._store, _claim_key(ctx, str(payload["i"])), _CLAIM_TTL):
|
|
279
|
+
raise ApprovalInvalid("approval has already been used or was rejected")
|
|
280
|
+
|
|
281
|
+
async def deny(self, request: PendingApproval | str) -> PendingApproval:
|
|
282
|
+
"""Record a refusal: the request can never be granted afterwards."""
|
|
283
|
+
pending = self.open(request)
|
|
284
|
+
ctx = ExecutionContext(actor=pending.actor, tenant=pending.tenant)
|
|
285
|
+
await claim_or_fail(self._store, _claim_key(ctx, pending.id), _CLAIM_TTL)
|
|
286
|
+
return pending
|
|
287
|
+
|
|
288
|
+
async def consume(
|
|
289
|
+
self,
|
|
290
|
+
token: str,
|
|
291
|
+
*,
|
|
292
|
+
ctx: ExecutionContext,
|
|
293
|
+
tool: str,
|
|
294
|
+
fingerprint: str,
|
|
295
|
+
arguments: Mapping[str, Any],
|
|
296
|
+
bound: Mapping[str, Any],
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
"""Verify and claim in one step."""
|
|
299
|
+
payload = self.verify(
|
|
300
|
+
token, ctx=ctx, tool=tool, fingerprint=fingerprint, arguments=arguments, bound=bound
|
|
301
|
+
)
|
|
302
|
+
await self.claim(payload, ctx)
|
|
303
|
+
return payload
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
#: Claims must outlive any grant. Grants never live longer than the request TTL, which
|
|
307
|
+
#: is capped at ``MAX_APPROVAL_TTL``, so a fixed claim TTL is safe even when workers
|
|
308
|
+
#: are configured with different ``approval_ttl`` values.
|
|
309
|
+
MAX_APPROVAL_TTL = 7 * 24 * 3600
|
|
310
|
+
_CLAIM_TTL = MAX_APPROVAL_TTL + 3600
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _claim_key(ctx: ExecutionContext, request_id: str) -> str:
|
|
314
|
+
return f"approval:{canonical_digest([ctx.actor, ctx.tenant])[:16]}:{request_id}"
|
toolbound/core/budget.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Token estimation.
|
|
2
|
+
|
|
3
|
+
The default estimator is deterministic and dependency-free. It is an
|
|
4
|
+
*approximation*, deliberately conservative (it tends to over-count) so budgets
|
|
5
|
+
are rarely exceeded. Plug in a real tokenizer through the
|
|
6
|
+
:class:`~toolbound.core.ports.TokenEstimator` port when exact counts matter
|
|
7
|
+
(see :class:`toolbound.estimators.TiktokenEstimator`).
|
|
8
|
+
|
|
9
|
+
Measured accuracy against a real tokenizer is published in ``docs/benchmarks.md``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ByteEstimator:
|
|
20
|
+
"""Estimate tokens as ``ceil(utf8_bytes / bytes_per_token)``.
|
|
21
|
+
|
|
22
|
+
The default of 3.0 bytes per token over-estimates typical English JSON
|
|
23
|
+
(closer to ~3.5-4.5 bytes/token for modern tokenizers) and stays
|
|
24
|
+
conservative for non-Latin scripts such as Persian or Arabic, which use 2
|
|
25
|
+
bytes per character in UTF-8 and often tokenize less efficiently.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, bytes_per_token: float = 3.0) -> None:
|
|
29
|
+
if bytes_per_token <= 0:
|
|
30
|
+
raise ValueError("bytes_per_token must be positive")
|
|
31
|
+
self.bytes_per_token = bytes_per_token
|
|
32
|
+
|
|
33
|
+
def count(self, text: str) -> int:
|
|
34
|
+
return math.ceil(len(text.encode("utf-8")) / self.bytes_per_token)
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"ByteEstimator(bytes_per_token={self.bytes_per_token})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_json(value: Any) -> str:
|
|
41
|
+
"""Compact, deterministic JSON used for both budgeting and model output."""
|
|
42
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Trusted execution context.
|
|
2
|
+
|
|
3
|
+
The :class:`ExecutionContext` is created by **your application** after it has
|
|
4
|
+
authenticated the end user. It is never built from model output. Everything
|
|
5
|
+
security-relevant (who is acting, for which tenant, with which scopes) comes from
|
|
6
|
+
here, not from tool arguments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _new_request_id() -> str:
|
|
19
|
+
return uuid.uuid4().hex
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class ExecutionContext:
|
|
24
|
+
"""Identity and environment for one agent turn, supplied by trusted code.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
actor: Stable identifier of the authenticated user or service account.
|
|
28
|
+
tenant: Optional tenant / organization identifier for multi-tenant apps.
|
|
29
|
+
scopes: Permissions granted to the actor (checked against
|
|
30
|
+
``Tool.required_scopes``).
|
|
31
|
+
attributes: Extra trusted values that contracts may bind into requests
|
|
32
|
+
(for example ``{"repo_owner": "acme"}``). Never put secrets here;
|
|
33
|
+
use a :class:`~toolbound.CredentialProvider` instead.
|
|
34
|
+
request_id: Correlation id for logs and approvals. Generated if omitted.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
actor: str
|
|
38
|
+
tenant: str | None = None
|
|
39
|
+
scopes: frozenset[str] = frozenset()
|
|
40
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
41
|
+
request_id: str = field(default_factory=_new_request_id)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not isinstance(self.actor, str) or not self.actor:
|
|
45
|
+
raise ValueError("ExecutionContext.actor must be a non-empty string")
|
|
46
|
+
object.__setattr__(self, "scopes", frozenset(self.scopes))
|
|
47
|
+
object.__setattr__(self, "attributes", MappingProxyType(dict(self.attributes)))
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
# Attribute values are deliberately not printed: they may be sensitive.
|
|
51
|
+
keys = ", ".join(sorted(self.attributes))
|
|
52
|
+
return (
|
|
53
|
+
f"ExecutionContext(actor={self.actor!r}, tenant={self.tenant!r}, "
|
|
54
|
+
f"scopes={sorted(self.scopes)!r}, attributes=[{keys}], "
|
|
55
|
+
f"request_id={self.request_id!r})"
|
|
56
|
+
)
|