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.
@@ -0,0 +1,689 @@
1
+ """Contract types: the developer-controlled definition of what a tool may do.
2
+
3
+ Everything in this module is **trusted configuration**. The model never sees
4
+ URLs, credentials, bound parameters, network policy, or limits. It only sees a
5
+ tool's name, description, and input schema.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from enum import StrEnum
14
+ from typing import Any, Literal
15
+ from urllib.parse import urlsplit
16
+
17
+ from .context import ExecutionContext
18
+ from .errors import ConfigurationError
19
+ from .paths import MISSING
20
+
21
+ _TOOL_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
22
+ _HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
23
+ _CREDENTIAL_NAME = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
24
+ _FORBIDDEN_STATIC_HEADERS = frozenset(
25
+ {
26
+ "host",
27
+ "content-length",
28
+ "transfer-encoding",
29
+ "connection",
30
+ "keep-alive",
31
+ "upgrade",
32
+ "te",
33
+ "trailer",
34
+ "proxy-authorization",
35
+ "proxy-connection",
36
+ "authorization",
37
+ "cookie",
38
+ }
39
+ )
40
+ _MAX_DESCRIPTION = 1024
41
+
42
+
43
+ class Effect(StrEnum):
44
+ """What a tool does to the outside world. Drives approval and retry rules."""
45
+
46
+ READ = "read"
47
+ WRITE = "write"
48
+ DESTRUCTIVE = "destructive"
49
+
50
+
51
+ # --------------------------------------------------------------------------- bindings
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class FromContext:
56
+ """Bind a request parameter to a value from the trusted :class:`ExecutionContext`.
57
+
58
+ ``path`` is one of ``"actor"``, ``"tenant"``, ``"request_id"`` or
59
+ ``"attributes.<key>"``.
60
+ """
61
+
62
+ path: str
63
+
64
+ def __post_init__(self) -> None:
65
+ if self.path in {"actor", "tenant", "request_id"}:
66
+ return
67
+ if self.path.startswith("attributes.") and len(self.path) > len("attributes."):
68
+ return
69
+ raise ConfigurationError(
70
+ f"invalid context binding {self.path!r}: use 'actor', 'tenant', "
71
+ "'request_id' or 'attributes.<key>'"
72
+ )
73
+
74
+ def resolve(self, ctx: ExecutionContext) -> Any:
75
+ if self.path == "actor":
76
+ return ctx.actor
77
+ if self.path == "tenant":
78
+ if ctx.tenant is None:
79
+ raise LookupError("tenant")
80
+ return ctx.tenant
81
+ if self.path == "request_id":
82
+ return ctx.request_id
83
+ key = self.path.removeprefix("attributes.")
84
+ if key not in ctx.attributes:
85
+ raise LookupError(key)
86
+ return ctx.attributes[key]
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Const:
91
+ """Bind a request parameter to a fixed value."""
92
+
93
+ value: Any
94
+
95
+
96
+ Binding = FromContext | Const | Callable[[ExecutionContext], Any]
97
+
98
+
99
+ def from_ctx(path: str) -> FromContext:
100
+ """Shorthand for :class:`FromContext`: ``from_ctx("attributes.repo_owner")``."""
101
+ return FromContext(path)
102
+
103
+
104
+ def parse_binding(value: Any) -> Binding:
105
+ """Parse a declarative binding (``"ctx.actor"`` or ``{"const": 1}``)."""
106
+ if isinstance(value, FromContext | Const) or callable(value):
107
+ return value # type: ignore[no-any-return]
108
+ if isinstance(value, str) and value.startswith("ctx."):
109
+ return FromContext(value.removeprefix("ctx."))
110
+ if isinstance(value, Mapping) and set(value) == {"const"}:
111
+ return Const(value["const"])
112
+ raise ConfigurationError(
113
+ f"invalid binding {value!r}: use 'ctx.actor', 'ctx.tenant', "
114
+ "'ctx.attributes.<key>' or {const: <value>}"
115
+ )
116
+
117
+
118
+ def resolve_binding(binding: Binding, ctx: ExecutionContext) -> Any:
119
+ if isinstance(binding, FromContext):
120
+ return binding.resolve(ctx)
121
+ if isinstance(binding, Const):
122
+ return binding.value
123
+ return binding(ctx)
124
+
125
+
126
+ # --------------------------------------------------------------------------- auth
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class NoAuth:
131
+ """No credentials are attached."""
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class BearerAuth:
136
+ """``Authorization: Bearer <credential>``. ``credential`` is a *name*, not a value."""
137
+
138
+ credential: str
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class ApiKeyAuth:
143
+ """An API key sent in a header (default ``X-API-Key``) or a query parameter.
144
+
145
+ Prefer headers: query-string keys are more likely to end up in upstream logs.
146
+ """
147
+
148
+ credential: str
149
+ header: str | None = "X-API-Key"
150
+ query: str | None = None
151
+
152
+
153
+ @dataclass(frozen=True, slots=True)
154
+ class BasicAuth:
155
+ """HTTP Basic auth. The credential value must be ``"username:password"``."""
156
+
157
+ credential: str
158
+
159
+
160
+ Auth = NoAuth | BearerAuth | ApiKeyAuth | BasicAuth
161
+
162
+
163
+ def _validate_auth(auth: Auth) -> None:
164
+ if isinstance(auth, NoAuth):
165
+ return
166
+ if not isinstance(auth, BearerAuth | ApiKeyAuth | BasicAuth):
167
+ raise ConfigurationError(f"unsupported auth type {type(auth).__name__}")
168
+ if not _CREDENTIAL_NAME.match(auth.credential):
169
+ raise ConfigurationError(
170
+ "auth.credential must be a credential *name* (letters, digits, _.:-), "
171
+ "never the secret value itself"
172
+ )
173
+ if isinstance(auth, ApiKeyAuth):
174
+ if (auth.header is None) == (auth.query is None):
175
+ raise ConfigurationError("ApiKeyAuth needs exactly one of header= or query=")
176
+ if auth.header is not None:
177
+ _validate_header_name(auth.header, allow_reserved=False)
178
+
179
+
180
+ # --------------------------------------------------------------------------- policies
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class NetworkPolicy:
185
+ """Where requests for a service may go.
186
+
187
+ Args:
188
+ allow_hosts: Exact hostnames (or ``*.example.com`` for subdomains).
189
+ Defaults to the host of ``Service.base_url``.
190
+ allow_ports: Allowed ports. Defaults to 443 (and 80 when ``allow_http``).
191
+ allow_http: Permit plain-HTTP targets. Off by default.
192
+ allow_networks: CIDR ranges that are trusted even though they are
193
+ private, loopback, or link-local (for internal services). Leave empty
194
+ for internet APIs: non-public addresses are then always blocked.
195
+ follow_redirects: Follow 3xx responses. Each hop is re-checked against
196
+ this policy, and credentials are dropped on a change of origin.
197
+ max_redirects: Maximum hops when ``follow_redirects`` is on.
198
+ """
199
+
200
+ allow_hosts: tuple[str, ...] = ()
201
+ allow_ports: tuple[int, ...] = ()
202
+ allow_http: bool = False
203
+ allow_networks: tuple[str, ...] = ()
204
+ follow_redirects: bool = False
205
+ max_redirects: int = 3
206
+
207
+
208
+ @dataclass(frozen=True, slots=True)
209
+ class Limits:
210
+ """Size and time limits for one upstream service."""
211
+
212
+ timeout: float = 15.0
213
+ connect_timeout: float = 5.0
214
+ max_request_bytes: int = 256 * 1024
215
+ max_response_bytes: int = 5 * 1024 * 1024
216
+ max_concurrency: int = 8
217
+ #: Deadline for one tool call including every retry, backoff and wait for a
218
+ #: concurrency slot. ``timeout`` bounds a single attempt.
219
+ total_timeout: float = 60.0
220
+
221
+ def __post_init__(self) -> None:
222
+ if self.timeout <= 0 or self.connect_timeout <= 0 or self.total_timeout <= 0:
223
+ raise ConfigurationError("timeouts must be positive")
224
+ if self.total_timeout < self.timeout:
225
+ raise ConfigurationError("total_timeout must be at least timeout")
226
+ if self.max_request_bytes <= 0 or self.max_response_bytes <= 0:
227
+ raise ConfigurationError("byte limits must be positive")
228
+ if self.max_concurrency < 1:
229
+ raise ConfigurationError("max_concurrency must be at least 1")
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class RetryPolicy:
234
+ """Bounded retries with exponential backoff and full jitter.
235
+
236
+ Retries only happen for safe methods (GET, HEAD, OPTIONS) or for operations
237
+ that declare an ``idempotency_header``. Non-idempotent calls are never
238
+ repeated automatically.
239
+ """
240
+
241
+ max_attempts: int = 3
242
+ backoff_base: float = 0.25
243
+ backoff_max: float = 4.0
244
+ retry_statuses: frozenset[int] = frozenset({429, 502, 503, 504})
245
+ max_retry_after: float = 30.0
246
+
247
+ def __post_init__(self) -> None:
248
+ if self.max_attempts < 1:
249
+ raise ConfigurationError("max_attempts must be at least 1")
250
+ if self.backoff_base < 0 or self.backoff_max < 0 or self.max_retry_after < 0:
251
+ raise ConfigurationError("backoff values must be non-negative")
252
+
253
+
254
+ @dataclass(frozen=True, slots=True)
255
+ class RateLimit:
256
+ """At most ``calls`` executions per ``per_seconds`` window, per ``scope``."""
257
+
258
+ calls: int
259
+ per_seconds: float
260
+ scope: Literal["actor", "tenant", "global"] = "actor"
261
+
262
+ def __post_init__(self) -> None:
263
+ if self.calls < 1 or self.per_seconds <= 0:
264
+ raise ConfigurationError("rate limit needs calls >= 1 and per_seconds > 0")
265
+
266
+
267
+ # --------------------------------------------------------------------------- output
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class Field:
272
+ """A projected output field with optional limits and transforms.
273
+
274
+ A plain string in a shape (``"author": "user.login"``) is shorthand for
275
+ ``Field("user.login")``.
276
+
277
+ Args:
278
+ source: Path into the upstream value (see :mod:`toolbound.core.paths`).
279
+ max_chars: Per-field string limit, overriding ``Output.max_chars``.
280
+ max_items: Per-field list limit, overriding ``Output.max_items``.
281
+ transform: A built-in transform name (``"lower"``, ``"upper"``,
282
+ ``"strip"``, ``"count"``, ``"first"``, ``"join"``, ``"bool"``,
283
+ ``"str"``) or a trusted Python callable.
284
+ default: Value used when the source path is missing.
285
+ shape: Nested shape applied to the value (to each element if a list).
286
+ """
287
+
288
+ source: str
289
+ max_chars: int | None = None
290
+ max_items: int | None = None
291
+ transform: str | Callable[[Any], Any] | None = None
292
+ default: Any = MISSING
293
+ shape: Mapping[str, FieldSpec] | None = None
294
+
295
+
296
+ FieldSpec = str | Field
297
+
298
+ OverflowMode = Literal["truncate", "reject"]
299
+ SchemaMode = Literal["strict", "warn"]
300
+
301
+
302
+ def _default_redact_keys() -> tuple[str, ...]:
303
+ return (
304
+ "password",
305
+ "passwd",
306
+ "secret",
307
+ "client_secret",
308
+ "token",
309
+ "access_token",
310
+ "refresh_token",
311
+ "id_token",
312
+ "api_key",
313
+ "apikey",
314
+ "authorization",
315
+ "cookie",
316
+ "set-cookie",
317
+ "private_key",
318
+ "session",
319
+ "session_id",
320
+ "x-api-key",
321
+ "client_assertion",
322
+ )
323
+
324
+
325
+ @dataclass(frozen=True, slots=True)
326
+ class Output:
327
+ """How an upstream response becomes the model-visible result.
328
+
329
+ Args:
330
+ root: Path to the part of the response to keep (``"$"`` = everything).
331
+ If it selects a list, ``shape`` is applied to every element.
332
+ shape: Allowlist of output keys mapped to source paths. ``None`` passes
333
+ the selected value through (limits and redaction still apply).
334
+ Prefer an explicit shape: allowlists are the primary data-minimization
335
+ control, redaction is only a second layer.
336
+ required: Output keys that must never be dropped to save space (for
337
+ example identifiers or citations). If they cannot fit the budget the
338
+ call fails with ``result_too_large`` instead of returning a misleading
339
+ result.
340
+ max_items: Maximum elements in any list (including the root list).
341
+ max_chars: Maximum characters in any string.
342
+ max_tokens: Token budget for the ``data`` part of the result.
343
+ overflow: ``"truncate"`` (trim and report what was dropped) or
344
+ ``"reject"`` (fail if anything would need to be dropped).
345
+ selectable: Expose an optional ``fields`` argument so the model can ask
346
+ for a subset of the top-level shape keys. Required keys are always
347
+ included.
348
+ redact_keys: Object keys (case-insensitive) whose values are replaced
349
+ with ``"[REDACTED]"`` anywhere in the result.
350
+ redact_secrets: Mask common secret formats (API keys, JWTs, private
351
+ keys) in string values.
352
+ redactors: Extra trusted hooks ``(path, value) -> value`` for PII rules.
353
+ schema: Optional JSON Schema the selected upstream value must match.
354
+ schema_mode: ``"strict"`` fails the call on mismatch; ``"warn"`` returns
355
+ the result with a warning.
356
+ error_shape: Optional shape applied to upstream error bodies so the model
357
+ sees a short, useful error message instead of only a status code.
358
+ accept_text: Accept ``text/*`` responses (returned as a string).
359
+ transform: Final trusted transform applied after shaping.
360
+ continuation: For read tools that return lists: when items are left
361
+ out (``max_items`` or the budget), return a signed ``next_cursor``
362
+ that resumes at the next item. Enabled automatically for operations
363
+ with ``pagination``. Continuing re-runs the request, so it assumes
364
+ the upstream ordering is stable.
365
+ """
366
+
367
+ root: str = "$"
368
+ shape: Mapping[str, FieldSpec] | None = None
369
+ required: tuple[str, ...] = ()
370
+ max_items: int = 50
371
+ max_chars: int = 4000
372
+ max_tokens: int | None = 2000
373
+ overflow: OverflowMode = "truncate"
374
+ selectable: bool = False
375
+ redact_keys: tuple[str, ...] = field(default_factory=_default_redact_keys)
376
+ redact_secrets: bool = True
377
+ redactors: tuple[Callable[[str, Any], Any], ...] = ()
378
+ schema: Mapping[str, Any] | None = None
379
+ schema_mode: SchemaMode = "strict"
380
+ error_shape: Mapping[str, FieldSpec] | None = None
381
+ accept_text: bool = False
382
+ transform: Callable[[Any], Any] | None = None
383
+ continuation: bool = False
384
+
385
+ def __post_init__(self) -> None:
386
+ if self.max_items < 1 or self.max_chars < 1:
387
+ raise ConfigurationError("max_items and max_chars must be at least 1")
388
+ if self.max_tokens is not None and self.max_tokens < 16:
389
+ raise ConfigurationError("max_tokens must be at least 16")
390
+ if self.overflow not in ("truncate", "reject"):
391
+ raise ConfigurationError("overflow must be 'truncate' or 'reject'")
392
+ if self.schema_mode not in ("strict", "warn"):
393
+ raise ConfigurationError("schema_mode must be 'strict' or 'warn'")
394
+ if self.required and self.shape is None:
395
+ raise ConfigurationError("Output.required needs an explicit shape")
396
+ if self.shape is not None:
397
+ missing = [k for k in self.required if k not in self.shape]
398
+ if missing:
399
+ raise ConfigurationError(f"required keys not in shape: {missing}")
400
+ if self.selectable and self.shape is None:
401
+ raise ConfigurationError("Output.selectable needs an explicit shape")
402
+
403
+
404
+ @dataclass(frozen=True, slots=True)
405
+ class Pagination:
406
+ """Upstream pagination. The model only ever sees an opaque, signed cursor.
407
+
408
+ * ``cursor``: the next token is read from ``next_path`` in the response and
409
+ sent back in the ``param`` query parameter.
410
+ * ``link``: the RFC 8288 ``Link: <...>; rel="next"`` header is followed. The
411
+ URL must stay on the same origin and path, and is re-checked against the
412
+ network policy.
413
+ * ``page``: a page number in ``param`` starting at ``start``. If
414
+ ``page_size`` is set, a short page ends the sequence.
415
+ """
416
+
417
+ type: Literal["cursor", "link", "page"]
418
+ param: str | None = None
419
+ next_path: str | None = None
420
+ start: int = 1
421
+ page_size: int | None = None
422
+
423
+ def __post_init__(self) -> None:
424
+ if self.type == "cursor" and (not self.param or not self.next_path):
425
+ raise ConfigurationError("cursor pagination needs param= and next_path=")
426
+ if self.type == "page" and not self.param:
427
+ raise ConfigurationError("page pagination needs param=")
428
+ if self.type not in ("cursor", "link", "page"):
429
+ raise ConfigurationError("pagination type must be cursor, link or page")
430
+
431
+
432
+ # --------------------------------------------------------------------------- service
433
+
434
+
435
+ @dataclass(frozen=True, slots=True)
436
+ class Service:
437
+ """A trusted upstream REST service: base URL, credentials and policies."""
438
+
439
+ name: str
440
+ base_url: str
441
+ auth: Auth = field(default_factory=NoAuth)
442
+ headers: Mapping[str, str] = field(default_factory=dict)
443
+ network: NetworkPolicy = field(default_factory=NetworkPolicy)
444
+ limits: Limits = field(default_factory=Limits)
445
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
446
+
447
+ def __post_init__(self) -> None:
448
+ if not _TOOL_NAME.match(self.name):
449
+ raise ConfigurationError(f"invalid service name {self.name!r}")
450
+ parts = urlsplit(self.base_url)
451
+ if parts.scheme not in ("https", "http"):
452
+ raise ConfigurationError(f"service {self.name}: base_url must be http(s)")
453
+ if parts.scheme == "http" and not self.network.allow_http:
454
+ raise ConfigurationError(
455
+ f"service {self.name}: http:// base_url requires network.allow_http=True"
456
+ )
457
+ if parts.username is not None or parts.password is not None:
458
+ raise ConfigurationError(f"service {self.name}: base_url must not contain userinfo")
459
+ if parts.query or parts.fragment:
460
+ raise ConfigurationError(f"service {self.name}: base_url must not have query/fragment")
461
+ if not parts.hostname:
462
+ raise ConfigurationError(f"service {self.name}: base_url has no host")
463
+ _validate_auth(self.auth)
464
+ for name, value in self.headers.items():
465
+ _validate_header_name(name, allow_reserved=False)
466
+ _validate_header_value(name, value)
467
+ host = parts.hostname.lower()
468
+ net = self.network
469
+ hosts = tuple(h.lower().rstrip(".") for h in net.allow_hosts) or (host,)
470
+ if not any(_host_matches(host, pattern) for pattern in hosts):
471
+ raise ConfigurationError(
472
+ f"service {self.name}: base_url host {host!r} is not in network.allow_hosts"
473
+ )
474
+ default_port = 443 if parts.scheme == "https" else 80
475
+ ports = net.allow_ports or ((443, 80) if net.allow_http else (443,))
476
+ if (parts.port or default_port) not in ports:
477
+ raise ConfigurationError(
478
+ f"service {self.name}: base_url port is not in network.allow_ports"
479
+ )
480
+ if net.max_redirects < 0:
481
+ raise ConfigurationError("max_redirects must be >= 0")
482
+ import ipaddress
483
+
484
+ for cidr in net.allow_networks:
485
+ try:
486
+ ipaddress.ip_network(cidr, strict=False)
487
+ except ValueError as exc:
488
+ raise ConfigurationError(f"invalid CIDR in allow_networks: {cidr!r}") from exc
489
+ object.__setattr__(
490
+ self,
491
+ "network",
492
+ NetworkPolicy(
493
+ allow_hosts=hosts,
494
+ allow_ports=tuple(ports),
495
+ allow_http=net.allow_http,
496
+ allow_networks=net.allow_networks,
497
+ follow_redirects=net.follow_redirects,
498
+ max_redirects=net.max_redirects,
499
+ ),
500
+ )
501
+ object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
502
+ object.__setattr__(self, "headers", dict(self.headers))
503
+
504
+
505
+ def _host_matches(host: str, pattern: str) -> bool:
506
+ if pattern.startswith("*."):
507
+ suffix = pattern[1:]
508
+ return host.endswith(suffix) and len(host) > len(suffix)
509
+ return host == pattern
510
+
511
+
512
+ def host_allowed(host: str, policy: NetworkPolicy) -> bool:
513
+ host = host.lower().rstrip(".")
514
+ return any(_host_matches(host, p) for p in policy.allow_hosts)
515
+
516
+
517
+ def _validate_header_name(name: str, *, allow_reserved: bool) -> None:
518
+ if not _HEADER_NAME.match(name):
519
+ raise ConfigurationError(f"invalid header name {name!r}")
520
+ if not allow_reserved and name.lower() in _FORBIDDEN_STATIC_HEADERS:
521
+ raise ConfigurationError(
522
+ f"header {name!r} cannot be set statically (use auth= for credentials)"
523
+ )
524
+
525
+
526
+ def _validate_header_value(name: str, value: str) -> None:
527
+ if not isinstance(value, str) or any(c in value for c in "\r\n\x00"):
528
+ raise ConfigurationError(f"invalid value for header {name!r}")
529
+
530
+
531
+ # --------------------------------------------------------------------------- tools
532
+
533
+
534
+ @dataclass(frozen=True, slots=True, kw_only=True)
535
+ class ToolSpec:
536
+ """Fields shared by REST operations and Python tools."""
537
+
538
+ name: str
539
+ description: str
540
+ input: Mapping[str, Any] = field(default_factory=dict)
541
+ #: ``None`` is resolved in ``__post_init__``: REST operations infer it from the
542
+ #: HTTP method; Python tools must declare it.
543
+ effect: Effect = None # type: ignore[assignment]
544
+ output: Output = field(default_factory=Output)
545
+ bind: Mapping[str, Binding] = field(default_factory=dict)
546
+ required_scopes: tuple[str, ...] = ()
547
+ tags: tuple[str, ...] = ()
548
+ group: str | None = None
549
+ requires_approval: bool | None = None
550
+ rate_limit: RateLimit | None = None
551
+
552
+ def __post_init__(self) -> None:
553
+ if not _TOOL_NAME.match(self.name):
554
+ raise ConfigurationError(
555
+ f"invalid tool name {self.name!r}: use 1-64 letters, digits, '_' or '-', "
556
+ "starting with a letter"
557
+ )
558
+ if not isinstance(self.description, str) or not self.description.strip():
559
+ raise ConfigurationError(f"tool {self.name}: description is required")
560
+ if len(self.description) > _MAX_DESCRIPTION:
561
+ raise ConfigurationError(
562
+ f"tool {self.name}: description longer than {_MAX_DESCRIPTION} characters"
563
+ )
564
+ if not _unset(self.effect):
565
+ object.__setattr__(self, "effect", Effect(self.effect))
566
+ object.__setattr__(self, "bind", {k: parse_binding(v) for k, v in dict(self.bind).items()})
567
+ object.__setattr__(self, "required_scopes", tuple(self.required_scopes))
568
+ object.__setattr__(self, "tags", tuple(self.tags))
569
+ if self.group is not None and not _TOOL_NAME.match(self.group):
570
+ raise ConfigurationError(f"tool {self.name}: invalid group name {self.group!r}")
571
+
572
+ @property
573
+ def needs_approval(self) -> bool:
574
+ if self.requires_approval is not None:
575
+ return self.requires_approval
576
+ return self.effect is Effect.DESTRUCTIVE
577
+
578
+
579
+ def _unset(value: object) -> bool:
580
+ return value is None
581
+
582
+
583
+ _METHODS = frozenset({"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
584
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
585
+ #: Default effect per HTTP method when a contract does not declare one. A POST
586
+ #: that only reads (a search endpoint) must say ``effect="read"`` explicitly.
587
+ _METHOD_EFFECTS = {
588
+ "GET": Effect.READ,
589
+ "HEAD": Effect.READ,
590
+ "OPTIONS": Effect.READ,
591
+ "POST": Effect.WRITE,
592
+ "PUT": Effect.WRITE,
593
+ "PATCH": Effect.WRITE,
594
+ "DELETE": Effect.DESTRUCTIVE,
595
+ }
596
+ _PLACEHOLDER = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
597
+
598
+
599
+ @dataclass(frozen=True, slots=True, kw_only=True)
600
+ class Operation(ToolSpec):
601
+ """A REST-backed tool.
602
+
603
+ Argument placement: names that appear in ``path`` placeholders go into the
604
+ path (strictly encoded). Remaining arguments go into the query string for
605
+ GET/HEAD/DELETE/OPTIONS and into the body otherwise, unless ``query`` /
606
+ ``body`` list names explicitly.
607
+ """
608
+
609
+ service: Service
610
+ method: str = "GET"
611
+ path: str = "/"
612
+ query: tuple[str, ...] | None = None
613
+ body: tuple[str, ...] | None = None
614
+ body_format: Literal["json", "form"] = "json"
615
+ headers: Mapping[str, str] = field(default_factory=dict)
616
+ idempotency_header: str | None = None
617
+ pagination: Pagination | None = None
618
+
619
+ def __post_init__(self) -> None:
620
+ ToolSpec.__post_init__(self)
621
+ method = self.method.upper()
622
+ if method not in _METHODS:
623
+ raise ConfigurationError(f"tool {self.name}: unsupported HTTP method {self.method!r}")
624
+ object.__setattr__(self, "method", method)
625
+ inferred = _METHOD_EFFECTS[method]
626
+ if _unset(self.effect):
627
+ object.__setattr__(self, "effect", inferred)
628
+ elif method == "DELETE" and self.effect is Effect.READ:
629
+ raise ConfigurationError(
630
+ f"tool {self.name}: a DELETE operation cannot be effect='read'"
631
+ )
632
+ if not self.path.startswith("/") or "?" in self.path or "#" in self.path:
633
+ raise ConfigurationError(
634
+ f"tool {self.name}: path must start with '/' and contain no query or fragment"
635
+ )
636
+ if ".." in self.path.split("/"):
637
+ raise ConfigurationError(f"tool {self.name}: path must not contain '..'")
638
+ if self.body_format not in ("json", "form"):
639
+ raise ConfigurationError(f"tool {self.name}: body_format must be json or form")
640
+ for name, value in self.headers.items():
641
+ _validate_header_name(name, allow_reserved=False)
642
+ _validate_header_value(name, value)
643
+ if self.idempotency_header is not None:
644
+ _validate_header_name(self.idempotency_header, allow_reserved=False)
645
+ if self.pagination is not None and self.effect is not Effect.READ:
646
+ raise ConfigurationError(f"tool {self.name}: pagination is only for read tools")
647
+ object.__setattr__(self, "headers", dict(self.headers))
648
+ if self.query is not None:
649
+ object.__setattr__(self, "query", tuple(self.query))
650
+ if self.body is not None:
651
+ object.__setattr__(self, "body", tuple(self.body))
652
+
653
+ @property
654
+ def path_params(self) -> tuple[str, ...]:
655
+ return tuple(_PLACEHOLDER.findall(self.path))
656
+
657
+
658
+ @dataclass(frozen=True, slots=True, kw_only=True)
659
+ class PythonTool(ToolSpec):
660
+ """A tool implemented by a trusted Python function.
661
+
662
+ Toolbound does **not** sandbox the handler. It runs with your application's
663
+ full privileges. Toolbound validates its input, enforces authorization,
664
+ approval, rate limits and timeouts, and shapes its output, exactly like a
665
+ REST operation.
666
+
667
+ The handler receives validated arguments (and bound values) as keyword
668
+ arguments. If it declares a parameter named ``ctx``, the trusted
669
+ :class:`ExecutionContext` is passed there. Sync handlers run in a worker
670
+ thread.
671
+ """
672
+
673
+ handler: Callable[..., Any]
674
+ timeout: float = 30.0
675
+
676
+ def __post_init__(self) -> None:
677
+ ToolSpec.__post_init__(self)
678
+ if _unset(self.effect):
679
+ raise ConfigurationError(
680
+ f"tool {self.name}: Python tools must declare effect='read', 'write' or "
681
+ "'destructive' (Toolbound cannot tell what a handler does)"
682
+ )
683
+ if not callable(self.handler):
684
+ raise ConfigurationError(f"tool {self.name}: handler must be callable")
685
+ if self.timeout <= 0:
686
+ raise ConfigurationError(f"tool {self.name}: timeout must be positive")
687
+
688
+
689
+ Tool = Operation | PythonTool