mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.
Where this SDK was already right, the other two moved: search normalizes the
knowledge kind in both directions, `Page.total` stays the server's own count,
and `put` picks its method from the name. Where it was not:
- kb.Import carries what the handler answers. `/v1/knowledge/import` writes
{format, imported, pages} (knowledge/import.go); this modelled `imported`
alone, so a caller could not see which pages landed.
- Fact carries the server's three. `GET /v1/graph` serves twelve members
(graph/typed.go, wireFact) and no other route hands back an assertion's
content address, so projecting the nine an asserter states made a read lossy
with nothing to recover it from. `id`, `by` and `knowable` arrive read-only
and `write()` never sends them.
- graph.extract and graph.ingest take a Source. Both routes bind the same
{source, subject, text, at} (graph/semantic.go, graphSourceIn), so it is one
value — and the first positional argument is now the same thing here as it is
in the other two.
- import_(format, data, project) — the order the contract writes and the other
two take. It was (export, *, format).
- A body carries what somebody set. `send` dropped None from the query and left
it in the body, so `search.find` posted {"index": null, "offset": null,
"project": null} where the other two omit the key: three requests for one
call, and cloud asked to read a value where there is none. Audit's pageSize
and p go the same way — the route's own defaults are the same 100 and 1, so
an unset term is one the route decides identically, and `all` pins the size it
walks at because it needs to know the page length.
- A write body is field data and nothing else. `url` belongs to a source, and an
empty title is left out so the doctype names the required field that is
missing rather than being handed a blank one.
- kb.list takes order and kb.links takes a project. `/v1/framework/{doctype}`
serves order_by and `/v1/knowledge/graph` serves ?project=.
- policy_denied and entitlement_required do not exist. Neither string appears
anywhere in hanzoai/cloud, so REFUSED is the two codes it does emit. A 403
read as denied on an invented code would hand an unauthenticated caller a cure
for a refusal nobody made.
- One refusal reader, by named fallback: code from `code` else `error`, reason
from `detail` else `message`, cures from `cure`. The gate's own `reason` —
"unpaid", "unresolved" — names the admit leg that failed rather than
explaining anything, so it is dropped, and `title` is not a refusal member.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- The fourth outcome is Fault, not the generated client's ApiException. It is
the one shared concept whose name a reader could not carry between languages,
and an exception from the generated layer is not this layer's vocabulary.
- A client with no credential builds. It raised at construction, which is a
process that cannot start where the other two are a process whose first call
fails — and the first call is where the credential is actually needed.
HIP-0044 is amended where the code was right and the document was not.
ruff check, ruff format --check and mypy clean on every changed file; 109
passed. The two test_smoke failures are pre-existing (ToolsApi is absent from
the generated tree) and reproduce on a stashed working copy.
Co-authored-by: Hanzo Dev <[email protected]>
217 lines
8.0 KiB
Python
217 lines
8.0 KiB
Python
"""The one rule that turns an HTTP answer into an arm.
|
|
|
|
Nothing here touches the network or the generated client. `answer.read` takes a
|
|
:class:`hanzoai.wire.Reply` — a status, a decoded body and a request id — which
|
|
is exactly what the client hands it.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from hanzoai import Ok, Cure, Held, Fault, Denied, answer
|
|
from hanzoai.wire import Reply
|
|
|
|
REFUSAL = {
|
|
"type": "about:blank",
|
|
"title": "Payment Required",
|
|
"status": 402,
|
|
"detail": "the wallet is empty",
|
|
"code": "insufficient_balance",
|
|
"cure": [{"kind": "credit", "url": "/v1/billing"}],
|
|
}
|
|
|
|
HOLD = {"status": "held", "id": "apr_7f3", "clause": "graph.assert", "reason": "a person reviews graph writes"}
|
|
|
|
|
|
def reply(status, body=None, request="req_1"):
|
|
return Reply(status=status, body=body, request=request)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The three arms
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_2xx_is_ok_and_carries_what_it_decoded():
|
|
a = answer.read(reply(200, {"n": 7}), lambda b: b["n"])
|
|
assert isinstance(a, Ok)
|
|
assert (a.status, a.value, a.request) == ("ok", 7, "req_1")
|
|
assert a.denied is None and a.held is None
|
|
|
|
|
|
def test_a_402_is_denied_whatever_the_code_says():
|
|
"""Money refused is money refused. The code names which refusal, not whether."""
|
|
a = answer.read(reply(402, REFUSAL), _never)
|
|
|
|
assert a.denied is a
|
|
assert isinstance(a, Denied)
|
|
assert (a.code, a.reason, a.request) == ("insufficient_balance", "the wallet is empty", "req_1")
|
|
assert a.cures == (Cure(kind="credit", url="/v1/billing"),)
|
|
|
|
|
|
def test_the_second_402_body_reads_the_same():
|
|
"""Cloud answers a refusal two ways. A caller learns neither.
|
|
|
|
`errmap` answers the RFC 9457 envelope with `code` and `detail`; the money
|
|
gate answers `{error, product, reason, message, cure}`, where `error` is
|
|
always `payment_required` and `message` is the sentence. Its own `reason` —
|
|
"unpaid", "unresolved" — names the admit leg that failed rather than
|
|
explaining anything to a person, so it is dropped. Both bodies land on the
|
|
same arm with the same fields filled, so no capability sniffs for a shape.
|
|
"""
|
|
a = answer.read(
|
|
reply(
|
|
402,
|
|
{
|
|
"error": "payment_required",
|
|
"product": "inference",
|
|
"reason": "unpaid",
|
|
"message": "no active subscription for inference and no prepaid credit",
|
|
"cure": [{"kind": "raise-cap", "url": "/v1/billing/limits"}],
|
|
},
|
|
),
|
|
_never,
|
|
)
|
|
assert (a.code, a.product) == ("payment_required", "inference")
|
|
assert a.reason == "no active subscription for inference and no prepaid credit"
|
|
assert a.cures == (Cure(kind="raise-cap", url="/v1/billing/limits"),)
|
|
|
|
|
|
@pytest.mark.parametrize("code", ["spend_cap_exceeded", "insufficient_balance"])
|
|
def test_a_403_carrying_a_refusal_code_is_denied(code):
|
|
"""The workaround for one cloud defect, and the only reason a code list exists.
|
|
|
|
Cloud spells "no validated principal" as 403 forbidden, so the status alone
|
|
cannot say whether the caller was refused or was never let in. These two
|
|
codes say it was refused, and they are the two cloud emits. Once cloud
|
|
answers 401 for the other case, the rule collapses to "402 or 403" and this
|
|
list goes.
|
|
"""
|
|
a = answer.read(reply(403, {"code": code, "detail": "no"}), _never)
|
|
assert isinstance(a, Denied)
|
|
assert a.code == code
|
|
|
|
|
|
@pytest.mark.parametrize("code", ["policy_denied", "entitlement_required"])
|
|
def test_a_403_carrying_a_code_cloud_does_not_emit_decided_nothing(code):
|
|
"""No route in cloud writes either word.
|
|
|
|
A 403 an SDK read as denied on a code no server sends would hand a caller a
|
|
cure for a refusal nobody made — and the same 403 is what an unauthenticated
|
|
call collects.
|
|
"""
|
|
with pytest.raises(Fault):
|
|
answer.read(reply(403, {"code": code, "detail": "no"}), _never)
|
|
|
|
|
|
def test_a_bare_403_decided_nothing_and_raises():
|
|
"""`{"code": "forbidden"}` is what an unauthenticated call collects.
|
|
|
|
Nothing was decided about the request, so there is no arm to read. This is
|
|
the live body of `GET /v1/allowance` with no credential.
|
|
"""
|
|
with pytest.raises(Fault) as caught:
|
|
answer.read(reply(403, {"code": "forbidden", "detail": "allowance: a validated principal is required"}), _never)
|
|
assert caught.value.status == 403
|
|
assert "a validated principal is required" in str(caught.value)
|
|
assert "req_1" in str(caught.value), "the request id has to survive into the error"
|
|
|
|
|
|
def test_a_202_carrying_a_hold_is_held():
|
|
a = answer.read(reply(202, HOLD), _never)
|
|
|
|
assert a.held is a
|
|
assert isinstance(a, Held)
|
|
assert (a.id, a.clause, a.reason) == ("apr_7f3", "graph.assert", "a person reviews graph writes")
|
|
assert a.denied is None
|
|
|
|
|
|
def test_a_202_that_is_not_a_hold_is_ok():
|
|
"""The body decides, never the status code.
|
|
|
|
A dozen long-running operations answer 202 for "accepted, working on it".
|
|
Reading 202 as a hold would break every one of them.
|
|
"""
|
|
a = answer.read(reply(202, {"id": "dep_1", "status": "queued"}), lambda b: b["id"])
|
|
assert isinstance(a, Ok)
|
|
assert a.value == "dep_1"
|
|
|
|
|
|
@pytest.mark.parametrize("status", [401, 400, 404, 409, 429, 500, 503])
|
|
def test_a_status_with_no_decision_in_it_raises(status):
|
|
with pytest.raises(Fault) as caught:
|
|
answer.read(reply(status, {"detail": "no"}), _never)
|
|
assert caught.value.status == status
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# What the arms let you do
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_reading_the_value_of_a_refusal_raises_the_refusal():
|
|
"""The whole safety property: you cannot reach past an arm you did not check."""
|
|
a = answer.read(reply(402, REFUSAL), _never)
|
|
with pytest.raises(Denied) as caught:
|
|
_ = a.value
|
|
assert caught.value.code == "insufficient_balance"
|
|
|
|
|
|
def test_reading_the_value_of_a_hold_raises_the_hold():
|
|
a = answer.read(reply(202, HOLD), _never)
|
|
with pytest.raises(Held) as caught:
|
|
_ = a.value
|
|
assert caught.value.id == "apr_7f3"
|
|
|
|
|
|
def test_the_arms_are_matched_structurally():
|
|
"""Three frozen types with a discriminant, so `match` sees them apart."""
|
|
seen = []
|
|
for a in (
|
|
answer.read(reply(200, {}), lambda _: "ran"),
|
|
answer.read(reply(402, REFUSAL), _never),
|
|
answer.read(reply(202, HOLD), _never),
|
|
):
|
|
match a:
|
|
case Ok(value=v):
|
|
seen.append(v)
|
|
case Denied(code=code):
|
|
seen.append(code)
|
|
case Held(id=id):
|
|
seen.append(id)
|
|
assert seen == ["ran", "insufficient_balance", "apr_7f3"]
|
|
|
|
|
|
def test_every_arm_carries_the_request_id():
|
|
"""The join to the audit trail. It is on all three or the composition fails."""
|
|
arms = [
|
|
answer.read(reply(200, {}), lambda _: None),
|
|
answer.read(reply(402, REFUSAL), _never),
|
|
answer.read(reply(202, HOLD), _never),
|
|
]
|
|
assert [a.request for a in arms] == ["req_1"] * 3
|
|
|
|
|
|
def test_a_refusal_says_what_it_is_when_printed():
|
|
a = answer.read(reply(402, REFUSAL), _never)
|
|
assert str(a) == "insufficient_balance: the wallet is empty"
|
|
assert str(answer.read(reply(202, HOLD), _never)) == "held on graph.assert: a person reviews graph writes"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The plain read
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_plain_read_answers_its_body():
|
|
assert answer.value(reply(200, {"plan": "pro"})) == {"plan": "pro"}
|
|
|
|
|
|
def test_a_plain_read_that_failed_raises_rather_than_answering_nothing():
|
|
with pytest.raises(Fault) as caught:
|
|
answer.value(reply(401, {"code": "unauthorized", "detail": "sign in to view the audit trail"}))
|
|
assert caught.value.status == 401
|
|
|
|
|
|
def _never(body):
|
|
raise AssertionError("a refused or held answer must not decode a value")
|