aex-sdk 1.2.0a1__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.
aex_sdk/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Agent Exchange Protocol (AEX) — Python SDK."""
2
+
3
+ from aex_sdk.client import DataPlaneTicket, SpizeClient, TransferResponse
4
+ from aex_sdk.errors import SpizeError, SpizeHTTPError
5
+ from aex_sdk.identity import Identity
6
+
7
+ __all__ = [
8
+ "DataPlaneTicket",
9
+ "Identity",
10
+ "SpizeClient",
11
+ "SpizeError",
12
+ "SpizeHTTPError",
13
+ "TransferResponse",
14
+ ]
15
+
16
+ __version__ = "1.2.0a1"
aex_sdk/client.py ADDED
@@ -0,0 +1,333 @@
1
+ """Synchronous HTTP client for the Spize control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import json
11
+ import httpx
12
+
13
+ from aex_sdk.errors import SpizeError, SpizeHTTPError
14
+ from aex_sdk.identity import Identity, random_nonce
15
+ from aex_sdk.wire import (
16
+ registration_challenge_bytes,
17
+ transfer_intent_bytes,
18
+ transfer_receipt_bytes,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class TransferResponse:
24
+ transfer_id: str
25
+ state: str
26
+ sender_agent_id: str
27
+ recipient: str
28
+ size_bytes: int
29
+ declared_mime: Optional[str]
30
+ filename: Optional[str]
31
+ scanner_verdict: Optional[dict[str, Any]]
32
+ policy_decision: Optional[dict[str, Any]]
33
+ rejection_code: Optional[str]
34
+ rejection_reason: Optional[str]
35
+
36
+ @classmethod
37
+ def from_json(cls, body: dict[str, Any]) -> "TransferResponse":
38
+ return cls(
39
+ transfer_id=body["transfer_id"],
40
+ state=body["state"],
41
+ sender_agent_id=body["sender_agent_id"],
42
+ recipient=body["recipient"],
43
+ size_bytes=int(body["size_bytes"]),
44
+ declared_mime=body.get("declared_mime"),
45
+ filename=body.get("filename"),
46
+ scanner_verdict=body.get("scanner_verdict"),
47
+ policy_decision=body.get("policy_decision"),
48
+ rejection_code=body.get("rejection_code"),
49
+ rejection_reason=body.get("rejection_reason"),
50
+ )
51
+
52
+ @property
53
+ def was_delivered(self) -> bool:
54
+ return self.state == "delivered"
55
+
56
+ @property
57
+ def was_rejected(self) -> bool:
58
+ return self.state == "rejected"
59
+
60
+
61
+ class SpizeClient:
62
+ """Thin wrapper over the control-plane REST API.
63
+
64
+ The client is stateless beyond `base_url` + `identity`; each call
65
+ builds a fresh nonce and signs the canonical payload. Reuses an
66
+ httpx.Client for connection pooling.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ base_url: str,
72
+ identity: Identity,
73
+ *,
74
+ timeout: float = 30.0,
75
+ ) -> None:
76
+ self.base_url = base_url.rstrip("/")
77
+ self.identity = identity
78
+ self._http = httpx.Client(base_url=self.base_url, timeout=timeout)
79
+
80
+ def close(self) -> None:
81
+ self._http.close()
82
+
83
+ def __enter__(self) -> "SpizeClient":
84
+ return self
85
+
86
+ def __exit__(self, *exc) -> None: # noqa: D401 - context manager boilerplate
87
+ self.close()
88
+
89
+ # ------------------------------ health ------------------------------
90
+
91
+ def health(self) -> dict[str, Any]:
92
+ r = self._http.get("/healthz")
93
+ self._raise_for_status(r)
94
+ return r.json()
95
+
96
+ # ---------------------------- registration ---------------------------
97
+
98
+ def register(self) -> dict[str, Any]:
99
+ """Register this identity with the control plane (idempotent-safe:
100
+ re-registering the same public key returns 409 Conflict — callers
101
+ can treat that as 'already registered')."""
102
+ issued_at = int(time.time())
103
+ nonce = random_nonce()
104
+ challenge = registration_challenge_bytes(
105
+ self.identity.public_key_hex,
106
+ self.identity.org,
107
+ self.identity.name,
108
+ nonce,
109
+ issued_at,
110
+ )
111
+ signature = self.identity.sign(challenge)
112
+ payload = {
113
+ "public_key_hex": self.identity.public_key_hex,
114
+ "org": self.identity.org,
115
+ "name": self.identity.name,
116
+ "nonce": nonce,
117
+ "issued_at": issued_at,
118
+ "signature_hex": signature.hex(),
119
+ }
120
+ r = self._http.post("/v1/agents/register", json=payload)
121
+ self._raise_for_status(r)
122
+ return r.json()
123
+
124
+ def get_agent(self, agent_id: str) -> dict[str, Any]:
125
+ r = self._http.get(f"/v1/agents/{agent_id}")
126
+ self._raise_for_status(r)
127
+ return r.json()
128
+
129
+ # ------------------------------- send -------------------------------
130
+
131
+ def send(
132
+ self,
133
+ recipient: str,
134
+ *,
135
+ data: Optional[bytes] = None,
136
+ file: Optional[str | Path] = None,
137
+ declared_mime: str = "",
138
+ filename: str = "",
139
+ ) -> TransferResponse:
140
+ """Initiate a transfer. Provide exactly one of `data` or `file`."""
141
+ if (data is None) == (file is None):
142
+ raise SpizeError("pass exactly one of data= or file=")
143
+ if file is not None:
144
+ p = Path(file)
145
+ data = p.read_bytes()
146
+ if not filename:
147
+ filename = p.name
148
+ assert data is not None
149
+
150
+ issued_at = int(time.time())
151
+ nonce = random_nonce()
152
+ canonical = transfer_intent_bytes(
153
+ self.identity.agent_id,
154
+ recipient,
155
+ len(data),
156
+ declared_mime,
157
+ filename,
158
+ nonce,
159
+ issued_at,
160
+ )
161
+ signature = self.identity.sign(canonical)
162
+ payload = {
163
+ "sender_agent_id": self.identity.agent_id,
164
+ "recipient": recipient,
165
+ "declared_mime": declared_mime,
166
+ "filename": filename,
167
+ "nonce": nonce,
168
+ "issued_at": issued_at,
169
+ "intent_signature_hex": signature.hex(),
170
+ "blob_hex": data.hex(),
171
+ }
172
+ r = self._http.post("/v1/transfers", json=payload)
173
+ self._raise_for_status(r)
174
+ return TransferResponse.from_json(r.json())
175
+
176
+ # ----------------------------- receive ------------------------------
177
+
178
+ def send_via_tunnel(
179
+ self,
180
+ *,
181
+ recipient: str,
182
+ declared_size: int,
183
+ declared_mime: str,
184
+ filename: str,
185
+ tunnel_url: str,
186
+ ) -> TransferResponse:
187
+ """M2: announce a transfer without uploading bytes. The sender
188
+ must serve the blob via `tunnel_url` (a data-plane URL)."""
189
+ if not self.identity:
190
+ raise ValueError("client has no identity")
191
+ nonce = random_nonce()
192
+ issued_at = int(time.time())
193
+ intent = transfer_intent_bytes(
194
+ sender_agent_id=self.identity.agent_id,
195
+ recipient=recipient,
196
+ size_bytes=declared_size,
197
+ declared_mime=declared_mime,
198
+ filename=filename,
199
+ nonce=nonce,
200
+ issued_at_unix=issued_at,
201
+ )
202
+ sig = self.identity.sign(intent)
203
+ payload = {
204
+ "sender_agent_id": self.identity.agent_id,
205
+ "recipient": recipient,
206
+ "declared_mime": declared_mime,
207
+ "filename": filename,
208
+ "nonce": nonce,
209
+ "issued_at": issued_at,
210
+ "intent_signature_hex": sig.hex(),
211
+ "blob_hex": "",
212
+ "tunnel_url": tunnel_url,
213
+ "declared_size": declared_size,
214
+ }
215
+ r = self._http.post("/v1/transfers", json=payload)
216
+ self._raise_for_status(r)
217
+ return TransferResponse.from_json(r.json())
218
+
219
+ def get_transfer(self, transfer_id: str) -> TransferResponse:
220
+ r = self._http.get(f"/v1/transfers/{transfer_id}")
221
+ self._raise_for_status(r)
222
+ return TransferResponse.from_json(r.json())
223
+
224
+ def download(self, transfer_id: str) -> bytes:
225
+ """Download the blob bytes. Must be called by the declared
226
+ recipient (signature bound to the recipient's identity)."""
227
+ body = self._build_receipt(transfer_id, "download")
228
+ r = self._http.post(f"/v1/transfers/{transfer_id}/download", json=body)
229
+ self._raise_for_status(r)
230
+ return bytes.fromhex(r.json()["blob_hex"])
231
+
232
+ def ack(self, transfer_id: str) -> dict[str, Any]:
233
+ """Acknowledge delivery. The returned `audit_chain_head` is proof
234
+ the delivery was logged at this chain position."""
235
+ body = self._build_receipt(transfer_id, "ack")
236
+ r = self._http.post(f"/v1/transfers/{transfer_id}/ack", json=body)
237
+ self._raise_for_status(r)
238
+ return r.json()
239
+
240
+ def request_ticket(self, transfer_id: str) -> "DataPlaneTicket":
241
+ """M2: request a signed data-plane ticket to fetch the blob directly
242
+ from the sender's tunnel, bypassing the control plane for payload.
243
+ Requires the transfer to be in ``ready_for_pickup`` with a tunnel_url.
244
+ """
245
+ receipt = self._build_receipt(transfer_id, "request_ticket")
246
+ r = self._http.post(
247
+ f"/v1/transfers/{transfer_id}/ticket",
248
+ json=receipt,
249
+ )
250
+ self._raise_for_status(r)
251
+ body = r.json()
252
+ return DataPlaneTicket(
253
+ transfer_id=body["transfer_id"],
254
+ recipient=body["recipient"],
255
+ data_plane_url=body["data_plane_url"],
256
+ expires=int(body["expires"]),
257
+ nonce=body["nonce"],
258
+ signature=body["signature"],
259
+ )
260
+
261
+ def fetch_from_tunnel(self, ticket: "DataPlaneTicket") -> bytes:
262
+ """M2: fetch blob bytes from the sender's data plane using a ticket."""
263
+ r = httpx.get(
264
+ f"{ticket.data_plane_url}/blob/{ticket.transfer_id}",
265
+ headers={"X-AEX-Ticket": ticket.as_header()},
266
+ timeout=30.0,
267
+ )
268
+ self._raise_for_status(r)
269
+ return r.content
270
+
271
+ def inbox(self) -> dict[str, Any]:
272
+ """List transfers waiting for this identity (state:
273
+ `ready_for_pickup` or `accepted`). Capped at 100 most recent rows."""
274
+ body = self._build_receipt("inbox", "inbox")
275
+ r = self._http.post("/v1/inbox", json=body)
276
+ self._raise_for_status(r)
277
+ return r.json()
278
+
279
+ def _build_receipt(self, transfer_id: str, action: str) -> dict[str, Any]:
280
+ issued_at = int(time.time())
281
+ nonce = random_nonce()
282
+ canonical = transfer_receipt_bytes(
283
+ self.identity.agent_id, transfer_id, action, nonce, issued_at
284
+ )
285
+ signature = self.identity.sign(canonical)
286
+ return {
287
+ "recipient_agent_id": self.identity.agent_id,
288
+ "nonce": nonce,
289
+ "issued_at": issued_at,
290
+ "signature_hex": signature.hex(),
291
+ }
292
+
293
+ # ------------------------------ helpers ------------------------------
294
+
295
+ @staticmethod
296
+ def _raise_for_status(r: httpx.Response) -> None:
297
+ if r.is_success:
298
+ return
299
+ try:
300
+ body = r.json()
301
+ except Exception:
302
+ body = {}
303
+ raise SpizeHTTPError(
304
+ status_code=r.status_code,
305
+ code=body.get("code"),
306
+ message=body.get("message") or r.text or "unknown error",
307
+ )
308
+
309
+
310
+ # ---------- M2 additions ----------
311
+
312
+ @dataclass(frozen=True)
313
+ class DataPlaneTicket:
314
+ transfer_id: str
315
+ recipient: str
316
+ data_plane_url: str
317
+ expires: int
318
+ nonce: str
319
+ signature: str
320
+
321
+ def as_header(self) -> str:
322
+ """JSON-encoded ticket for the `X-AEX-Ticket` header."""
323
+ return json.dumps(
324
+ {
325
+ "transfer_id": self.transfer_id,
326
+ "recipient": self.recipient,
327
+ "data_plane_url": self.data_plane_url,
328
+ "expires": self.expires,
329
+ "nonce": self.nonce,
330
+ "signature": self.signature,
331
+ },
332
+ separators=(",", ":"),
333
+ )
aex_sdk/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Spize SDK exception hierarchy."""
2
+
3
+
4
+ class SpizeError(Exception):
5
+ """Root class for SDK errors."""
6
+
7
+
8
+ class SpizeHTTPError(SpizeError):
9
+ """Raised when the control plane returns a non-2xx response."""
10
+
11
+ def __init__(self, status_code: int, code: str | None, message: str) -> None:
12
+ super().__init__(f"[{status_code}] {code or 'error'}: {message}")
13
+ self.status_code = status_code
14
+ self.code = code
15
+ self.message = message
16
+
17
+
18
+ class IdentityError(SpizeError):
19
+ """Raised for identity-file corruption or mismatched keys."""
aex_sdk/identity.py ADDED
@@ -0,0 +1,173 @@
1
+ """Spize-native Ed25519 identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import secrets
9
+ import string
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from cryptography.hazmat.primitives import serialization
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
16
+ Ed25519PrivateKey,
17
+ Ed25519PublicKey,
18
+ )
19
+
20
+ from aex_sdk.errors import IdentityError
21
+
22
+ _LABEL_ALPHABET = set(string.ascii_letters + string.digits + "-_")
23
+ _LABEL_MAX = 64
24
+
25
+
26
+ def _validate_label(s: str, field: str) -> None:
27
+ if not s:
28
+ raise IdentityError(f"{field} is empty")
29
+ if len(s) > _LABEL_MAX:
30
+ raise IdentityError(f"{field} exceeds {_LABEL_MAX} chars")
31
+ for c in s:
32
+ if c not in _LABEL_ALPHABET:
33
+ raise IdentityError(
34
+ f"{field} must match [a-zA-Z0-9_-]+, got {c!r}"
35
+ )
36
+
37
+
38
+ def _compute_fingerprint(public_key_bytes: bytes) -> str:
39
+ """First 3 bytes of SHA-256 over the public key, hex-encoded."""
40
+ return hashlib.sha256(public_key_bytes).digest()[:3].hex()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Identity:
45
+ """Ed25519 keypair + canonical Spize agent_id."""
46
+
47
+ org: str
48
+ name: str
49
+ private_key_bytes: bytes # 32 bytes
50
+ public_key_bytes: bytes # 32 bytes
51
+
52
+ @classmethod
53
+ def generate(cls, org: str, name: str) -> "Identity":
54
+ _validate_label(org, "org")
55
+ _validate_label(name, "name")
56
+ private_key = Ed25519PrivateKey.generate()
57
+ private_bytes = private_key.private_bytes(
58
+ encoding=serialization.Encoding.Raw,
59
+ format=serialization.PrivateFormat.Raw,
60
+ encryption_algorithm=serialization.NoEncryption(),
61
+ )
62
+ public_bytes = private_key.public_key().public_bytes(
63
+ encoding=serialization.Encoding.Raw,
64
+ format=serialization.PublicFormat.Raw,
65
+ )
66
+ return cls(org=org, name=name, private_key_bytes=private_bytes, public_key_bytes=public_bytes)
67
+
68
+ @classmethod
69
+ def from_secret(cls, org: str, name: str, private_key_bytes: bytes) -> "Identity":
70
+ _validate_label(org, "org")
71
+ _validate_label(name, "name")
72
+ if len(private_key_bytes) != 32:
73
+ raise IdentityError(f"Ed25519 secret must be 32 bytes, got {len(private_key_bytes)}")
74
+ private_key = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
75
+ public_bytes = private_key.public_key().public_bytes(
76
+ encoding=serialization.Encoding.Raw,
77
+ format=serialization.PublicFormat.Raw,
78
+ )
79
+ return cls(org=org, name=name, private_key_bytes=private_key_bytes, public_key_bytes=public_bytes)
80
+
81
+ # ---------- derived properties ----------
82
+
83
+ @property
84
+ def fingerprint(self) -> str:
85
+ return _compute_fingerprint(self.public_key_bytes)
86
+
87
+ @property
88
+ def agent_id(self) -> str:
89
+ return f"spize:{self.org}/{self.name}:{self.fingerprint}"
90
+
91
+ @property
92
+ def public_key_hex(self) -> str:
93
+ return self.public_key_bytes.hex()
94
+
95
+ # ---------- signing ----------
96
+
97
+ def sign(self, message: bytes) -> bytes:
98
+ return Ed25519PrivateKey.from_private_bytes(self.private_key_bytes).sign(message)
99
+
100
+ # ---------- persistence ----------
101
+
102
+ def save(self, path: str | os.PathLike, *, overwrite: bool = False) -> None:
103
+ """Persist the identity to a JSON file with 0600 perms.
104
+
105
+ Write pattern: write to a sibling tmp file → fsync → rename. This
106
+ guarantees the final path either contains the full, valid JSON or
107
+ nothing at all — a crash during save cannot leave a truncated key
108
+ file that re-opens as corrupt.
109
+ """
110
+ p = Path(path)
111
+ if p.exists() and not overwrite:
112
+ raise IdentityError(f"{p} already exists; pass overwrite=True to replace")
113
+ payload = {
114
+ "version": 1,
115
+ "org": self.org,
116
+ "name": self.name,
117
+ "private_key_hex": self.private_key_bytes.hex(),
118
+ "public_key_hex": self.public_key_bytes.hex(),
119
+ "agent_id": self.agent_id,
120
+ }
121
+ data = json.dumps(payload, indent=2).encode("utf-8")
122
+
123
+ tmp = p.with_name(p.name + ".tmp")
124
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
125
+ fd = os.open(tmp, flags, 0o600)
126
+ try:
127
+ with os.fdopen(fd, "wb") as f:
128
+ f.write(data)
129
+ f.flush()
130
+ os.fsync(f.fileno())
131
+ os.replace(tmp, p)
132
+ except Exception:
133
+ try:
134
+ tmp.unlink(missing_ok=True)
135
+ except OSError:
136
+ pass
137
+ raise
138
+
139
+ @classmethod
140
+ def load(cls, path: str | os.PathLike) -> "Identity":
141
+ p = Path(path)
142
+ with open(p, "rb") as f:
143
+ payload = json.loads(f.read().decode("utf-8"))
144
+ if payload.get("version") != 1:
145
+ raise IdentityError(f"unsupported identity file version: {payload.get('version')}")
146
+ try:
147
+ org = payload["org"]
148
+ name = payload["name"]
149
+ private_key_hex = payload["private_key_hex"]
150
+ except KeyError as e:
151
+ raise IdentityError(f"missing field in identity file: {e.args[0]}") from e
152
+
153
+ identity = cls.from_secret(org, name, bytes.fromhex(private_key_hex))
154
+ # Sanity: stored public/agent_id should match derived values.
155
+ if "public_key_hex" in payload and payload["public_key_hex"] != identity.public_key_hex:
156
+ raise IdentityError("stored public_key_hex does not match derived public key")
157
+ if "agent_id" in payload and payload["agent_id"] != identity.agent_id:
158
+ raise IdentityError("stored agent_id does not match derived agent_id")
159
+ return identity
160
+
161
+
162
+ def random_nonce(byte_length: int = 16) -> str:
163
+ """Hex nonce with `byte_length` bytes of entropy."""
164
+ return secrets.token_hex(byte_length)
165
+
166
+
167
+ def verify_signature(public_key_bytes: bytes, message: bytes, signature: bytes) -> bool:
168
+ """Verify an Ed25519 signature; returns True/False without raising."""
169
+ try:
170
+ Ed25519PublicKey.from_public_bytes(public_key_bytes).verify(signature, message)
171
+ return True
172
+ except Exception:
173
+ return False
aex_sdk/py.typed ADDED
File without changes
aex_sdk/wire.py ADDED
@@ -0,0 +1,106 @@
1
+ """Canonical wire-format functions.
2
+
3
+ These MUST produce byte-for-byte identical output to the corresponding
4
+ Rust functions in ``aex_core::wire``. The test suite in
5
+ ``tests/test_wire.py`` checks this against the golden vectors exported
6
+ from the Rust tests — DO NOT modify without updating both sides
7
+ together.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ PROTOCOL_VERSION = "v1"
13
+ MAX_CLOCK_SKEW_SECS = 300
14
+ MIN_NONCE_LEN = 32
15
+ MAX_NONCE_LEN = 128
16
+
17
+
18
+ def _validate_ascii_line(s: str, field: str, *, allow_empty: bool = False) -> None:
19
+ if not s:
20
+ if allow_empty:
21
+ return
22
+ raise ValueError(f"{field} is empty")
23
+ for i, c in enumerate(s):
24
+ if ord(c) > 127 or c in ("\n", "\r", "\0"):
25
+ raise ValueError(f"{field} has invalid char at {i}: {c!r}")
26
+
27
+
28
+ def _validate_nonce(nonce: str) -> None:
29
+ if not (MIN_NONCE_LEN <= len(nonce) <= MAX_NONCE_LEN):
30
+ raise ValueError(
31
+ f"nonce length {len(nonce)} outside [{MIN_NONCE_LEN}, {MAX_NONCE_LEN}]"
32
+ )
33
+ if not all(c in "0123456789abcdefABCDEF" for c in nonce):
34
+ raise ValueError("nonce must be hex")
35
+
36
+
37
+ def registration_challenge_bytes(
38
+ public_key_hex: str,
39
+ org: str,
40
+ name: str,
41
+ nonce: str,
42
+ issued_at_unix: int,
43
+ ) -> bytes:
44
+ _validate_ascii_line(public_key_hex, "public_key_hex")
45
+ _validate_ascii_line(org, "org")
46
+ _validate_ascii_line(name, "name")
47
+ _validate_nonce(nonce)
48
+ return (
49
+ f"spize-register:{PROTOCOL_VERSION}\n"
50
+ f"pub={public_key_hex}\n"
51
+ f"org={org}\n"
52
+ f"name={name}\n"
53
+ f"nonce={nonce}\n"
54
+ f"ts={issued_at_unix}"
55
+ ).encode("ascii")
56
+
57
+
58
+ def transfer_intent_bytes(
59
+ sender_agent_id: str,
60
+ recipient: str,
61
+ size_bytes: int,
62
+ declared_mime: str,
63
+ filename: str,
64
+ nonce: str,
65
+ issued_at_unix: int,
66
+ ) -> bytes:
67
+ _validate_ascii_line(sender_agent_id, "sender_agent_id")
68
+ _validate_ascii_line(recipient, "recipient")
69
+ _validate_ascii_line(declared_mime, "declared_mime", allow_empty=True)
70
+ _validate_ascii_line(filename, "filename", allow_empty=True)
71
+ _validate_nonce(nonce)
72
+ return (
73
+ f"spize-transfer-intent:{PROTOCOL_VERSION}\n"
74
+ f"sender={sender_agent_id}\n"
75
+ f"recipient={recipient}\n"
76
+ f"size={size_bytes}\n"
77
+ f"mime={declared_mime}\n"
78
+ f"filename={filename}\n"
79
+ f"nonce={nonce}\n"
80
+ f"ts={issued_at_unix}"
81
+ ).encode("ascii")
82
+
83
+
84
+ def transfer_receipt_bytes(
85
+ recipient_agent_id: str,
86
+ transfer_id: str,
87
+ action: str,
88
+ nonce: str,
89
+ issued_at_unix: int,
90
+ ) -> bytes:
91
+ _validate_ascii_line(recipient_agent_id, "recipient_agent_id")
92
+ _validate_ascii_line(transfer_id, "transfer_id")
93
+ _validate_ascii_line(action, "action")
94
+ _validate_nonce(nonce)
95
+ if action not in ("download", "ack", "inbox", "request_ticket"):
96
+ raise ValueError(
97
+ f"action must be 'download', 'ack', 'inbox' or 'request_ticket', got {action}"
98
+ )
99
+ return (
100
+ f"spize-transfer-receipt:{PROTOCOL_VERSION}\n"
101
+ f"recipient={recipient_agent_id}\n"
102
+ f"transfer={transfer_id}\n"
103
+ f"action={action}\n"
104
+ f"nonce={nonce}\n"
105
+ f"ts={issued_at_unix}"
106
+ ).encode("ascii")
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: aex-sdk
3
+ Version: 1.2.0a1
4
+ Summary: Python SDK for AEX — the Agent Exchange Protocol.
5
+ Author-email: Icaro Holding <oss@spize.ai>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://spize.ai
8
+ Project-URL: Repository, https://github.com/icaroholding/spize
9
+ Project-URL: Documentation, https://github.com/icaroholding/spize/tree/master/docs
10
+ Keywords: aex,agent,protocol,identity,transfer,p2p
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: cryptography>=42
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
24
+ Requires-Dist: ruff>=0.3; extra == "dev"
25
+ Requires-Dist: mypy>=1.8; extra == "dev"
26
+
27
+ # spize (Python SDK)
28
+
29
+ Python client for the [Agent Exchange Protocol (AEX)](https://github.com/icaroholding/spize).
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install spize
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from spize import Identity, SpizeClient
41
+
42
+ # One-time: create + register an identity.
43
+ identity = Identity.generate(org="acme", name="alice")
44
+ identity.save("alice.key")
45
+
46
+ client = SpizeClient(base_url="http://localhost:8080", identity=identity)
47
+ client.register()
48
+
49
+ # Send.
50
+ transfer = client.send(
51
+ recipient="spize:acme/bob:aabbcc",
52
+ file="invoice.pdf",
53
+ declared_mime="application/pdf",
54
+ )
55
+ print(transfer.state) # 'ready_for_pickup' or 'rejected'
56
+
57
+ # Receive (as Bob).
58
+ bob = Identity.load("bob.key")
59
+ bob_client = SpizeClient(base_url="http://localhost:8080", identity=bob)
60
+ bytes_in = bob_client.download(transfer.transfer_id)
61
+ bob_client.ack(transfer.transfer_id)
62
+ ```
63
+
64
+ ## Components
65
+
66
+ - `Identity` — Ed25519 keypair + canonical agent_id derivation. Save/load to disk.
67
+ - `SpizeClient` — thin HTTP wrapper over the control plane. Handles signing + replay nonces.
68
+ - `wire` — canonical byte functions that mirror `spize_core::wire` exactly; change only in lockstep.
@@ -0,0 +1,10 @@
1
+ aex_sdk/__init__.py,sha256=eW0QJckAFRD-KEZvbVbaET8qyHEv9ikyQ97Wti1vUbA,381
2
+ aex_sdk/client.py,sha256=gaTbCGqIHFHNcYDwaGJdfwtQr7D5ShXf-l_rmFGg28A,11126
3
+ aex_sdk/errors.py,sha256=j6Is0kXzcvYjeEpmPmXaZW3LpxkcOMvgDSMGQjW6ZYk,562
4
+ aex_sdk/identity.py,sha256=C-gdh-Q0SaDWeKzHsjRaix_mvl_OEhJHoPCXDYhEEG8,6210
5
+ aex_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ aex_sdk/wire.py,sha256=9llhKZfHQ-UViUJYGGqYWH1PapkZkgxLh3qMJr-N6qs,3222
7
+ aex_sdk-1.2.0a1.dist-info/METADATA,sha256=n5eLqb5nnEkFiVm5TQKa758iu1XCFtuBdwFkoPqfA7w,2195
8
+ aex_sdk-1.2.0a1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ aex_sdk-1.2.0a1.dist-info/top_level.txt,sha256=c45JG6I1zKEI6nO9YuYfT3XTG82BzFfRT9m0M35vEhk,8
10
+ aex_sdk-1.2.0a1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aex_sdk