archerprotocol-sdk 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.
- archer_sdk/__init__.py +55 -0
- archer_sdk/builders.py +149 -0
- archer_sdk/caller.py +39 -0
- archer_sdk/effects.py +306 -0
- archer_sdk/embed.py +164 -0
- archer_sdk/errors.py +19 -0
- archer_sdk/fastapi.py +58 -0
- archer_sdk/handler.py +85 -0
- archer_sdk/money.py +33 -0
- archerprotocol_sdk-0.1.0.dist-info/METADATA +106 -0
- archerprotocol_sdk-0.1.0.dist-info/RECORD +13 -0
- archerprotocol_sdk-0.1.0.dist-info/WHEEL +5 -0
- archerprotocol_sdk-0.1.0.dist-info/top_level.txt +1 -0
archer_sdk/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Archer contribution SDK for Python: build, monetize, and publish an
|
|
2
|
+
Archer intent. Envelope builders, GUI embeds, caller context, effect
|
|
3
|
+
inference, and a one-call FastAPI handler (archer_sdk.fastapi). Mirrors the
|
|
4
|
+
Node @archerprotocol/sdk; the shared test-vectors enforce parity."""
|
|
5
|
+
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
from . import embed as _embed
|
|
9
|
+
from .builders import (
|
|
10
|
+
authorization,
|
|
11
|
+
cost,
|
|
12
|
+
data,
|
|
13
|
+
envelope,
|
|
14
|
+
is_invocation_envelope,
|
|
15
|
+
record,
|
|
16
|
+
)
|
|
17
|
+
from .caller import ArcherCaller, get_archer_caller, require_archer_caller
|
|
18
|
+
from .effects import infer_effects
|
|
19
|
+
from .errors import BadInputError, UpstreamError
|
|
20
|
+
from .handler import ArcherHandler, HandlerContext, run_invocation
|
|
21
|
+
from .money import to_micro, to_micro_optional
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
# The one-object surface most handlers want: archer.envelope(...), archer.render(...).
|
|
26
|
+
archer = SimpleNamespace(
|
|
27
|
+
data=data,
|
|
28
|
+
cost=cost,
|
|
29
|
+
authorization=authorization,
|
|
30
|
+
record=record,
|
|
31
|
+
envelope=envelope,
|
|
32
|
+
embed=_embed,
|
|
33
|
+
render=_embed.render,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"archer",
|
|
38
|
+
"authorization",
|
|
39
|
+
"cost",
|
|
40
|
+
"data",
|
|
41
|
+
"envelope",
|
|
42
|
+
"record",
|
|
43
|
+
"is_invocation_envelope",
|
|
44
|
+
"get_archer_caller",
|
|
45
|
+
"require_archer_caller",
|
|
46
|
+
"ArcherCaller",
|
|
47
|
+
"infer_effects",
|
|
48
|
+
"BadInputError",
|
|
49
|
+
"UpstreamError",
|
|
50
|
+
"ArcherHandler",
|
|
51
|
+
"HandlerContext",
|
|
52
|
+
"run_invocation",
|
|
53
|
+
"to_micro",
|
|
54
|
+
"to_micro_optional",
|
|
55
|
+
]
|
archer_sdk/builders.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Envelope builders for the generic Archer invocation contract. Inputs take
|
|
2
|
+
pythonic snake_case keywords; outputs are plain dicts whose keys and shapes
|
|
3
|
+
are byte-identical to the Node SDK's JSON (the shared test-vectors enforce
|
|
4
|
+
this). Optional fields are omitted when absent, never set to null."""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .money import Money, to_micro, to_micro_optional
|
|
9
|
+
|
|
10
|
+
_COST_MODELS = {"flat", "percent", "x402", "embedded", "none"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _prune(obj: Dict[str, Any]) -> Dict[str, Any]:
|
|
14
|
+
return {k: v for k, v in obj.items() if v is not None}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def data(value: Any) -> Dict[str, Any]:
|
|
18
|
+
"""The Render arm."""
|
|
19
|
+
return {"data": value}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cost(
|
|
23
|
+
*,
|
|
24
|
+
model: str,
|
|
25
|
+
amount_micro: Optional[Money] = None,
|
|
26
|
+
value_micro: Optional[Money] = None,
|
|
27
|
+
bps: Optional[int] = None,
|
|
28
|
+
bob_fee_micro: Optional[Money] = None,
|
|
29
|
+
archer_fee_micro: Optional[Money] = None,
|
|
30
|
+
) -> Dict[str, Any]:
|
|
31
|
+
"""The Settle arm: a declared fee model plus its parameters."""
|
|
32
|
+
if model in ("flat", "x402"):
|
|
33
|
+
params = _prune(
|
|
34
|
+
{
|
|
35
|
+
"amountMicro": to_micro(amount_micro) if amount_micro is not None else None,
|
|
36
|
+
"archerFeeMicro": to_micro_optional(archer_fee_micro),
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
elif model == "percent":
|
|
40
|
+
params = _prune(
|
|
41
|
+
{
|
|
42
|
+
"valueMicro": to_micro(value_micro) if value_micro is not None else None,
|
|
43
|
+
"bps": bps,
|
|
44
|
+
"archerFeeMicro": to_micro_optional(archer_fee_micro),
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
elif model == "embedded":
|
|
48
|
+
params = _prune(
|
|
49
|
+
{
|
|
50
|
+
"bobFeeMicro": to_micro_optional(bob_fee_micro),
|
|
51
|
+
"archerFeeMicro": to_micro_optional(archer_fee_micro),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
elif model == "none":
|
|
55
|
+
params = {}
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f'unknown fee model "{model}" (expected one of {sorted(_COST_MODELS)})')
|
|
58
|
+
return {"model": model, "params": params}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def authorization(
|
|
62
|
+
*,
|
|
63
|
+
type: str,
|
|
64
|
+
label: str,
|
|
65
|
+
payload: Dict[str, Any],
|
|
66
|
+
namespace: Optional[str] = None,
|
|
67
|
+
) -> Dict[str, Any]:
|
|
68
|
+
"""One entry of the Authorize arm. The label is the human disclosure."""
|
|
69
|
+
if not label or not isinstance(label, str):
|
|
70
|
+
raise ValueError("authorization label is required (human-readable disclosure)")
|
|
71
|
+
return _prune(
|
|
72
|
+
{
|
|
73
|
+
"type": type,
|
|
74
|
+
"namespace": namespace,
|
|
75
|
+
"label": label,
|
|
76
|
+
"payload": payload if payload is not None else {},
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def record(
|
|
82
|
+
*,
|
|
83
|
+
record_kind: str,
|
|
84
|
+
basis_micro: Optional[Money] = None,
|
|
85
|
+
value_micro: Optional[Money] = None,
|
|
86
|
+
bob_fee_micro: Optional[Money] = None,
|
|
87
|
+
archer_fee_micro: Optional[Money] = None,
|
|
88
|
+
value_read: Optional[Dict[str, Any]] = None,
|
|
89
|
+
exit_instructions: Optional[Dict[str, Any]] = None,
|
|
90
|
+
ref: Optional[str] = None,
|
|
91
|
+
group_key: Optional[str] = None,
|
|
92
|
+
view_url: Optional[str] = None,
|
|
93
|
+
disclosure: Optional[Dict[str, Any]] = None,
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""The Record arm: a durable, user-owned outcome (opaque to the engine)."""
|
|
96
|
+
if not record_kind or not isinstance(record_kind, str):
|
|
97
|
+
raise ValueError("record_kind is required")
|
|
98
|
+
return _prune(
|
|
99
|
+
{
|
|
100
|
+
"recordKind": record_kind,
|
|
101
|
+
"basisMicro": to_micro_optional(basis_micro),
|
|
102
|
+
"valueMicro": to_micro_optional(value_micro),
|
|
103
|
+
"bobFeeMicro": to_micro_optional(bob_fee_micro),
|
|
104
|
+
"archerFeeMicro": to_micro_optional(archer_fee_micro),
|
|
105
|
+
"valueRead": value_read,
|
|
106
|
+
"exitInstructions": exit_instructions,
|
|
107
|
+
"ref": ref,
|
|
108
|
+
"groupKey": group_key,
|
|
109
|
+
"viewUrl": view_url,
|
|
110
|
+
"disclosure": disclosure,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def envelope(
|
|
116
|
+
*,
|
|
117
|
+
data: Any = None,
|
|
118
|
+
cost: Optional[Dict[str, Any]] = None,
|
|
119
|
+
authorizations: Optional[List[Dict[str, Any]]] = None,
|
|
120
|
+
record: Optional[Dict[str, Any]] = None,
|
|
121
|
+
) -> Dict[str, Any]:
|
|
122
|
+
"""Assemble the arms into one invocation envelope, validated structurally."""
|
|
123
|
+
env = _prune(
|
|
124
|
+
{"data": data, "cost": cost, "authorizations": authorizations, "record": record}
|
|
125
|
+
)
|
|
126
|
+
if not is_invocation_envelope(env):
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"envelope arms are malformed (cost needs a model, record needs a recordKind, "
|
|
129
|
+
"authorizations must be a list)"
|
|
130
|
+
)
|
|
131
|
+
return env
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_invocation_envelope(value: Any) -> bool:
|
|
135
|
+
"""Structural check mirroring the engine's isInvocationEnvelope."""
|
|
136
|
+
if value is None or not isinstance(value, dict):
|
|
137
|
+
return False
|
|
138
|
+
if "authorizations" in value and value["authorizations"] is not None:
|
|
139
|
+
if not isinstance(value["authorizations"], list):
|
|
140
|
+
return False
|
|
141
|
+
if "cost" in value and value["cost"] is not None:
|
|
142
|
+
c = value["cost"]
|
|
143
|
+
if not isinstance(c, dict) or not isinstance(c.get("model"), str):
|
|
144
|
+
return False
|
|
145
|
+
if "record" in value and value["record"] is not None:
|
|
146
|
+
r = value["record"]
|
|
147
|
+
if not isinstance(r, dict) or not isinstance(r.get("recordKind"), str):
|
|
148
|
+
return False
|
|
149
|
+
return True
|
archer_sdk/caller.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Caller context. Archer injects the invoking user's context inside the
|
|
2
|
+
signed args as args["archerCaller"], so it is verified as part of the
|
|
3
|
+
envelope. Read it here."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArcherCaller(TypedDict):
|
|
9
|
+
evmAddress: Optional[str]
|
|
10
|
+
svmAddress: Optional[str]
|
|
11
|
+
archerFeeRate: Optional[float]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_archer_caller(args: Dict[str, Any]) -> Optional[ArcherCaller]:
|
|
15
|
+
raw = args.get("archerCaller") if isinstance(args, dict) else None
|
|
16
|
+
if raw is None or not isinstance(raw, dict):
|
|
17
|
+
return None
|
|
18
|
+
evm = raw.get("evmAddress")
|
|
19
|
+
svm = raw.get("svmAddress")
|
|
20
|
+
rate = raw.get("archerFeeRate")
|
|
21
|
+
return {
|
|
22
|
+
"evmAddress": evm if isinstance(evm, str) else None,
|
|
23
|
+
"svmAddress": svm if isinstance(svm, str) else None,
|
|
24
|
+
"archerFeeRate": float(rate)
|
|
25
|
+
if isinstance(rate, (int, float)) and not isinstance(rate, bool)
|
|
26
|
+
else None,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_archer_caller(args: Dict[str, Any]) -> ArcherCaller:
|
|
31
|
+
"""For intents that move user funds or cause side effects, caller context
|
|
32
|
+
is mandatory. A missing caller almost always means the intent was
|
|
33
|
+
published without requiresCaller: true."""
|
|
34
|
+
caller = get_archer_caller(args)
|
|
35
|
+
if caller is None:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"caller context is missing; declare requiresCaller: true when publishing this intent"
|
|
38
|
+
)
|
|
39
|
+
return caller
|
archer_sdk/effects.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""infer_effects: derive a reviewable effect report from one authorization.
|
|
2
|
+
|
|
3
|
+
Pure and dependency-injected: descriptors are data rows, simulation and
|
|
4
|
+
pricing are injected async callables. The function branches on verdict
|
|
5
|
+
strategy only (simulate / decode / opaque), never on a venue. It never
|
|
6
|
+
raises; an unrecognizable or malformed payload renders as OPAQUE.
|
|
7
|
+
|
|
8
|
+
The shared effects vectors enforce byte-parity with the Node SDK and the
|
|
9
|
+
platform's server-side derivation."""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
_MAX_ROWS = 30
|
|
15
|
+
_MAX_VALUE_CHARS = 120
|
|
16
|
+
# JS Number.MAX_SAFE_INTEGER: a ceiling beyond any real economy is an
|
|
17
|
+
# unlimited-approval sentinel and reports as unmeasurable.
|
|
18
|
+
_MAX_SAFE = 9007199254740991
|
|
19
|
+
|
|
20
|
+
SimulateFn = Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]
|
|
21
|
+
PriceFn = Callable[[str], Awaitable[Optional[float]]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def infer_effects(
|
|
25
|
+
auth: Any,
|
|
26
|
+
*,
|
|
27
|
+
descriptors: List[Dict[str, Any]],
|
|
28
|
+
simulate: Optional[SimulateFn] = None,
|
|
29
|
+
price: Optional[PriceFn] = None,
|
|
30
|
+
) -> Dict[str, Any]:
|
|
31
|
+
try:
|
|
32
|
+
return await _infer(auth, descriptors, simulate, price)
|
|
33
|
+
except Exception:
|
|
34
|
+
# The last-resort guarantee: whatever went wrong, render opaquely.
|
|
35
|
+
return _opaque_report(auth)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _infer(auth, descriptors, simulate, price):
|
|
39
|
+
auth_type = auth.get("type") if isinstance(auth, dict) else None
|
|
40
|
+
auth_type = auth_type if isinstance(auth_type, str) else "unknown"
|
|
41
|
+
|
|
42
|
+
if auth_type == "tx" and simulate is not None:
|
|
43
|
+
try:
|
|
44
|
+
outcome = await simulate(auth)
|
|
45
|
+
value = outcome.get("valueUsd")
|
|
46
|
+
measurable = value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
|
47
|
+
report = {
|
|
48
|
+
"verdict": "SIMULATED",
|
|
49
|
+
"authType": auth_type,
|
|
50
|
+
"namespace": _str_or_none(auth.get("namespace")),
|
|
51
|
+
"measurableValueUsd": measurable,
|
|
52
|
+
"simulation": outcome.get("raw"),
|
|
53
|
+
"display": {"title": _title_of(auth), "rows": outcome.get("rows") or []},
|
|
54
|
+
}
|
|
55
|
+
return _prune_report(report)
|
|
56
|
+
except Exception:
|
|
57
|
+
return _opaque_report(auth)
|
|
58
|
+
|
|
59
|
+
if auth_type == "sign":
|
|
60
|
+
decoded = _decode_against_descriptors(auth, descriptors or [])
|
|
61
|
+
if decoded is not None:
|
|
62
|
+
descriptor, bounded = decoded
|
|
63
|
+
report = {
|
|
64
|
+
"verdict": "DECODED_BOUNDED",
|
|
65
|
+
"authType": auth_type,
|
|
66
|
+
"namespace": _str_or_none(auth.get("namespace")),
|
|
67
|
+
"measurableValueUsd": await _value_of(descriptor, bounded, price),
|
|
68
|
+
"bounded": bounded,
|
|
69
|
+
"display": {
|
|
70
|
+
"title": descriptor["label"],
|
|
71
|
+
"rows": [{"label": k, "value": _clip(v)} for k, v in bounded.items()],
|
|
72
|
+
},
|
|
73
|
+
"descriptorRef": descriptor["id"],
|
|
74
|
+
}
|
|
75
|
+
return _prune_report(report)
|
|
76
|
+
|
|
77
|
+
return _opaque_report(auth)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── decoding ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decode_against_descriptors(auth, descriptors):
|
|
84
|
+
payload = _as_dict(auth.get("payload") if isinstance(auth, dict) else None)
|
|
85
|
+
if payload is None:
|
|
86
|
+
return None
|
|
87
|
+
domain = _as_dict(payload.get("domain"))
|
|
88
|
+
message = _as_dict(payload.get("message"))
|
|
89
|
+
primary_type = payload.get("primaryType")
|
|
90
|
+
if domain is None or message is None or not isinstance(primary_type, str):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
for descriptor in descriptors:
|
|
94
|
+
if not _matches(descriptor, primary_type, domain):
|
|
95
|
+
continue
|
|
96
|
+
if not _is_canonical_contract(descriptor, domain):
|
|
97
|
+
continue
|
|
98
|
+
bounded = {}
|
|
99
|
+
for entry in descriptor.get("extract", []):
|
|
100
|
+
value = _get_path(message, entry["path"])
|
|
101
|
+
if value is not None and not isinstance(value, (dict, list)):
|
|
102
|
+
bounded[entry["role"]] = _js_str(value)
|
|
103
|
+
return descriptor, bounded
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _matches(descriptor, primary_type, domain):
|
|
108
|
+
match = descriptor.get("match", {})
|
|
109
|
+
if match.get("primaryType") != primary_type:
|
|
110
|
+
return False
|
|
111
|
+
want = match.get("domain")
|
|
112
|
+
if not want:
|
|
113
|
+
return True
|
|
114
|
+
if "name" in want and domain.get("name") != want["name"]:
|
|
115
|
+
return False
|
|
116
|
+
if "version" in want and domain.get("version") != want["version"]:
|
|
117
|
+
return False
|
|
118
|
+
if "chainId" in want:
|
|
119
|
+
try:
|
|
120
|
+
if float(domain.get("chainId")) != float(want["chainId"]):
|
|
121
|
+
return False
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_canonical_contract(descriptor, domain):
|
|
128
|
+
"""The canonical-contract pin: a domain/type match only earns
|
|
129
|
+
DECODED_BOUNDED when the payload's verifyingContract is one of the
|
|
130
|
+
descriptor's canonical addresses. A lookalike domain pointed at another
|
|
131
|
+
contract stays OPAQUE."""
|
|
132
|
+
vc = domain.get("verifyingContract")
|
|
133
|
+
if not isinstance(vc, str) or len(vc) == 0:
|
|
134
|
+
return False
|
|
135
|
+
needle = vc.lower()
|
|
136
|
+
contracts = descriptor.get("riskRules", {}).get("verifyingContracts", [])
|
|
137
|
+
return any(isinstance(c, str) and c.lower() == needle for c in contracts)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── valuation ───────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _value_of(descriptor, bounded, price):
|
|
144
|
+
"""Priceability gates measurability: a missing amount, an unbounded
|
|
145
|
+
amount, an unknown asset, or an unpriceable asset all yield None, and the
|
|
146
|
+
policy layer treats None via its opaque-cap behavior."""
|
|
147
|
+
valuation = descriptor.get("valuation")
|
|
148
|
+
if not valuation or price is None:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
raw = bounded.get(valuation["source"])
|
|
152
|
+
if raw is None:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
units = float(raw) / (10 ** valuation["decimals"])
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
return None
|
|
158
|
+
if not math.isfinite(units):
|
|
159
|
+
return None
|
|
160
|
+
if units > _MAX_SAFE:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
asset = valuation.get("asset")
|
|
164
|
+
if asset is None and valuation.get("assetRole"):
|
|
165
|
+
asset = bounded.get(valuation["assetRole"])
|
|
166
|
+
if not asset:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
unit_price = await price(asset)
|
|
171
|
+
except Exception:
|
|
172
|
+
return None
|
|
173
|
+
if unit_price is None or not math.isfinite(unit_price):
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
value = units * unit_price
|
|
177
|
+
if not math.isfinite(value):
|
|
178
|
+
return None
|
|
179
|
+
return _js_number(value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── opaque rendering ────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _opaque_report(auth):
|
|
186
|
+
namespace = _str_or_none(auth.get("namespace")) if isinstance(auth, dict) else None
|
|
187
|
+
report = {
|
|
188
|
+
"verdict": "OPAQUE",
|
|
189
|
+
"authType": (auth.get("type") if isinstance(auth, dict) and isinstance(auth.get("type"), str) else "unknown"),
|
|
190
|
+
"measurableValueUsd": None,
|
|
191
|
+
"display": {
|
|
192
|
+
"title": _title_of(auth),
|
|
193
|
+
"rows": _flatten_for_display(auth.get("payload") if isinstance(auth, dict) else None),
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
if namespace is not None:
|
|
197
|
+
report["namespace"] = namespace
|
|
198
|
+
return _order_report(report)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _flatten_for_display(payload):
|
|
202
|
+
rows = []
|
|
203
|
+
try:
|
|
204
|
+
_walk(payload, "", rows)
|
|
205
|
+
except Exception:
|
|
206
|
+
rows.append({"label": "payload", "value": "unavailable"})
|
|
207
|
+
if not rows:
|
|
208
|
+
rows.append({"label": "payload", "value": "empty"})
|
|
209
|
+
return rows[:_MAX_ROWS]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _walk(value, prefix, rows):
|
|
213
|
+
if len(rows) >= _MAX_ROWS:
|
|
214
|
+
return
|
|
215
|
+
if value is None:
|
|
216
|
+
if prefix:
|
|
217
|
+
rows.append({"label": prefix, "value": "None" if value is None else str(value)})
|
|
218
|
+
return
|
|
219
|
+
if not isinstance(value, (dict, list)):
|
|
220
|
+
rows.append({"label": prefix or "value", "value": _clip(_js_str(value))})
|
|
221
|
+
return
|
|
222
|
+
entries = (
|
|
223
|
+
[(str(i), v) for i, v in enumerate(value)]
|
|
224
|
+
if isinstance(value, list)
|
|
225
|
+
else list(value.items())
|
|
226
|
+
)
|
|
227
|
+
for key, child in entries:
|
|
228
|
+
_walk(child, f"{prefix}.{key}" if prefix else key, rows)
|
|
229
|
+
if len(rows) >= _MAX_ROWS:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── small helpers ───────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _title_of(auth):
|
|
237
|
+
label = auth.get("label") if isinstance(auth, dict) else None
|
|
238
|
+
return label if isinstance(label, str) and len(label) > 0 else "Authorization"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _as_dict(value):
|
|
242
|
+
return value if isinstance(value, dict) else None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _get_path(obj, path):
|
|
246
|
+
current = obj
|
|
247
|
+
for segment in path.split("."):
|
|
248
|
+
if not isinstance(current, dict):
|
|
249
|
+
return None
|
|
250
|
+
current = current.get(segment)
|
|
251
|
+
return current
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _str_or_none(value):
|
|
255
|
+
return value if isinstance(value, str) and len(value) > 0 else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _clip(value):
|
|
259
|
+
return value[:_MAX_VALUE_CHARS] + "…" if len(value) > _MAX_VALUE_CHARS else value
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _js_str(value):
|
|
263
|
+
"""Mirror JS String() for the value types that ride typed-data messages."""
|
|
264
|
+
if isinstance(value, bool):
|
|
265
|
+
return "true" if value else "false"
|
|
266
|
+
if isinstance(value, float):
|
|
267
|
+
return _js_number_str(value)
|
|
268
|
+
return str(value)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _js_number_str(f):
|
|
272
|
+
if math.isnan(f):
|
|
273
|
+
return "NaN"
|
|
274
|
+
if math.isinf(f):
|
|
275
|
+
return "Infinity" if f > 0 else "-Infinity"
|
|
276
|
+
if f.is_integer() and abs(f) < 1e21:
|
|
277
|
+
return str(int(f))
|
|
278
|
+
return repr(f)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _js_number(value):
|
|
282
|
+
"""Collapse float results that are exact integers to int so JSON matches
|
|
283
|
+
the Node output (12.5 stays 12.5; 5.0 becomes 5)."""
|
|
284
|
+
if isinstance(value, float) and value.is_integer() and abs(value) <= _MAX_SAFE:
|
|
285
|
+
return int(value)
|
|
286
|
+
return value
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
_REPORT_KEY_ORDER = [
|
|
290
|
+
"verdict",
|
|
291
|
+
"authType",
|
|
292
|
+
"namespace",
|
|
293
|
+
"measurableValueUsd",
|
|
294
|
+
"bounded",
|
|
295
|
+
"simulation",
|
|
296
|
+
"display",
|
|
297
|
+
"descriptorRef",
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _prune_report(report):
|
|
302
|
+
return _order_report({k: v for k, v in report.items() if v is not None or k == "measurableValueUsd"})
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _order_report(report):
|
|
306
|
+
return {k: report[k] for k in _REPORT_KEY_ORDER if k in report}
|
archer_sdk/embed.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Typed builders for the archerEmbed GUI render contract. Mirrors the
|
|
2
|
+
engine's whitelisted discriminated union (by `kind`); anything off-contract
|
|
3
|
+
is dropped by the engine before rendering, so the builders validate the
|
|
4
|
+
load-bearing fields at author time. Output keys are byte-identical to the
|
|
5
|
+
Node SDK (the shared embeds vectors enforce this)."""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from .builders import _prune
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _forward_compat(
|
|
13
|
+
persistence: Optional[str],
|
|
14
|
+
live_source_url: Optional[str],
|
|
15
|
+
refresh_interval_ms: Optional[int],
|
|
16
|
+
) -> Dict[str, Any]:
|
|
17
|
+
return _prune(
|
|
18
|
+
{
|
|
19
|
+
"persistence": persistence,
|
|
20
|
+
"liveSourceUrl": live_source_url,
|
|
21
|
+
"refreshIntervalMs": refresh_interval_ms,
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def chart(
|
|
27
|
+
*,
|
|
28
|
+
chart_type: str,
|
|
29
|
+
data: List[Dict[str, Any]],
|
|
30
|
+
title: Optional[str] = None,
|
|
31
|
+
x_label: Optional[str] = None,
|
|
32
|
+
y_label: Optional[str] = None,
|
|
33
|
+
series: Optional[List[Dict[str, Any]]] = None,
|
|
34
|
+
y_axis_domain: Optional[Union[str, List[float]]] = None,
|
|
35
|
+
persistence: Optional[str] = None,
|
|
36
|
+
live_source_url: Optional[str] = None,
|
|
37
|
+
refresh_interval_ms: Optional[int] = None,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
if not isinstance(data, list) or len(data) == 0:
|
|
40
|
+
raise ValueError("chart embed: data must have at least one point")
|
|
41
|
+
return _prune(
|
|
42
|
+
{
|
|
43
|
+
"kind": "chart",
|
|
44
|
+
"chartType": chart_type,
|
|
45
|
+
"title": title,
|
|
46
|
+
"xLabel": x_label,
|
|
47
|
+
"yLabel": y_label,
|
|
48
|
+
"data": data,
|
|
49
|
+
"series": series,
|
|
50
|
+
"yAxisDomain": y_axis_domain,
|
|
51
|
+
**_forward_compat(persistence, live_source_url, refresh_interval_ms),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def metric(
|
|
57
|
+
*,
|
|
58
|
+
label: str,
|
|
59
|
+
value: str,
|
|
60
|
+
delta: Optional[Dict[str, str]] = None,
|
|
61
|
+
persistence: Optional[str] = None,
|
|
62
|
+
live_source_url: Optional[str] = None,
|
|
63
|
+
refresh_interval_ms: Optional[int] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
if not label or not value:
|
|
66
|
+
raise ValueError("metric embed: label and value are required")
|
|
67
|
+
return _prune(
|
|
68
|
+
{
|
|
69
|
+
"kind": "metric",
|
|
70
|
+
"label": label,
|
|
71
|
+
"value": value,
|
|
72
|
+
"delta": delta,
|
|
73
|
+
**_forward_compat(persistence, live_source_url, refresh_interval_ms),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def table(
|
|
79
|
+
*,
|
|
80
|
+
columns: List[Dict[str, Any]],
|
|
81
|
+
rows: Optional[List[Dict[str, Any]]] = None,
|
|
82
|
+
persistence: Optional[str] = None,
|
|
83
|
+
live_source_url: Optional[str] = None,
|
|
84
|
+
refresh_interval_ms: Optional[int] = None,
|
|
85
|
+
) -> Dict[str, Any]:
|
|
86
|
+
if not isinstance(columns, list) or len(columns) == 0:
|
|
87
|
+
raise ValueError("table embed: at least one column is required")
|
|
88
|
+
return _prune(
|
|
89
|
+
{
|
|
90
|
+
"kind": "table",
|
|
91
|
+
"columns": columns,
|
|
92
|
+
"rows": rows if rows is not None else [],
|
|
93
|
+
**_forward_compat(persistence, live_source_url, refresh_interval_ms),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def kv(
|
|
99
|
+
*,
|
|
100
|
+
pairs: List[Dict[str, str]],
|
|
101
|
+
persistence: Optional[str] = None,
|
|
102
|
+
live_source_url: Optional[str] = None,
|
|
103
|
+
refresh_interval_ms: Optional[int] = None,
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
if not isinstance(pairs, list) or len(pairs) == 0:
|
|
106
|
+
raise ValueError("kv embed: at least one pair is required")
|
|
107
|
+
return _prune(
|
|
108
|
+
{
|
|
109
|
+
"kind": "kv",
|
|
110
|
+
"pairs": pairs,
|
|
111
|
+
**_forward_compat(persistence, live_source_url, refresh_interval_ms),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def alert(
|
|
117
|
+
*,
|
|
118
|
+
tone: str,
|
|
119
|
+
body: str,
|
|
120
|
+
title: Optional[str] = None,
|
|
121
|
+
persistence: Optional[str] = None,
|
|
122
|
+
live_source_url: Optional[str] = None,
|
|
123
|
+
refresh_interval_ms: Optional[int] = None,
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
if not tone or not body:
|
|
126
|
+
raise ValueError("alert embed: tone and body are required")
|
|
127
|
+
return _prune(
|
|
128
|
+
{
|
|
129
|
+
"kind": "alert",
|
|
130
|
+
"tone": tone,
|
|
131
|
+
"title": title,
|
|
132
|
+
"body": body,
|
|
133
|
+
**_forward_compat(persistence, live_source_url, refresh_interval_ms),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def image(
|
|
139
|
+
*,
|
|
140
|
+
url: str,
|
|
141
|
+
alt: str,
|
|
142
|
+
width: Optional[int] = None,
|
|
143
|
+
height: Optional[int] = None,
|
|
144
|
+
persistence: Optional[str] = None,
|
|
145
|
+
live_source_url: Optional[str] = None,
|
|
146
|
+
refresh_interval_ms: Optional[int] = None,
|
|
147
|
+
) -> Dict[str, Any]:
|
|
148
|
+
if not url or not alt:
|
|
149
|
+
raise ValueError("image embed: url and alt are required")
|
|
150
|
+
return _prune(
|
|
151
|
+
{
|
|
152
|
+
"kind": "image",
|
|
153
|
+
"url": url,
|
|
154
|
+
"alt": alt,
|
|
155
|
+
"width": width,
|
|
156
|
+
"height": height,
|
|
157
|
+
**_forward_compat(persistence, live_source_url, refresh_interval_ms),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def render(*, text: Optional[str] = None, embed: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
163
|
+
"""The flat read payload the integrated engine reads: text + archerEmbed."""
|
|
164
|
+
return _prune({"text": text, "archerEmbed": embed})
|
archer_sdk/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Handler error types. The one-call handler maps these to the partner
|
|
2
|
+
response envelope: BadInputError -> 400, UpstreamError -> 503; anything else
|
|
3
|
+
-> 500 with code UNKNOWN."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BadInputError(ValueError):
|
|
7
|
+
status = 400
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, code: str = "BAD_INPUT"):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UpstreamError(RuntimeError):
|
|
15
|
+
status = 503
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, code: str = "UPSTREAM_ERROR"):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.code = code
|
archer_sdk/fastapi.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""FastAPI adapter for the one-call handler. Import requires the fastapi
|
|
2
|
+
extra: pip install "archer-sdk[fastapi]"."""
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from archer_verify import create_verifier
|
|
7
|
+
|
|
8
|
+
from .handler import ArcherHandler, run_invocation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_archer_intent(
|
|
12
|
+
handler: ArcherHandler,
|
|
13
|
+
*,
|
|
14
|
+
verifier: Any = None,
|
|
15
|
+
verify: Optional[Dict[str, Any]] = None,
|
|
16
|
+
provider_cost_micro: Optional[int] = None,
|
|
17
|
+
):
|
|
18
|
+
"""Build a FastAPI route callable: verifies the signed envelope, invokes
|
|
19
|
+
your handler, returns the partner response envelope. Pass a prebuilt
|
|
20
|
+
`verifier`, or `verify` kwargs forwarded to archer_verify.create_verifier
|
|
21
|
+
(e.g. archer_base_url, public_key_pem)."""
|
|
22
|
+
from fastapi import Request
|
|
23
|
+
from fastapi.responses import JSONResponse
|
|
24
|
+
|
|
25
|
+
v = verifier or create_verifier(**(verify or {}))
|
|
26
|
+
|
|
27
|
+
async def route(request: Request) -> JSONResponse:
|
|
28
|
+
try:
|
|
29
|
+
body = await request.json()
|
|
30
|
+
except Exception:
|
|
31
|
+
body = None
|
|
32
|
+
status, payload = await run_invocation(
|
|
33
|
+
v,
|
|
34
|
+
handler,
|
|
35
|
+
body=body,
|
|
36
|
+
headers=dict(request.headers),
|
|
37
|
+
provider_cost_micro=provider_cost_micro,
|
|
38
|
+
)
|
|
39
|
+
return JSONResponse(status_code=status, content=payload)
|
|
40
|
+
|
|
41
|
+
return route
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mount_archer_tools(
|
|
45
|
+
router: Any,
|
|
46
|
+
tools: Dict[str, ArcherHandler],
|
|
47
|
+
*,
|
|
48
|
+
verifier: Any = None,
|
|
49
|
+
verify: Optional[Dict[str, Any]] = None,
|
|
50
|
+
provider_cost_micro: Optional[int] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Mount several sub-tools on a router you own (APIRouter or FastAPI app):
|
|
53
|
+
one POST route per path."""
|
|
54
|
+
v = verifier or create_verifier(**(verify or {}))
|
|
55
|
+
for path, handler in tools.items():
|
|
56
|
+
router.post(path)(
|
|
57
|
+
create_archer_intent(handler, verifier=v, provider_cost_micro=provider_cost_micro)
|
|
58
|
+
)
|
archer_sdk/handler.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The one-call request handler, framework-free core. Verifies the signed
|
|
2
|
+
Archer envelope (via archer-verify), extracts the handler context, invokes
|
|
3
|
+
your handler, and wraps the return in the partner response envelope. The
|
|
4
|
+
FastAPI adapter in archer_sdk.fastapi builds routes on top of this."""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union
|
|
9
|
+
|
|
10
|
+
from .caller import ArcherCaller, get_archer_caller
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HandlerContext:
|
|
15
|
+
args: Dict[str, Any]
|
|
16
|
+
caller: Optional[ArcherCaller]
|
|
17
|
+
request_id: str
|
|
18
|
+
intent_definition_id: str
|
|
19
|
+
user_id: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ArcherHandler = Callable[[HandlerContext], Union[Any, Awaitable[Any]]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _reject_message(status: int) -> str:
|
|
26
|
+
if status == 401:
|
|
27
|
+
return "Unauthorized: Archer signature missing, stale, or replayed."
|
|
28
|
+
if status == 403:
|
|
29
|
+
return "Forbidden: Archer signature invalid."
|
|
30
|
+
return "Service unavailable: cannot reach Archer to verify the signature."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _error_status(err: Exception) -> int:
|
|
34
|
+
status = getattr(err, "status", None)
|
|
35
|
+
return status if status in (400, 403, 503) else 500
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _error_code(err: Exception, status: int) -> str:
|
|
39
|
+
code = getattr(err, "code", None)
|
|
40
|
+
if isinstance(code, str):
|
|
41
|
+
return code
|
|
42
|
+
return "UNKNOWN" if status == 500 else "ERROR"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def run_invocation(
|
|
46
|
+
verifier: Any,
|
|
47
|
+
handler: ArcherHandler,
|
|
48
|
+
*,
|
|
49
|
+
body: Any,
|
|
50
|
+
headers: Dict[str, Any],
|
|
51
|
+
provider_cost_micro: Optional[int] = None,
|
|
52
|
+
) -> Tuple[int, Dict[str, Any]]:
|
|
53
|
+
"""Verify + dispatch one invocation. Returns (http_status, response_body)."""
|
|
54
|
+
result = verifier.verify(headers, body=body)
|
|
55
|
+
if not result.ok:
|
|
56
|
+
status = result.status or 401
|
|
57
|
+
return status, {
|
|
58
|
+
"success": False,
|
|
59
|
+
"error": {"code": result.reason, "message": _reject_message(status)},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
env = result.envelope or {}
|
|
63
|
+
args = env.get("args") if isinstance(env.get("args"), dict) else {}
|
|
64
|
+
ctx = HandlerContext(
|
|
65
|
+
args=args,
|
|
66
|
+
caller=get_archer_caller(args),
|
|
67
|
+
request_id=env.get("requestId", ""),
|
|
68
|
+
intent_definition_id=env.get("intentDefinitionId", ""),
|
|
69
|
+
user_id=env.get("userId", ""),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
invocation = handler(ctx)
|
|
74
|
+
if inspect.isawaitable(invocation):
|
|
75
|
+
invocation = await invocation
|
|
76
|
+
response: Dict[str, Any] = {"success": True, "result": invocation}
|
|
77
|
+
if provider_cost_micro is not None:
|
|
78
|
+
response["providerCostMicro"] = provider_cost_micro
|
|
79
|
+
return 200, response
|
|
80
|
+
except Exception as err:
|
|
81
|
+
status = _error_status(err)
|
|
82
|
+
return status, {
|
|
83
|
+
"success": False,
|
|
84
|
+
"error": {"code": _error_code(err, status), "message": str(err)},
|
|
85
|
+
}
|
archer_sdk/money.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Money coercion: everything the engine bills parses as a BigInt, so every
|
|
2
|
+
micro amount travels as an integer decimal string. Mirrors the Node SDK's
|
|
3
|
+
coercion strictness exactly."""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
Money = Union[int, float, str]
|
|
10
|
+
|
|
11
|
+
_INT_STRING = re.compile(r"^-?\d+$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def to_micro(value: Money) -> str:
|
|
15
|
+
"""Coerce a money input to an integer decimal string, or raise."""
|
|
16
|
+
if isinstance(value, bool):
|
|
17
|
+
raise TypeError(f"micro amount must be a finite integer, received {value!r}")
|
|
18
|
+
if isinstance(value, int):
|
|
19
|
+
return str(value)
|
|
20
|
+
if isinstance(value, float):
|
|
21
|
+
if not math.isfinite(value) or not value.is_integer():
|
|
22
|
+
raise ValueError(f"micro amount must be a finite integer, received {value!r}")
|
|
23
|
+
return str(int(value))
|
|
24
|
+
if isinstance(value, str):
|
|
25
|
+
s = value.strip()
|
|
26
|
+
if not _INT_STRING.match(s):
|
|
27
|
+
raise ValueError(f'micro amount must be an integer decimal string, received "{value}"')
|
|
28
|
+
return s
|
|
29
|
+
raise TypeError(f"micro amount must be int, integral float, or digit string, received {type(value).__name__}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_micro_optional(value: Optional[Money]) -> Optional[str]:
|
|
33
|
+
return None if value is None else to_micro(value)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: archerprotocol-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and monetize an Archer intent from Python: typed envelope builders, embeds, caller context, effect inference, and a one-call FastAPI handler.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Archer-Laboratories/archer-sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/Archer-Laboratories/archer-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/Archer-Laboratories/archer-sdk/issues
|
|
9
|
+
Keywords: archer,archer-protocol,intent,partner-sdk,fastapi,ed25519
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: archer-verify>=0.1.0
|
|
16
|
+
Provides-Extra: fastapi
|
|
17
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
18
|
+
Requires-Dist: starlette>=0.36; extra == "fastapi"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
22
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# archerprotocol-sdk
|
|
25
|
+
|
|
26
|
+
Build, monetize, and publish an Archer intent from Python. This package
|
|
27
|
+
mirrors the Node `@archerprotocol/sdk` builder surface; both are held to the
|
|
28
|
+
same shared test vectors, so an envelope built here is byte-identical to one
|
|
29
|
+
built in TypeScript.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install "archerprotocol-sdk[fastapi]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## One-call handler (FastAPI)
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from fastapi import FastAPI
|
|
39
|
+
from archer_sdk import archer, require_archer_caller, BadInputError
|
|
40
|
+
from archer_sdk.fastapi import mount_archer_tools
|
|
41
|
+
|
|
42
|
+
app = FastAPI()
|
|
43
|
+
|
|
44
|
+
async def deposit(ctx):
|
|
45
|
+
caller = require_archer_caller(ctx.args)
|
|
46
|
+
amount = ctx.args.get("amount")
|
|
47
|
+
if not amount:
|
|
48
|
+
raise BadInputError('amount is required (e.g. "1.5")')
|
|
49
|
+
return archer.envelope(
|
|
50
|
+
cost=archer.cost(model="embedded", archer_fee_micro=200),
|
|
51
|
+
authorizations=[
|
|
52
|
+
archer.authorization(
|
|
53
|
+
type="tx",
|
|
54
|
+
namespace="evm",
|
|
55
|
+
label=f"Deposit {amount} USDC",
|
|
56
|
+
payload={"chainId": 8453, "to": "0x...", "data": "0x...", "value": "0x0"},
|
|
57
|
+
)
|
|
58
|
+
],
|
|
59
|
+
record=archer.record(
|
|
60
|
+
record_kind="position",
|
|
61
|
+
basis_micro=1_000_000,
|
|
62
|
+
disclosure={"custody": "SELF", "exitModel": "PERMISSIONLESS"},
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
mount_archer_tools(app, {"/deposit": deposit})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The handler verifies the signed Archer envelope (via `archer-verify`),
|
|
70
|
+
hands you `ctx.args / ctx.caller / ctx.request_id / ctx.intent_definition_id /
|
|
71
|
+
ctx.user_id`, and wraps your return in the partner response envelope.
|
|
72
|
+
Raise `BadInputError` (400) or `UpstreamError` (503) for clean failures.
|
|
73
|
+
|
|
74
|
+
## Reads
|
|
75
|
+
|
|
76
|
+
Return the flat render payload instead of an envelope:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from archer_sdk import archer
|
|
80
|
+
|
|
81
|
+
async def price_chart(ctx):
|
|
82
|
+
return archer.render(
|
|
83
|
+
text="ETH, last 30 days",
|
|
84
|
+
embed=archer.embed.chart(chart_type="line", data=[{"x": "2026-01", "y": 3000}]),
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Effect inference (pre-flight)
|
|
89
|
+
|
|
90
|
+
`infer_effects` is the same function the platform runs server-side to decode
|
|
91
|
+
a `sign` authorization into a verified, bounded confirmation. Run it locally
|
|
92
|
+
against your descriptors to see exactly what the user's confirmation screen
|
|
93
|
+
will derive; the platform always re-derives its own report.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from archer_sdk import infer_effects
|
|
97
|
+
|
|
98
|
+
report = await infer_effects(auth, descriptors=my_descriptors, price=my_price_fn)
|
|
99
|
+
assert report["verdict"] == "DECODED_BOUNDED"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Publishing
|
|
103
|
+
|
|
104
|
+
Publishing and lifecycle management use the Node CLI, which works without a
|
|
105
|
+
Node project: `npx @archerprotocol/sdk` gives you `archer init / publish /
|
|
106
|
+
activate / stats`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
archer_sdk/__init__.py,sha256=7R_va7cFOSphoQFn4jJ5ZuAD2MJG8ir3yeVTEh0OTPA,1408
|
|
2
|
+
archer_sdk/builders.py,sha256=_QNUx0YDTAmLUY48doNzuW6HzkwPYNY80bJ9UAeJefI,5131
|
|
3
|
+
archer_sdk/caller.py,sha256=OJxIUtDOQnjIVeHFFhKo0_GTrDXwc7_cUHlQjhvGWUM,1406
|
|
4
|
+
archer_sdk/effects.py,sha256=KgNeB7yvvewPeWZP_fq5IavcdXx5yS7dAxvWEabSxIA,10491
|
|
5
|
+
archer_sdk/embed.py,sha256=Zos2oFXTb9rSrvbB3CqQKmXx4vNLJXpmN-YtYiDg9FY,4862
|
|
6
|
+
archer_sdk/errors.py,sha256=Q4NjGtWyzn4yVEtLBBc7XV-vLB7CfK28ZTjZDX2OYAk,534
|
|
7
|
+
archer_sdk/fastapi.py,sha256=L1MaeV-sT6v9N-r9g0mwYBiVGKaDNadCpuTYYsuqu1U,1854
|
|
8
|
+
archer_sdk/handler.py,sha256=hLRiapkb1ft4kpZ_cbEObcpB2A6_OuFbiOVMgqLvars,2776
|
|
9
|
+
archer_sdk/money.py,sha256=hJlK91LuSbLgnhaILyd3aiNLdGNEHY0Tu1J2yNhbhtM,1257
|
|
10
|
+
archerprotocol_sdk-0.1.0.dist-info/METADATA,sha256=yRHUohtotgSVxQV7dB7M7cuyONuxPQ_UrMJXTKsQBfU,3683
|
|
11
|
+
archerprotocol_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
archerprotocol_sdk-0.1.0.dist-info/top_level.txt,sha256=0baA18VzWTDojRlynkT01cYeuKlJO8Rm1dn97q0JZas,11
|
|
13
|
+
archerprotocol_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
archer_sdk
|