ordainer 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.
ordainer/__init__.py ADDED
@@ -0,0 +1,170 @@
1
+ """Ask Ordainer before your tool runs.
2
+
3
+ One decide() call checks the caller's plan and counts the use. Python 3.9+,
4
+ standard library only. Mirrors the Ruby gem (packages/ordainer-ruby) and the
5
+ Node SDK (packages/ordainer-node).
6
+
7
+ from ordainer import Client, deny_response
8
+
9
+ client = Client(api_key=os.environ["ORDAINER_API_KEY"])
10
+ decision = client.decide(tool="research.run")
11
+ if not decision.allowed:
12
+ blocked = deny_response(decision)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import threading
19
+ from typing import Any, Callable, Optional
20
+
21
+ from .client import Client
22
+ from .decision import Decision, ErrorInfo
23
+ from .deny import (
24
+ BILLING_BLOCKED_MESSAGE,
25
+ CONCURRENCY_LIMITED_MESSAGE,
26
+ RATE_LIMITED_MESSAGE,
27
+ UNAVAILABLE_MESSAGE,
28
+ deny_response,
29
+ plan_denied_message,
30
+ rejected_message,
31
+ reset_day_utc,
32
+ test_limit_message,
33
+ )
34
+ from .engine import idempotency_key_for
35
+ from .errors import (
36
+ BlockedError,
37
+ ConfigurationError,
38
+ DeniedError,
39
+ Error,
40
+ NetworkError,
41
+ RateLimitedError,
42
+ RejectedError,
43
+ TimeoutError,
44
+ UnavailableError,
45
+ )
46
+ from .version import __version__
47
+
48
+ VERSION = __version__
49
+
50
+ __all__ = [
51
+ "VERSION",
52
+ "AsyncClient",
53
+ "BILLING_BLOCKED_MESSAGE",
54
+ "BlockedError",
55
+ "CONCURRENCY_LIMITED_MESSAGE",
56
+ "Client",
57
+ "Configuration",
58
+ "ConfigurationError",
59
+ "Decision",
60
+ "DeniedError",
61
+ "Error",
62
+ "ErrorInfo",
63
+ "NetworkError",
64
+ "RATE_LIMITED_MESSAGE",
65
+ "RateLimitedError",
66
+ "RejectedError",
67
+ "TimeoutError",
68
+ "UNAVAILABLE_MESSAGE",
69
+ "UnavailableError",
70
+ "__version__",
71
+ "client",
72
+ "configure",
73
+ "configuration",
74
+ "deny_response",
75
+ "idempotency_key_for",
76
+ "plan_denied_message",
77
+ "rejected_message",
78
+ "reset",
79
+ "reset_day_utc",
80
+ "test_limit_message",
81
+ ]
82
+
83
+
84
+ class Configuration:
85
+ """Global settings for ordainer.client(), same fields as Client."""
86
+
87
+ def __init__(self) -> None:
88
+ self.api_key: Optional[str] = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
89
+ self.base_url: Optional[str] = None
90
+ self.timeout: Any = None
91
+ self.fail_mode: Any = None
92
+ self.retries: Any = None
93
+ self.retry_budget: Any = None
94
+ self.mode: Any = None
95
+ self.billing_lapse: Any = None
96
+ self.deny_status: Any = None
97
+ self.sticky_deny: Any = None
98
+ self.logger: Any = None
99
+ self.on_decision: Optional[Callable[..., Any]] = None
100
+
101
+ def to_client_options(self) -> dict[str, Any]:
102
+ options = {
103
+ "api_key": self.api_key,
104
+ "base_url": self.base_url,
105
+ "timeout": self.timeout,
106
+ "fail_mode": self.fail_mode,
107
+ "retries": self.retries,
108
+ "retry_budget": self.retry_budget,
109
+ "mode": self.mode,
110
+ "billing_lapse": self.billing_lapse,
111
+ "deny_status": self.deny_status,
112
+ "sticky_deny": self.sticky_deny,
113
+ "logger": self.logger,
114
+ "on_decision": self.on_decision,
115
+ }
116
+ return {key: value for key, value in options.items() if value is not None}
117
+
118
+
119
+ _configuration: Optional[Configuration] = None
120
+ _client: Optional[Client] = None
121
+ _lock = threading.RLock()
122
+
123
+
124
+ def configuration() -> Configuration:
125
+ global _configuration
126
+ with _lock:
127
+ if _configuration is None:
128
+ _configuration = Configuration()
129
+ return _configuration
130
+
131
+
132
+ def configure(callback: Callable[[Configuration], Any]) -> Configuration:
133
+ """Apply callback to the global configuration and drop the cached client."""
134
+ global _client
135
+ cfg = configuration()
136
+ callback(cfg)
137
+ with _lock:
138
+ old = _client
139
+ _client = None
140
+ if old is not None:
141
+ old.close()
142
+ return cfg
143
+
144
+
145
+ def client() -> Client:
146
+ """A shared client built from the configuration."""
147
+ global _client
148
+ with _lock:
149
+ if _client is None:
150
+ _client = Client(**configuration().to_client_options())
151
+ return _client
152
+
153
+
154
+ def reset() -> None:
155
+ """Drop the global configuration and the shared client."""
156
+ global _configuration, _client
157
+ with _lock:
158
+ old = _client
159
+ _configuration = None
160
+ _client = None
161
+ if old is not None:
162
+ old.close()
163
+
164
+
165
+ def __getattr__(name: str) -> Any:
166
+ if name == "AsyncClient":
167
+ from .async_client import AsyncClient
168
+
169
+ return AsyncClient
170
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,243 @@
1
+ """Optional async client. Requires httpx (`pip install ordainer[async]`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Awaitable, Callable, Optional
6
+
7
+ from .client import USER_AGENT, _default_logger, random_default
8
+ from .decision import Decision
9
+ from .engine import (
10
+ DEFAULT_BASE_URL,
11
+ DEFAULT_RETRIES,
12
+ DEFAULT_RETRY_BUDGET,
13
+ DEFAULT_STICKY_DENY,
14
+ DEFAULT_TIMEOUT,
15
+ FAIL_MODES,
16
+ MODES,
17
+ Runtime,
18
+ check_choice,
19
+ check_deny_status,
20
+ check_retries,
21
+ check_retry_budget,
22
+ check_sticky,
23
+ check_timeout,
24
+ strip_base_url,
25
+ )
26
+ from .errors import ConfigurationError, DeniedError, RateLimitedError, RejectedError, UnavailableError
27
+ from .transport import Transport
28
+
29
+
30
+ def _require_httpx() -> Any:
31
+ try:
32
+ import httpx
33
+ except ImportError as exc:
34
+ raise ImportError("AsyncClient requires httpx. Install it with: pip install 'ordainer[async]'") from exc
35
+ return httpx
36
+
37
+
38
+ class AsyncClient:
39
+ """Async twin of Client. Same decide() rules, httpx for the HTTP call.
40
+
41
+ Use as an async context manager, or call aclose() when finished.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ api_key: Optional[str] = None,
48
+ base_url: Optional[str] = None,
49
+ timeout: float = DEFAULT_TIMEOUT,
50
+ fail_mode: str = "open",
51
+ retries: int = DEFAULT_RETRIES,
52
+ retry_budget: float = DEFAULT_RETRY_BUDGET,
53
+ mode: Optional[str] = None,
54
+ billing_lapse: str = "open",
55
+ deny_status: int = 429,
56
+ sticky_deny: float = DEFAULT_STICKY_DENY,
57
+ logger: Any = None,
58
+ on_decision: Optional[Callable[[Decision], Any]] = None,
59
+ sleeper: Optional[Callable[[float], Any]] = None,
60
+ random: Optional[Callable[[], float]] = None,
61
+ ) -> None:
62
+ self._httpx = _require_httpx()
63
+ import os
64
+
65
+ try:
66
+ if api_key is None:
67
+ api_key = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
68
+ if base_url is None:
69
+ base_url = os.environ.get("ORDAINER_BASE_URL") or DEFAULT_BASE_URL
70
+ self.base_url = strip_base_url(str(base_url))
71
+ self.timeout = check_timeout(timeout)
72
+ self.fail_mode = check_choice(fail_mode, FAIL_MODES, "fail_mode")
73
+ self.billing_lapse = check_choice(billing_lapse, FAIL_MODES, "billing_lapse")
74
+ self.mode = None if mode is None else check_choice(mode, MODES, "mode")
75
+ self.retries = check_retries(retries)
76
+ self.retry_budget = check_retry_budget(retry_budget)
77
+ self.deny_status = check_deny_status(deny_status)
78
+ self.sticky_deny = check_sticky(sticky_deny)
79
+ except ConfigurationError:
80
+ raise
81
+ except (TypeError, ValueError) as exc:
82
+ raise ConfigurationError(str(exc)) from exc
83
+ self.api_key = api_key
84
+ if logger is False:
85
+ active_logger = None
86
+ elif logger is None:
87
+ active_logger = _default_logger()
88
+ else:
89
+ active_logger = logger
90
+ self._sleeper = sleeper
91
+ # Transport parses the URL (host, path) the same way as the sync client. No connection is kept here.
92
+ try:
93
+ parsed = Transport(self.base_url)
94
+ except ConfigurationError:
95
+ raise
96
+ except Exception as exc:
97
+ raise ConfigurationError(str(exc)) from exc
98
+ self._endpoint = parsed.endpoint
99
+ parsed.close()
100
+ self._runtime = Runtime(
101
+ base_url=self.base_url,
102
+ host=parsed.host,
103
+ timeout=self.timeout,
104
+ fail_mode=self.fail_mode,
105
+ retries=self.retries,
106
+ retry_budget=self.retry_budget,
107
+ mode=self.mode,
108
+ billing_lapse=self.billing_lapse,
109
+ deny_status=self.deny_status,
110
+ sticky_deny=self.sticky_deny,
111
+ api_key=api_key if api_key is None else str(api_key),
112
+ logger=active_logger,
113
+ on_decision=on_decision,
114
+ random=random or random_default,
115
+ )
116
+ self._http: Any = None
117
+
118
+ async def _client(self) -> Any:
119
+ if self._http is None:
120
+ timeout = self._httpx.Timeout(self.timeout)
121
+ self._http = self._httpx.AsyncClient(timeout=timeout, follow_redirects=False, trust_env=False)
122
+ return self._http
123
+
124
+ async def aclose(self) -> None:
125
+ http = self._http
126
+ self._http = None
127
+ if http is not None:
128
+ await http.aclose()
129
+
130
+ async def __aenter__(self) -> "AsyncClient":
131
+ return self
132
+
133
+ async def __aexit__(self, *exc: Any) -> None:
134
+ await self.aclose()
135
+
136
+ def __repr__(self) -> str:
137
+ return f"<ordainer.AsyncClient base_url={self.base_url!r} fail_mode={self.fail_mode!r}>"
138
+
139
+ async def _post(self, api_key: str, payload: bytes, timeout: float) -> tuple[int, dict[str, str], bytes]:
140
+ http = await self._client()
141
+ try:
142
+ response = await http.post(
143
+ self._endpoint,
144
+ content=payload,
145
+ headers={
146
+ "Authorization": f"Bearer {api_key}",
147
+ "Content-Type": "application/json",
148
+ "User-Agent": USER_AGENT,
149
+ "Accept": "application/json",
150
+ },
151
+ timeout=self._httpx.Timeout(timeout),
152
+ )
153
+ except self._httpx.TimeoutException:
154
+ raise
155
+ except self._httpx.HTTPError:
156
+ raise
157
+ headers = {key.lower(): value for key, value in response.headers.items()}
158
+ return response.status_code, headers, response.content
159
+
160
+ async def _pause(self, seconds: float) -> None:
161
+ sleeper = self._sleeper
162
+ if sleeper is None:
163
+ import asyncio
164
+
165
+ await asyncio.sleep(seconds)
166
+ return
167
+ result = sleeper(seconds)
168
+ if _is_awaitable(result):
169
+ await result
170
+
171
+ async def decide(
172
+ self,
173
+ *,
174
+ tool: Any,
175
+ api_key: Optional[str] = None,
176
+ idempotency_key: Any = None,
177
+ mode: Optional[str] = None,
178
+ fail_mode: Optional[str] = None,
179
+ billing_lapse: Optional[str] = None,
180
+ retries: Optional[int] = None,
181
+ ) -> Decision:
182
+ """Ask Ordainer whether this call may run. Never raises for an answer or an outage."""
183
+ plan = self._runtime.decide_plan(
184
+ tool=tool,
185
+ api_key=api_key,
186
+ idempotency_key=idempotency_key,
187
+ mode=mode,
188
+ fail_mode=fail_mode,
189
+ billing_lapse=billing_lapse,
190
+ retries=retries,
191
+ )
192
+ if plan["done"] is not None:
193
+ return self._runtime.finish(plan["done"], plan["started"])
194
+ timeout = self.timeout
195
+ attempt = 0
196
+ result: Decision
197
+ while True:
198
+ attempt += 1
199
+ try:
200
+ status, headers, raw = await self._post(plan["key"], plan["payload"], timeout)
201
+ except self._httpx.TimeoutException:
202
+ result, retryable = self._runtime.on_timeout(plan, timeout)
203
+ except Exception as exc:
204
+ # Same contract as the sync client: an encoding or transport error is a
205
+ # network_error, and fail_mode applies. decide() does not raise.
206
+ result, retryable = self._runtime.on_network(plan, exc)
207
+ else:
208
+ result, retryable = self._runtime.on_http(plan, status, headers, raw)
209
+ result.attempts = attempt
210
+ if not retryable or attempt >= plan["max_attempts"]:
211
+ break
212
+ nxt = self._runtime.pause_for(attempt, plan["budget_end"])
213
+ if nxt is None:
214
+ break
215
+ pause, timeout = nxt
216
+ await self._pause(pause)
217
+ result = self._runtime.apply_sticky(result, plan["key"], plan["tool"], plan["req_mode"])
218
+ return self._runtime.finish(result, plan["started"])
219
+
220
+ async def decide_or_raise(self, **kwargs: Any) -> Decision:
221
+ """Like decide, but raises a BlockedError subclass when the call must not run."""
222
+ decision = await self.decide(**kwargs)
223
+ if decision.allowed:
224
+ return decision
225
+ if decision.rate_limited:
226
+ raise RateLimitedError(decision)
227
+ if decision.rejected:
228
+ raise RejectedError(decision)
229
+ if decision.fail_closed:
230
+ raise UnavailableError(decision)
231
+ raise DeniedError(decision)
232
+
233
+ async def guard(self, fn: Callable[[Decision], Any], /, **kwargs: Any) -> Any:
234
+ """Run fn(decision) only if the call is allowed. Raises like decide_or_raise."""
235
+ decision = await self.decide_or_raise(**kwargs)
236
+ result = fn(decision)
237
+ if _is_awaitable(result):
238
+ return await result
239
+ return result
240
+
241
+
242
+ def _is_awaitable(value: Any) -> bool:
243
+ return isinstance(value, Awaitable)
ordainer/client.py ADDED
@@ -0,0 +1,207 @@
1
+ """Synchronous Ordainer client. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import random
8
+ import sys
9
+ import time
10
+ from typing import Any, Callable, Optional
11
+
12
+ from .decision import Decision
13
+ from .engine import (
14
+ DEFAULT_BASE_URL,
15
+ DEFAULT_RETRIES,
16
+ DEFAULT_RETRY_BUDGET,
17
+ DEFAULT_STICKY_DENY,
18
+ DEFAULT_TIMEOUT,
19
+ Runtime,
20
+ check_choice,
21
+ check_deny_status,
22
+ check_retries,
23
+ check_retry_budget,
24
+ check_sticky,
25
+ check_timeout,
26
+ strip_base_url,
27
+ FAIL_MODES,
28
+ MODES,
29
+ )
30
+ from .errors import ConfigurationError, DeniedError, RateLimitedError, RejectedError, UnavailableError
31
+ from .transport import Transport, is_timeout
32
+ from .version import __version__
33
+
34
+ USER_AGENT = f"ordainer-python/{__version__}"
35
+
36
+
37
+ def _default_logger() -> logging.Logger:
38
+ logger = logging.Logger("ordainer")
39
+ handler = logging.StreamHandler(sys.stderr)
40
+ handler.setFormatter(logging.Formatter("%(message)s"))
41
+ logger.addHandler(handler)
42
+ logger.setLevel(logging.WARNING)
43
+ return logger
44
+
45
+
46
+ class Client:
47
+ """Thread-safe. Share one per process.
48
+
49
+ decide() never raises for an Ordainer answer or an outage. A deny is a normal Decision.
50
+ Bad arguments raise ConfigurationError.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ api_key: Optional[str] = None,
57
+ base_url: Optional[str] = None,
58
+ timeout: float = DEFAULT_TIMEOUT,
59
+ fail_mode: str = "open",
60
+ retries: int = DEFAULT_RETRIES,
61
+ retry_budget: float = DEFAULT_RETRY_BUDGET,
62
+ mode: Optional[str] = None,
63
+ billing_lapse: str = "open",
64
+ deny_status: int = 429,
65
+ sticky_deny: float = DEFAULT_STICKY_DENY,
66
+ logger: Any = None,
67
+ on_decision: Optional[Callable[[Decision], Any]] = None,
68
+ sleeper: Optional[Callable[[float], Any]] = None,
69
+ random: Optional[Callable[[], float]] = None,
70
+ ) -> None:
71
+ try:
72
+ if api_key is None:
73
+ api_key = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
74
+ if base_url is None:
75
+ base_url = os.environ.get("ORDAINER_BASE_URL") or DEFAULT_BASE_URL
76
+ self.base_url = strip_base_url(str(base_url))
77
+ self.timeout = check_timeout(timeout)
78
+ self.fail_mode = check_choice(fail_mode, FAIL_MODES, "fail_mode")
79
+ self.billing_lapse = check_choice(billing_lapse, FAIL_MODES, "billing_lapse")
80
+ self.mode = None if mode is None else check_choice(mode, MODES, "mode")
81
+ self.retries = check_retries(retries)
82
+ self.retry_budget = check_retry_budget(retry_budget)
83
+ self.deny_status = check_deny_status(deny_status)
84
+ self.sticky_deny = check_sticky(sticky_deny)
85
+ except ConfigurationError:
86
+ raise
87
+ except (TypeError, ValueError) as exc:
88
+ raise ConfigurationError(str(exc)) from exc
89
+ self.api_key = api_key
90
+ if logger is False:
91
+ active_logger = None
92
+ elif logger is None:
93
+ active_logger = _default_logger()
94
+ else:
95
+ active_logger = logger
96
+ self._sleeper = sleeper or time.sleep
97
+ try:
98
+ self._transport = Transport(self.base_url)
99
+ except ConfigurationError:
100
+ raise
101
+ except Exception as exc:
102
+ raise ConfigurationError(str(exc)) from exc
103
+ self._runtime = Runtime(
104
+ base_url=self.base_url,
105
+ host=self._transport.host,
106
+ timeout=self.timeout,
107
+ fail_mode=self.fail_mode,
108
+ retries=self.retries,
109
+ retry_budget=self.retry_budget,
110
+ mode=self.mode,
111
+ billing_lapse=self.billing_lapse,
112
+ deny_status=self.deny_status,
113
+ sticky_deny=self.sticky_deny,
114
+ api_key=api_key if api_key is None else str(api_key),
115
+ logger=active_logger,
116
+ on_decision=on_decision,
117
+ random=random or random_default,
118
+ )
119
+
120
+ def decide(
121
+ self,
122
+ *,
123
+ tool: Any,
124
+ api_key: Optional[str] = None,
125
+ idempotency_key: Any = None,
126
+ mode: Optional[str] = None,
127
+ fail_mode: Optional[str] = None,
128
+ billing_lapse: Optional[str] = None,
129
+ retries: Optional[int] = None,
130
+ ) -> Decision:
131
+ """Ask Ordainer whether this call may run. Never raises for an answer or an outage."""
132
+ plan = self._runtime.decide_plan(
133
+ tool=tool,
134
+ api_key=api_key,
135
+ idempotency_key=idempotency_key,
136
+ mode=mode,
137
+ fail_mode=fail_mode,
138
+ billing_lapse=billing_lapse,
139
+ retries=retries,
140
+ )
141
+ if plan["done"] is not None:
142
+ return self._runtime.finish(plan["done"], plan["started"])
143
+ timeout = self.timeout
144
+ attempt = 0
145
+ result: Decision
146
+ while True:
147
+ attempt += 1
148
+ try:
149
+ status, headers, raw = self._transport.post(plan["key"], plan["payload"], timeout, USER_AGENT)
150
+ except Exception as exc:
151
+ # decide() never raises for a transport failure. A newline or a non-Latin-1
152
+ # key makes http.client raise ValueError / UnicodeEncodeError before the
153
+ # bytes go out; that is a network_error, and fail_mode applies. Timeouts stay timeouts.
154
+ if is_timeout(exc):
155
+ result, retryable = self._runtime.on_timeout(plan, timeout)
156
+ else:
157
+ result, retryable = self._runtime.on_network(plan, exc)
158
+ else:
159
+ result, retryable = self._runtime.on_http(plan, status, headers, raw)
160
+ result.attempts = attempt
161
+ if not retryable or attempt >= plan["max_attempts"]:
162
+ break
163
+ nxt = self._runtime.pause_for(attempt, plan["budget_end"])
164
+ if nxt is None:
165
+ break
166
+ pause, timeout = nxt
167
+ self._sleeper(pause)
168
+ result = self._runtime.apply_sticky(result, plan["key"], plan["tool"], plan["req_mode"])
169
+ return self._runtime.finish(result, plan["started"])
170
+
171
+ def decide_or_raise(self, **kwargs: Any) -> Decision:
172
+ """Like decide, but raises a BlockedError subclass when the call must not run.
173
+
174
+ This is the Python name for the Ruby client's decide!.
175
+ """
176
+ decision = self.decide(**kwargs)
177
+ if decision.allowed:
178
+ return decision
179
+ if decision.rate_limited:
180
+ raise RateLimitedError(decision)
181
+ if decision.rejected:
182
+ raise RejectedError(decision)
183
+ if decision.fail_closed:
184
+ raise UnavailableError(decision)
185
+ raise DeniedError(decision)
186
+
187
+ def guard(self, fn: Callable[[Decision], Any], /, **kwargs: Any) -> Any:
188
+ """Run fn(decision) only if the call is allowed. Raises like decide_or_raise."""
189
+ decision = self.decide_or_raise(**kwargs)
190
+ return fn(decision)
191
+
192
+ def close(self) -> None:
193
+ self._transport.close()
194
+
195
+ def __repr__(self) -> str:
196
+ # Never shows the key.
197
+ return f"<ordainer.Client base_url={self.base_url!r} fail_mode={self.fail_mode!r}>"
198
+
199
+ def __enter__(self) -> "Client":
200
+ return self
201
+
202
+ def __exit__(self, *exc: Any) -> None:
203
+ self.close()
204
+
205
+
206
+ def random_default() -> float:
207
+ return random.random()