Files
zeekayandHanzo Dev 0da7a4185c fix(six): one word, one body, one shape across the three SDKs
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]>
2026-09-10 16:26:15 -07:00

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")