axorum-client 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.
- axorum/__init__.py +152 -0
- axorum/client.py +408 -0
- axorum/errors.py +326 -0
- axorum/ids.py +98 -0
- axorum/models.py +677 -0
- axorum/protocol.py +376 -0
- axorum/py.typed +0 -0
- axorum_client-0.1.0.dist-info/METADATA +235 -0
- axorum_client-0.1.0.dist-info/RECORD +11 -0
- axorum_client-0.1.0.dist-info/WHEEL +4 -0
- axorum_client-0.1.0.dist-info/licenses/LICENSE +202 -0
axorum/__init__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The official Python client for the Axorum agent plane.
|
|
2
|
+
|
|
3
|
+
Axorum is a deontic financial-control ledger: an agent submits an *intent*, and
|
|
4
|
+
the policy in force permits or refuses it. This package speaks that protocol.
|
|
5
|
+
|
|
6
|
+
The one rule
|
|
7
|
+
------------
|
|
8
|
+
**A policy refusal is not an error.** An intent the policy in force forbids is
|
|
9
|
+
still committed, still judged, and still *recorded* — and that recording is the
|
|
10
|
+
product. It arrives as an :class:`AgentOutcome` with ``posted`` false and
|
|
11
|
+
``verdict`` ``ForbiddenRejected``, over a ``200``::
|
|
12
|
+
|
|
13
|
+
from axorum import ActionTerm, Amount, AxorumClient, Entry, IntentDraft
|
|
14
|
+
|
|
15
|
+
with AxorumClient("http://127.0.0.1:8080") as client:
|
|
16
|
+
outcome = client.submit_pinned(
|
|
17
|
+
IntentDraft(
|
|
18
|
+
agent="agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
|
|
19
|
+
attestation=token,
|
|
20
|
+
action=ActionTerm("post"),
|
|
21
|
+
entries=[
|
|
22
|
+
Entry.debit(cash, Amount(1000, "USD")),
|
|
23
|
+
Entry.credit(revenue, Amount(1000, "USD")),
|
|
24
|
+
],
|
|
25
|
+
justification="settling invoice 42",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if outcome.posted:
|
|
30
|
+
print("permitted, and the entries posted")
|
|
31
|
+
else:
|
|
32
|
+
# Not a failure. The ledger recorded a refusal, which is what you
|
|
33
|
+
# asked it to do.
|
|
34
|
+
print(f"refused, and the refusal is on the ledger: {outcome.verdict}")
|
|
35
|
+
|
|
36
|
+
An :class:`AxorumError` means the ledger never got to answer at all: the caller
|
|
37
|
+
was not who they claimed, the pin did not hold, the cluster could not confirm,
|
|
38
|
+
the wire broke.
|
|
39
|
+
|
|
40
|
+
Sync or async, one protocol
|
|
41
|
+
---------------------------
|
|
42
|
+
:class:`AxorumClient` blocks; :class:`AsyncAxorumClient` awaits. They are shells
|
|
43
|
+
over :mod:`axorum.protocol`, where every request builder, every response decoder,
|
|
44
|
+
and the policy-pin loop itself are pure functions — so the two clients cannot
|
|
45
|
+
drift, and the protocol is testable without a socket.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from .client import DEFAULT_TIMEOUT, AsyncAxorumClient, AxorumClient
|
|
49
|
+
from .errors import (
|
|
50
|
+
ApiError,
|
|
51
|
+
AttestationError,
|
|
52
|
+
AxorumError,
|
|
53
|
+
CommitUnavailableError,
|
|
54
|
+
ConfigError,
|
|
55
|
+
DecodeError,
|
|
56
|
+
NoActivePolicyError,
|
|
57
|
+
NotPrimaryError,
|
|
58
|
+
StalePolicyError,
|
|
59
|
+
TransportError,
|
|
60
|
+
UnexpectedResponseError,
|
|
61
|
+
UnknownAgentError,
|
|
62
|
+
)
|
|
63
|
+
from .ids import (
|
|
64
|
+
ACCOUNT_PREFIX,
|
|
65
|
+
POLICY_PREFIX,
|
|
66
|
+
TRANSACTION_PREFIX,
|
|
67
|
+
InvalidIdError,
|
|
68
|
+
new_transaction_id,
|
|
69
|
+
typeid_from_uuid,
|
|
70
|
+
uuid_from_typeid,
|
|
71
|
+
)
|
|
72
|
+
from .models import (
|
|
73
|
+
ActionTerm,
|
|
74
|
+
ActivePolicyResponse,
|
|
75
|
+
AgentOutcome,
|
|
76
|
+
Amount,
|
|
77
|
+
BalanceResponse,
|
|
78
|
+
Entry,
|
|
79
|
+
IntentDraft,
|
|
80
|
+
IntentEnvelope,
|
|
81
|
+
JsonValue,
|
|
82
|
+
MasterDataBasis,
|
|
83
|
+
ObligationInstance,
|
|
84
|
+
ObligationOutcomes,
|
|
85
|
+
ObligationsResponse,
|
|
86
|
+
ObligationState,
|
|
87
|
+
OutcomeProvenance,
|
|
88
|
+
Provenance,
|
|
89
|
+
ReferenceSet,
|
|
90
|
+
Side,
|
|
91
|
+
TransactionRecord,
|
|
92
|
+
Value,
|
|
93
|
+
ValueKind,
|
|
94
|
+
Verdict,
|
|
95
|
+
)
|
|
96
|
+
from .protocol import MAX_PINS, REQUEST_ID_HEADER, Request, error_from, interpret, pin_flow
|
|
97
|
+
|
|
98
|
+
__version__ = "0.1.0"
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"ACCOUNT_PREFIX",
|
|
102
|
+
"DEFAULT_TIMEOUT",
|
|
103
|
+
"MAX_PINS",
|
|
104
|
+
"POLICY_PREFIX",
|
|
105
|
+
"REQUEST_ID_HEADER",
|
|
106
|
+
"TRANSACTION_PREFIX",
|
|
107
|
+
"ActionTerm",
|
|
108
|
+
"ActivePolicyResponse",
|
|
109
|
+
"AgentOutcome",
|
|
110
|
+
"Amount",
|
|
111
|
+
"ApiError",
|
|
112
|
+
"AsyncAxorumClient",
|
|
113
|
+
"AttestationError",
|
|
114
|
+
"AxorumClient",
|
|
115
|
+
"AxorumError",
|
|
116
|
+
"BalanceResponse",
|
|
117
|
+
"CommitUnavailableError",
|
|
118
|
+
"ConfigError",
|
|
119
|
+
"DecodeError",
|
|
120
|
+
"Entry",
|
|
121
|
+
"IntentDraft",
|
|
122
|
+
"IntentEnvelope",
|
|
123
|
+
"InvalidIdError",
|
|
124
|
+
"JsonValue",
|
|
125
|
+
"MasterDataBasis",
|
|
126
|
+
"NoActivePolicyError",
|
|
127
|
+
"NotPrimaryError",
|
|
128
|
+
"ObligationInstance",
|
|
129
|
+
"ObligationOutcomes",
|
|
130
|
+
"ObligationState",
|
|
131
|
+
"ObligationsResponse",
|
|
132
|
+
"OutcomeProvenance",
|
|
133
|
+
"Provenance",
|
|
134
|
+
"ReferenceSet",
|
|
135
|
+
"Request",
|
|
136
|
+
"Side",
|
|
137
|
+
"StalePolicyError",
|
|
138
|
+
"TransactionRecord",
|
|
139
|
+
"TransportError",
|
|
140
|
+
"UnexpectedResponseError",
|
|
141
|
+
"UnknownAgentError",
|
|
142
|
+
"Value",
|
|
143
|
+
"ValueKind",
|
|
144
|
+
"Verdict",
|
|
145
|
+
"__version__",
|
|
146
|
+
"error_from",
|
|
147
|
+
"interpret",
|
|
148
|
+
"new_transaction_id",
|
|
149
|
+
"pin_flow",
|
|
150
|
+
"typeid_from_uuid",
|
|
151
|
+
"uuid_from_typeid",
|
|
152
|
+
]
|
axorum/client.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""The sync and async clients — thin shells over :mod:`axorum.protocol`.
|
|
2
|
+
|
|
3
|
+
Both clients hold an httpx client and nothing else. Every decision about what to
|
|
4
|
+
send and what a response means lives in :mod:`axorum.protocol` as a pure
|
|
5
|
+
function, so the two are the same client twice: one that blocks and one that
|
|
6
|
+
awaits. They cannot drift, because there is only one implementation of the
|
|
7
|
+
protocol to drift from.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError, NoActivePolicyError, StalePolicyError, TransportError
|
|
18
|
+
from .models import (
|
|
19
|
+
AgentOutcome,
|
|
20
|
+
BalanceResponse,
|
|
21
|
+
IntentDraft,
|
|
22
|
+
IntentEnvelope,
|
|
23
|
+
ObligationsResponse,
|
|
24
|
+
TransactionRecord,
|
|
25
|
+
)
|
|
26
|
+
from .protocol import (
|
|
27
|
+
MAX_PINS,
|
|
28
|
+
Request,
|
|
29
|
+
build_active_policy,
|
|
30
|
+
build_balance,
|
|
31
|
+
build_obligations,
|
|
32
|
+
build_submit_intent,
|
|
33
|
+
build_transaction,
|
|
34
|
+
interpret_active_policy,
|
|
35
|
+
interpret_balance,
|
|
36
|
+
interpret_obligations,
|
|
37
|
+
interpret_outcome,
|
|
38
|
+
interpret_transaction,
|
|
39
|
+
normalize_base_url,
|
|
40
|
+
pin_flow,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = ["AsyncAxorumClient", "AxorumClient"]
|
|
44
|
+
|
|
45
|
+
_Self = TypeVar("_Self", bound="AxorumClient")
|
|
46
|
+
_AsyncSelf = TypeVar("_AsyncSelf", bound="AsyncAxorumClient")
|
|
47
|
+
|
|
48
|
+
DEFAULT_TIMEOUT = 10.0
|
|
49
|
+
"""The per-request timeout, in seconds."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _auth_headers(bearer_token: str | None) -> dict[str, str]:
|
|
53
|
+
"""Build the headers every request carries.
|
|
54
|
+
|
|
55
|
+
The agent plane does not *require* a bearer token — an agent authenticates
|
|
56
|
+
with the PASETO attestation *inside* the envelope, not at the transport layer.
|
|
57
|
+
This is here for a deployment that fronts the plane with a gateway that does.
|
|
58
|
+
"""
|
|
59
|
+
if bearer_token is None:
|
|
60
|
+
return {}
|
|
61
|
+
if not bearer_token.strip():
|
|
62
|
+
raise ConfigError("the bearer token is empty")
|
|
63
|
+
return {"authorization": f"Bearer {bearer_token}"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AxorumClient:
|
|
67
|
+
"""A typed, synchronous client for the Axorum agent plane.
|
|
68
|
+
|
|
69
|
+
Usable as a context manager, which is the recommended form — it closes the
|
|
70
|
+
connection pool on the way out::
|
|
71
|
+
|
|
72
|
+
with AxorumClient("http://127.0.0.1:8080") as client:
|
|
73
|
+
outcome = client.submit_pinned(draft)
|
|
74
|
+
|
|
75
|
+
# A refusal is a RETURN VALUE, not an exception. Branch on the
|
|
76
|
+
# outcome, never on a status code.
|
|
77
|
+
if outcome.posted:
|
|
78
|
+
print("recorded and posted")
|
|
79
|
+
else:
|
|
80
|
+
print(f"recorded as refused: {outcome.verdict}")
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
base_url: The service root, e.g. ``http://127.0.0.1:8080``.
|
|
84
|
+
bearer_token: Sent as ``Authorization: Bearer …`` on every request, for a
|
|
85
|
+
deployment that fronts the plane with a gateway that wants one.
|
|
86
|
+
timeout: The per-request timeout, in seconds.
|
|
87
|
+
transport: An httpx transport to use instead of the network — the seam
|
|
88
|
+
unit tests drive the client through.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ConfigError: if the base URL or the token is unusable.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
base_url: str,
|
|
97
|
+
*,
|
|
98
|
+
bearer_token: str | None = None,
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
100
|
+
transport: httpx.BaseTransport | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Build a client for the service rooted at `base_url`."""
|
|
103
|
+
self._base_url = normalize_base_url(base_url)
|
|
104
|
+
self._headers = _auth_headers(bearer_token)
|
|
105
|
+
self._http = httpx.Client(timeout=timeout, transport=transport)
|
|
106
|
+
|
|
107
|
+
def __enter__(self: _Self) -> _Self:
|
|
108
|
+
"""Enter the context manager."""
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(
|
|
112
|
+
self,
|
|
113
|
+
exc_type: type[BaseException] | None,
|
|
114
|
+
exc: BaseException | None,
|
|
115
|
+
traceback: TracebackType | None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Close the connection pool."""
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
"""Close the underlying connection pool."""
|
|
122
|
+
self._http.close()
|
|
123
|
+
|
|
124
|
+
def _send(self, request: Request) -> tuple[int, str]:
|
|
125
|
+
"""Send one request and return ``(status, body)`` — the only I/O in this class.
|
|
126
|
+
|
|
127
|
+
The body comes back as text, never pre-parsed, so a response that will not
|
|
128
|
+
decode can be shown to a human verbatim.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
response = self._http.request(
|
|
132
|
+
request.method,
|
|
133
|
+
request.url,
|
|
134
|
+
headers={**self._headers, **request.headers},
|
|
135
|
+
params=dict(request.params) or None,
|
|
136
|
+
json=request.json,
|
|
137
|
+
)
|
|
138
|
+
except httpx.HTTPError as failure:
|
|
139
|
+
raise TransportError(str(failure)) from failure
|
|
140
|
+
return response.status_code, response.text
|
|
141
|
+
|
|
142
|
+
def active_policy(self) -> str | None:
|
|
143
|
+
"""Read the id of the policy currently in force, or ``None`` if none is.
|
|
144
|
+
|
|
145
|
+
``None`` is **not** an error — the service answers ``200 {"policy": null}``,
|
|
146
|
+
and "no policy is in force" is a true and useful fact about a ledger, not a
|
|
147
|
+
failure to report one.
|
|
148
|
+
|
|
149
|
+
This is the endpoint to re-read after a ``409``; :meth:`submit_pinned` does
|
|
150
|
+
that for you.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
TransportError: if the service cannot be reached.
|
|
154
|
+
"""
|
|
155
|
+
status, body = self._send(build_active_policy(self._base_url))
|
|
156
|
+
return interpret_active_policy(status, body)
|
|
157
|
+
|
|
158
|
+
def submit_intent(self, envelope: IntentEnvelope) -> AgentOutcome:
|
|
159
|
+
"""Submit an intent envelope and return the ledger's outcome.
|
|
160
|
+
|
|
161
|
+
**A refusal is a return value.** If the policy in force forbids the action,
|
|
162
|
+
this returns an :class:`~axorum.AgentOutcome` with ``posted`` false and a
|
|
163
|
+
``ForbiddenRejected`` verdict, over a ``200``. The intent reached the commit
|
|
164
|
+
point, was judged, and the refusal was *recorded* — that record is the
|
|
165
|
+
product. Branch on ``posted``/``verdict``; an exception here means the
|
|
166
|
+
ledger never got to answer at all.
|
|
167
|
+
|
|
168
|
+
Re-submitting a ``transaction`` id that already committed replays its stored
|
|
169
|
+
outcome rather than double-posting — the id is the substrate's idempotency
|
|
170
|
+
key — which is what makes retrying a submission safe.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
StalePolicyError: if the pin did not hold. Use :meth:`submit_pinned` to
|
|
174
|
+
handle that automatically.
|
|
175
|
+
UnknownAgentError: if the ``agent://`` URI is not bound to a party.
|
|
176
|
+
AttestationError: if the capability attestation was rejected.
|
|
177
|
+
NotPrimaryError: if a cluster mutation reached a follower. This client
|
|
178
|
+
**never** follows the redirect for you.
|
|
179
|
+
CommitUnavailableError: if a cluster commit could not be confirmed.
|
|
180
|
+
TransportError: if the service cannot be reached.
|
|
181
|
+
"""
|
|
182
|
+
status, body = self._send(build_submit_intent(self._base_url, envelope))
|
|
183
|
+
return interpret_outcome(status, body)
|
|
184
|
+
|
|
185
|
+
def submit_pinned(self, draft: IntentDraft, *, max_pins: int = MAX_PINS) -> AgentOutcome:
|
|
186
|
+
"""Fetch the active policy, pin the draft to it, submit — and re-pin if it moved.
|
|
187
|
+
|
|
188
|
+
This is the loop every correct agent writes, so it ships in the client
|
|
189
|
+
rather than in each caller. It is safe to retry for one specific reason:
|
|
190
|
+
the ``transaction`` id was minted **once**, on the draft, before the first
|
|
191
|
+
attempt, and is the substrate's idempotency key. Every re-pinned attempt
|
|
192
|
+
carries the same id, so an attempt that in fact landed replays its stored
|
|
193
|
+
outcome. The loop cannot double-post.
|
|
194
|
+
|
|
195
|
+
A refusal is still a return value (see :meth:`submit_intent`), and a ``421``
|
|
196
|
+
is still raised: following a redirect to another replica is the caller's
|
|
197
|
+
decision, not the client's.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
NoActivePolicyError: if no policy is in force — there is nothing to pin to.
|
|
201
|
+
StalePolicyError: if the policy kept moving out from under `max_pins`
|
|
202
|
+
attempts.
|
|
203
|
+
AxorumError: anything :meth:`submit_intent` raises.
|
|
204
|
+
"""
|
|
205
|
+
policy = self.active_policy()
|
|
206
|
+
if policy is None:
|
|
207
|
+
raise NoActivePolicyError("no policy is in force, so an intent cannot be pinned to one")
|
|
208
|
+
return self.submit_pinned_from(draft, policy, max_pins=max_pins)
|
|
209
|
+
|
|
210
|
+
def submit_pinned_from(
|
|
211
|
+
self, draft: IntentDraft, policy: str, *, max_pins: int = MAX_PINS
|
|
212
|
+
) -> AgentOutcome:
|
|
213
|
+
""":meth:`submit_pinned`, but starting from a policy you already believe is in force.
|
|
214
|
+
|
|
215
|
+
This is the shape a long-running agent actually wants. Reading
|
|
216
|
+
``GET /policies/active`` before *every* submission is a round trip spent
|
|
217
|
+
re-learning something that changes rarely; an agent can hold the last policy
|
|
218
|
+
it saw, submit straight against it, and let the ``409`` tell it when its
|
|
219
|
+
belief went stale. The recovery is the same loop either way, so caching costs
|
|
220
|
+
nothing in correctness — the service remains the authority, it is simply
|
|
221
|
+
consulted only when it disagrees.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
StalePolicyError: if the policy kept moving out from under `max_pins`
|
|
225
|
+
attempts.
|
|
226
|
+
AxorumError: anything :meth:`submit_intent` raises.
|
|
227
|
+
"""
|
|
228
|
+
flow = pin_flow(draft, policy, max_pins=max_pins)
|
|
229
|
+
envelope = next(flow)
|
|
230
|
+
while True:
|
|
231
|
+
try:
|
|
232
|
+
return self.submit_intent(envelope)
|
|
233
|
+
except StalePolicyError as failure:
|
|
234
|
+
envelope = flow.send(failure)
|
|
235
|
+
|
|
236
|
+
def transaction(self, transaction: str) -> TransactionRecord:
|
|
237
|
+
"""Read the stored record for a committed transaction.
|
|
238
|
+
|
|
239
|
+
Reading it is the idempotent replay lookup: it tells you what a
|
|
240
|
+
previously-submitted ``transaction`` id did, **without resubmitting it**. A
|
|
241
|
+
recorded refusal is here like any other record, with ``posted`` false.
|
|
242
|
+
|
|
243
|
+
The record is contractually opaque in this version of the wire contract, so
|
|
244
|
+
it comes back as parsed JSON.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ApiError: with code ``unknown_transaction`` (404) if there is no such
|
|
248
|
+
transaction, or ``invalid_transaction_id`` (400) if that is not one.
|
|
249
|
+
TransportError: if the service cannot be reached.
|
|
250
|
+
"""
|
|
251
|
+
status, body = self._send(build_transaction(self._base_url, transaction))
|
|
252
|
+
return interpret_transaction(status, body)
|
|
253
|
+
|
|
254
|
+
def obligations(self, actor: str) -> ObligationsResponse:
|
|
255
|
+
"""Read the open obligations an actor owes.
|
|
256
|
+
|
|
257
|
+
`actor` may be a party id (``party_…``) **or** a DSL party name; the service
|
|
258
|
+
resolves either, and echoes back exactly what you supplied. An unknown or
|
|
259
|
+
duty-free actor is not an error — it answers with an empty list.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
TransportError: if the service cannot be reached.
|
|
263
|
+
"""
|
|
264
|
+
status, body = self._send(build_obligations(self._base_url, actor))
|
|
265
|
+
return interpret_obligations(status, body)
|
|
266
|
+
|
|
267
|
+
def balance(self, account: str) -> BalanceResponse:
|
|
268
|
+
"""Read an account's net balance, in minor units.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
ApiError: with code ``unknown_account`` (404) if there is no such account,
|
|
272
|
+
or ``invalid_account_id`` (400) if that is not one.
|
|
273
|
+
TransportError: if the service cannot be reached.
|
|
274
|
+
"""
|
|
275
|
+
status, body = self._send(build_balance(self._base_url, account))
|
|
276
|
+
return interpret_balance(status, body)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class AsyncAxorumClient:
|
|
280
|
+
"""A typed, asynchronous client for the Axorum agent plane.
|
|
281
|
+
|
|
282
|
+
The same client as :class:`AxorumClient`, awaited. Every method has the same
|
|
283
|
+
name, the same arguments, and the same semantics — above all the one that
|
|
284
|
+
matters: **a refusal is a return value, not an exception**::
|
|
285
|
+
|
|
286
|
+
async with AsyncAxorumClient("http://127.0.0.1:8080") as client:
|
|
287
|
+
outcome = await client.submit_pinned(draft)
|
|
288
|
+
if not outcome.posted:
|
|
289
|
+
print(f"recorded as refused: {outcome.verdict}")
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
base_url: The service root, e.g. ``http://127.0.0.1:8080``.
|
|
293
|
+
bearer_token: Sent as ``Authorization: Bearer …`` on every request.
|
|
294
|
+
timeout: The per-request timeout, in seconds.
|
|
295
|
+
transport: An httpx transport to use instead of the network.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ConfigError: if the base URL or the token is unusable.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
base_url: str,
|
|
304
|
+
*,
|
|
305
|
+
bearer_token: str | None = None,
|
|
306
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
307
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Build a client for the service rooted at `base_url`."""
|
|
310
|
+
self._base_url = normalize_base_url(base_url)
|
|
311
|
+
self._headers = _auth_headers(bearer_token)
|
|
312
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
313
|
+
|
|
314
|
+
async def __aenter__(self: _AsyncSelf) -> _AsyncSelf:
|
|
315
|
+
"""Enter the async context manager."""
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
async def __aexit__(
|
|
319
|
+
self,
|
|
320
|
+
exc_type: type[BaseException] | None,
|
|
321
|
+
exc: BaseException | None,
|
|
322
|
+
traceback: TracebackType | None,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Close the connection pool."""
|
|
325
|
+
await self.aclose()
|
|
326
|
+
|
|
327
|
+
async def aclose(self) -> None:
|
|
328
|
+
"""Close the underlying connection pool."""
|
|
329
|
+
await self._http.aclose()
|
|
330
|
+
|
|
331
|
+
async def _send(self, request: Request) -> tuple[int, str]:
|
|
332
|
+
"""Send one request and return ``(status, body)`` — the only I/O in this class."""
|
|
333
|
+
try:
|
|
334
|
+
response = await self._http.request(
|
|
335
|
+
request.method,
|
|
336
|
+
request.url,
|
|
337
|
+
headers={**self._headers, **request.headers},
|
|
338
|
+
params=dict(request.params) or None,
|
|
339
|
+
json=request.json,
|
|
340
|
+
)
|
|
341
|
+
except httpx.HTTPError as failure:
|
|
342
|
+
raise TransportError(str(failure)) from failure
|
|
343
|
+
return response.status_code, response.text
|
|
344
|
+
|
|
345
|
+
async def active_policy(self) -> str | None:
|
|
346
|
+
"""Read the id of the policy currently in force, or ``None`` if none is.
|
|
347
|
+
|
|
348
|
+
``None`` is **not** an error. See :meth:`AxorumClient.active_policy`.
|
|
349
|
+
"""
|
|
350
|
+
status, body = await self._send(build_active_policy(self._base_url))
|
|
351
|
+
return interpret_active_policy(status, body)
|
|
352
|
+
|
|
353
|
+
async def submit_intent(self, envelope: IntentEnvelope) -> AgentOutcome:
|
|
354
|
+
"""Submit an intent envelope and return the ledger's outcome.
|
|
355
|
+
|
|
356
|
+
**A refusal is a return value.** See :meth:`AxorumClient.submit_intent`.
|
|
357
|
+
"""
|
|
358
|
+
status, body = await self._send(build_submit_intent(self._base_url, envelope))
|
|
359
|
+
return interpret_outcome(status, body)
|
|
360
|
+
|
|
361
|
+
async def submit_pinned(self, draft: IntentDraft, *, max_pins: int = MAX_PINS) -> AgentOutcome:
|
|
362
|
+
"""Fetch the active policy, pin the draft to it, submit — and re-pin if it moved.
|
|
363
|
+
|
|
364
|
+
See :meth:`AxorumClient.submit_pinned`.
|
|
365
|
+
"""
|
|
366
|
+
policy = await self.active_policy()
|
|
367
|
+
if policy is None:
|
|
368
|
+
raise NoActivePolicyError("no policy is in force, so an intent cannot be pinned to one")
|
|
369
|
+
return await self.submit_pinned_from(draft, policy, max_pins=max_pins)
|
|
370
|
+
|
|
371
|
+
async def submit_pinned_from(
|
|
372
|
+
self, draft: IntentDraft, policy: str, *, max_pins: int = MAX_PINS
|
|
373
|
+
) -> AgentOutcome:
|
|
374
|
+
""":meth:`submit_pinned`, but starting from a policy you already believe is in force.
|
|
375
|
+
|
|
376
|
+
See :meth:`AxorumClient.submit_pinned_from`.
|
|
377
|
+
"""
|
|
378
|
+
flow = pin_flow(draft, policy, max_pins=max_pins)
|
|
379
|
+
envelope = next(flow)
|
|
380
|
+
while True:
|
|
381
|
+
try:
|
|
382
|
+
return await self.submit_intent(envelope)
|
|
383
|
+
except StalePolicyError as failure:
|
|
384
|
+
envelope = flow.send(failure)
|
|
385
|
+
|
|
386
|
+
async def transaction(self, transaction: str) -> TransactionRecord:
|
|
387
|
+
"""Read the stored record for a committed transaction.
|
|
388
|
+
|
|
389
|
+
See :meth:`AxorumClient.transaction`.
|
|
390
|
+
"""
|
|
391
|
+
status, body = await self._send(build_transaction(self._base_url, transaction))
|
|
392
|
+
return interpret_transaction(status, body)
|
|
393
|
+
|
|
394
|
+
async def obligations(self, actor: str) -> ObligationsResponse:
|
|
395
|
+
"""Read the open obligations an actor owes.
|
|
396
|
+
|
|
397
|
+
See :meth:`AxorumClient.obligations`.
|
|
398
|
+
"""
|
|
399
|
+
status, body = await self._send(build_obligations(self._base_url, actor))
|
|
400
|
+
return interpret_obligations(status, body)
|
|
401
|
+
|
|
402
|
+
async def balance(self, account: str) -> BalanceResponse:
|
|
403
|
+
"""Read an account's net balance, in minor units.
|
|
404
|
+
|
|
405
|
+
See :meth:`AxorumClient.balance`.
|
|
406
|
+
"""
|
|
407
|
+
status, body = await self._send(build_balance(self._base_url, account))
|
|
408
|
+
return interpret_balance(status, body)
|