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]>
This commit is contained in:
2026-09-10 16:26:15 -07:00
co-authored by Hanzo Dev
parent c2c8b8fd11
commit 0da7a4185c
14 changed files with 457 additions and 195 deletions
+4
View File
@@ -49,6 +49,7 @@ _HOME = {
"Ok": "hanzoai.answer",
"Denied": "hanzoai.answer",
"Held": "hanzoai.answer",
"Fault": "hanzoai.answer",
"Cure": "hanzoai.answer",
"Money": "hanzoai.budget",
"Allowance": "hanzoai.budget",
@@ -71,6 +72,7 @@ _HOME = {
"Walk": "hanzoai.graph",
"Triple": "hanzoai.graph",
"Vocabulary": "hanzoai.graph",
"Source": "hanzoai.graph",
}
#: The capability modules, reachable as `hanzoai.budget` and so on.
@@ -112,6 +114,7 @@ if TYPE_CHECKING: # what a type checker and an IDE see, without the import cost
Fact as Fact,
Walk as Walk,
Wrote as Wrote,
Source as Source,
Triple as Triple,
Resolution as Resolution,
Vocabulary as Vocabulary,
@@ -121,6 +124,7 @@ if TYPE_CHECKING: # what a type checker and an IDE see, without the import cost
Ok as Ok,
Cure as Cure,
Held as Held,
Fault as Fault,
Answer as Answer,
Denied as Denied,
)
+48 -33
View File
@@ -22,7 +22,7 @@ you cannot reach the value without acknowledging which arm you got.
Statuses with no decision in them — 401, a bare 403, any other 4xx, every 5xx,
a transport failure — are not arms. Nothing was decided, so there is nothing to
read, and they raise :class:`~hanzoai.cloud.exceptions.ApiException`.
read, and they raise :class:`Fault`.
"""
from __future__ import annotations
@@ -33,18 +33,19 @@ from typing_extensions import Literal
from hanzoai.wire import Reply, rows, text
__all__ = ["Cure", "Ok", "Denied", "Held", "Answer", "read", "value", "fault", "error", "held", "REFUSED"]
__all__ = ["Cure", "Ok", "Denied", "Held", "Fault", "Answer", "read", "value", "fault", "held", "REFUSED"]
T = TypeVar("T")
#: The codes that make a 403 a refusal rather than an unauthenticated call.
#:
#: Cloud spells "no validated principal" as 403 forbidden, so status alone
#: cannot separate "authenticated and refused" from "not signed in". These four
#: codes do. The set is deliberately small and closed: it is the workaround for
#: that one defect, not a vocabulary. `Denied.code` itself stays a plain string,
#: so a code cloud adds tomorrow arrives as data.
REFUSED = frozenset({"policy_denied", "entitlement_required", "spend_cap_exceeded", "insufficient_balance"})
#: cannot separate "authenticated and refused" from "not signed in". These two
#: codes do, and they are the two cloud emits: a policy refusal has no code of
#: its own yet, and a code invented here would be one no server sends. The set
#: is the workaround for that one defect, not a vocabulary — `Denied.code`
#: itself stays a plain string, so a code cloud adds tomorrow arrives as data.
REFUSED = frozenset({"spend_cap_exceeded", "insufficient_balance"})
@dataclass(frozen=True)
@@ -119,14 +120,20 @@ class Denied(Exception):
that there are two.
"""
cures = []
for c in rows(body, "cure") + rows(body, "cures"):
for c in rows(body, "cure"):
if isinstance(c, dict):
cures.append(Cure(kind=text(c, "kind"), url=text(c, "url")))
elif isinstance(c, str):
cures.append(Cure(kind=c, url=""))
# The envelope's sentence is `detail` and the money gate's is `message`.
# The money gate's own `reason` — "unpaid", "unresolved" — names the leg
# that failed rather than explaining anything to a person, so it is not
# read. A body that named no code leaves `code` empty: cloud really does
# answer payment_required, and writing that word here would make its
# refusal and an unreadable body the same value.
return cls(
code=text(body, "code") or text(body, "error"),
reason=text(body, "detail") or text(body, "reason") or text(body, "message") or text(body, "title"),
reason=text(body, "detail") or text(body, "message"),
product=text(body, "product"),
cures=tuple(cures),
request=request,
@@ -176,6 +183,32 @@ class Held(Exception):
)
class Fault(Exception):
"""An outcome with no decision in it.
A 401, a bare 403, any other 4xx, every 5xx, a transport failure, an absent
credential. Nothing in it is for a caller to act on except `request`, which
is what support finds the call by. It is not an arm: a refusal a caller can
act on is :class:`Denied` and a call a person was asked about is
:class:`Held`, and reading a network partition as either would let a caller
cache a policy answer nobody gave.
"""
def __init__(self, status: int, code: str = "", reason: str = "", request: str = "") -> None:
self.status = status
#: The RFC 9457 `code`, empty where the answer carried none — including
#: every fault the SDK raises before a request goes out.
self.code = code
self.reason = reason
self.request = request
at = str(status)
if code:
at += " " + code
if request:
at += " (request {0})".format(request)
super().__init__("hanzoai: {0}: {1}".format(at, reason))
#: What a gated call answers. The three arms share `request` and `status` and
#: nothing else, so reading `value` off the wrong one raises instead of handing
#: back a half-answer.
@@ -219,27 +252,9 @@ def value(reply: Reply) -> Any:
raise fault(reply)
def fault(reply: Reply) -> Exception:
"""The error for a reply that decided nothing, naming the call in the trail."""
detail = text(reply.body, "detail") or text(reply.body, "title") or text(reply.body, "error")
if not detail:
detail = reply.body if isinstance(reply.body, str) else "no readable body"
return error(
reply.status,
"{0} (request {1})".format(detail, reply.request or "unstamped"),
reply.body if isinstance(reply.body, str) else "",
)
def error(status: int, reason: str, body: str = "") -> Exception:
"""The SDK's one HTTP error, built in the one place that builds it.
:class:`~hanzoai.cloud.exceptions.ApiException` is imported here rather than
at the top of the module, so describing a failure does not pull the
generated client in behind it. The six capabilities are written over the
wire, not over the 2,479 generated operations; only
:class:`hanzoai.Client` bridges the two.
"""
from hanzoai.cloud.exceptions import ApiException
return ApiException(status=status, reason=reason, body=body or None)
def fault(reply: Reply) -> "Fault":
"""The fault for a reply that decided nothing, naming the call in the trail."""
reason = text(reply.body, "detail") or text(reply.body, "title")
if not reason:
reason = reply.body if isinstance(reply.body, str) else "no readable body"
return Fault(reply.status, text(reply.body, "code"), reason, reply.request)
+18 -8
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Tuple, Iterator, Optional
from datetime import datetime
from dataclasses import dataclass
from dataclasses import replace, dataclass
from hanzoai.page import Page
from hanzoai.wire import rows, text, number, instant
@@ -105,7 +105,12 @@ class Filter:
page: int = 0
def query(self, page: int = 0) -> dict:
"""The query cloud reads, in cloud's own spelling."""
"""The query cloud reads, in cloud's own spelling.
A term nobody set is left out: the route's own defaults are 100 rows and
page 1, so sending them again asks for the same trail in a different
request.
"""
return {
"sub": self.actor,
"action": self.action,
@@ -114,8 +119,8 @@ class Filter:
"result": self.result,
"since": self.since,
"until": self.until,
"pageSize": self.size or SIZE,
"p": page or self.page or 1,
"pageSize": self.size or None,
"p": page or self.page or None,
}
def matches(self, event: Event) -> bool:
@@ -144,15 +149,20 @@ class Audit:
"""Every event the filter matches, page after page.
Asks for page 1 at the filter's size and stops when a page comes back
empty or when the server's own rows reach the total it reported. The
count that decides is the server's, not the narrowed one — a page where
nothing matched :attr:`Filter.request` is not the end of the trail.
empty or when the server's own rows reach the total it reported. What is
counted is what the server sent, not what survived
:attr:`Filter.request` here — a page where nothing matched is not the
end of the trail.
The size is pinned so the walk advances by a page it knows the length
of, rather than by whatever the route defaults to today.
"""
f = filter or Filter()
walked = replace(f, size=f.size or SIZE)
seen = 0
page = f.page or 1
while True:
events, total = self._page(f, page)
events, total = self._page(walked, page)
for event in events:
if f.matches(event):
yield event
+14 -8
View File
@@ -1,7 +1,7 @@
"""What you may spend, what is left, and what it cost.
Allowance and money never stand in for each other. :meth:`Budget.left` answers
a count of free calls; :meth:`Budget.balance` answers a sum of cents. A product
a count of free calls; :meth:`Budget.balance` answers a sum of money. A product
that shows "17 of 20 left today" is reading the first; a gate that admits a paid
request is reading the second. Collapsing them is how a funded account reads as
broke, and a broke one as funded.
@@ -24,13 +24,16 @@ __all__ = ["Money", "Allowance", "Balance", "Plan", "Charge", "Budget"]
@dataclass(frozen=True)
class Money:
"""An amount in integer minor units. Never a float, anywhere, ever.
"""An amount in the currency's minor units. Never a float, anywhere, ever.
Cloud answers USD cents. A float dollar amount cannot represent 0.1 and
a page of sub-cent calls sums to something nobody was charged.
`minor` counts minor units as ISO 4217 defines them: hundredths for USD,
whole yen for JPY, thousandths for KWD. Not "cents", which is false for two
of those three. Cloud answers USD today. A float dollar amount cannot
represent 0.1, and a page of sub-cent calls sums to something nobody was
charged.
"""
cents: int = 0
minor: int = 0
currency: str = "USD"
@@ -72,20 +75,23 @@ class Allowance:
class Balance:
"""The wallet this caller bills from.
`account` echoes which wallet the cents belong to. A caller could only guess
`account` echoes which wallet the money belongs to. A caller could only guess
its own payer by decoding its own token, and a guess that disagrees with the
server is how money lands in an account the gate never reads.
"""
available: Money = Money()
held: Money = Money()
#: What a reservation has claimed and not yet posted. The wire spells it
#: ``holds``; the name here is `reserved` because `held` is an arm of an
#: answer, and one word for two facts is what the naming rule prevents.
reserved: Money = Money()
account: str = ""
@classmethod
def read(cls, body: Any) -> "Balance":
return cls(
available=Money(number(body, "available")),
held=Money(number(body, "holds")),
reserved=Money(number(body, "holds")),
account=text(body, "account"),
)
+21 -10
View File
@@ -36,7 +36,6 @@ from hanzoai.budget import Budget
from hanzoai.policy import Policy
from hanzoai.search import Search
from hanzoai.cloud.api_client import ApiClient
from hanzoai.cloud.exceptions import ApiException
from hanzoai.cloud.configuration import Configuration
__all__ = ["Client"]
@@ -79,13 +78,10 @@ class Client(ApiClient):
super().__init__(Configuration(host=self.base))
if credential is None:
# Construction never fails on a missing credential; the first call
# does, and says which variable is unset (:meth:`hanzoai.Token.token`).
id = id or os.environ.get("HANZO_CLIENT_ID") or ""
secret = secret or os.environ.get("HANZO_CLIENT_SECRET") or ""
if not id or not secret:
raise ApiException(
status=0,
reason="no IAM credential: pass id and secret, or set HANZO_CLIENT_ID and HANZO_CLIENT_SECRET",
)
credential = Token(id, secret, issuer=self.issuer, resource=self.resource, transport=self.rest_client)
self.credential = credential
@@ -127,14 +123,16 @@ class Client(ApiClient):
) -> wire.Reply:
"""One call, with the credential on it and the request id off it.
A `query` entry that is `None` or empty is dropped; a `datetime` is
stamped RFC 3339. A 401 re-mints once and replays, so a rotated token
costs a round trip rather than an error the caller has to handle.
A `query` entry that is `None` or empty is dropped, and so is a `body`
member nobody set — a request carries what the caller determined, and a
JSON null is not that. A `datetime` is stamped RFC 3339. A 401 re-mints
once and replays, so a rotated token costs a round trip rather than an
error the caller has to handle.
"""
headers = {"Accept": "application/json", "Authorization": "Bearer " + self.credential.token()}
if body is not None:
headers["Content-Type"] = media
response = self.call_api(method, self.base + path + _query(query), headers, body)
response = self.call_api(method, self.base + path + _query(query), headers, _body(body))
response.read()
return _reply(response)
@@ -191,6 +189,19 @@ def _query(params: Optional[Dict[str, Any]]) -> str:
return "?" + urlencode(asked) if asked else ""
def _body(body: Any) -> Any:
"""The body cloud reads: the members somebody set, and no nulls.
A member the caller left unset is a member the caller did not narrow by.
Sending it as an explicit null makes three languages send three different
requests for one call, and asks cloud to read a value where there is none.
Anything that is not a JSON object — an upload's bytes — passes through.
"""
if not isinstance(body, dict):
return body
return {k: v for k, v in body.items() if v is not None}
def _reply(response: Any) -> wire.Reply:
"""The decoded answer, and the id that names this call in the trail."""
raw = response.data
+4 -9
View File
@@ -19,7 +19,7 @@ from typing import Any, Optional
from urllib.parse import urlencode
from hanzoai.token import TTL, EARLY, ISSUER
from hanzoai.answer import error
from hanzoai.answer import Fault
__all__ = ["Grant"]
@@ -89,10 +89,9 @@ class Grant:
response.read()
body = response.data.decode("utf-8", "replace") if response.data else ""
if not 200 <= response.status <= 299:
raise error(
raise Fault(
response.status,
"{0} refused an act grant for {1}".format(self.issuer, self.subject),
body,
reason="{0} refused an act grant for {1}: {2}".format(self.issuer, self.subject, body),
)
# IAM answers camelCase here. The oauth mint next door answers RFC 6749
@@ -100,11 +99,7 @@ class Grant:
payload = json.loads(body) if body else {}
token = payload.get("accessToken") if isinstance(payload, dict) else None
if not isinstance(token, str) or not token:
raise error(
response.status,
"IAM issued no token for {0}".format(self.subject),
body,
)
raise Fault(response.status, reason="IAM issued no token for {0}".format(self.subject))
seconds = payload.get("expiresIn")
self._held = token
+56 -28
View File
@@ -25,7 +25,7 @@ from hanzoai.answer import Answer
if TYPE_CHECKING: # the capability layer is written over the wire, not the generated client
from hanzoai.client import Client
__all__ = ["Fact", "Wrote", "Resolution", "Walk", "Triple", "Vocabulary", "Graph"]
__all__ = ["Fact", "Wrote", "Resolution", "Walk", "Triple", "Vocabulary", "Source", "Graph"]
@dataclass(frozen=True)
@@ -35,6 +35,12 @@ class Fact:
`names` declares that `value` is another entity's key, which makes the
assertion an edge — the only thing :meth:`Graph.walk` follows. `source` and
`evidence` are the provenance; `confidence` is how sure the source was.
`id`, `by` and `knowable` are the server's: it mints the content address,
stamps the filer from the validated principal, and derives when the row
became knowable. They arrive on a read and :meth:`write` never sends them,
so a fact read back and asserted again states the nine members an asserter
states and none of the three it cannot.
"""
entity: str = ""
@@ -46,13 +52,22 @@ class Fact:
source: str = ""
evidence: str = ""
confidence: float = 0.0
#: The assertion's content address. Two callers who assert the identical
#: thing land on one id and one row. Read only.
id: str = ""
#: The identity that filed it — ``owner`` or ``owner/user``. Read only.
by: str = ""
#: The first instant this plane could have answered with the assertion,
#: which is what an as-of read is bounded by. Read only.
knowable: Optional[datetime] = None
def write(self) -> Dict[str, Any]:
"""This fact as cloud reads it.
"""This fact as an asserter states it.
An instant nobody set is left out rather than sent as ``""``: cloud
reads an absent `seen` as `at`, and an empty string as a timestamp that
is not RFC 3339.
is not RFC 3339. `id`, `by` and `knowable` are the server's and are
never sent.
"""
out: Dict[str, Any] = {
"entity": self.entity,
@@ -77,6 +92,9 @@ class Fact:
source=wire.text(body, "source"),
evidence=wire.text(body, "evidence"),
confidence=wire.real(body, "confidence"),
id=wire.text(body, "id"),
by=wire.text(body, "by"),
knowable=wire.instant(body.get("knowable")) if isinstance(body, dict) else None,
)
@@ -179,19 +197,48 @@ class Triple:
)
@dataclass(frozen=True)
class Source:
"""A document to read relations out of, and where it came from.
Both :meth:`Graph.extract` and :meth:`Graph.ingest` take it, because they
take the same thing and differ only in what they do with what they found.
"""
#: Where the text came from — a URL, a document id, a page title. Stamped
#: on every assertion as its source, and with the section number as its
#: evidence, so a claim can be traced back to the passage that made it.
source: str = ""
#: The document. A line written ``relation:: value`` states one relation;
#: prose states none.
text: str = ""
#: When what the source says was so. Part of every resulting assertion's
#: content address, so re-reading one source at one instant records one set
#: of rows however many times it is delivered.
at: Optional[datetime] = None
#: The entity the text is about before any heading names one.
subject: str = ""
def write(self) -> Dict[str, Any]:
"""This source as cloud reads it."""
return {"source": self.source, "text": self.text, "subject": self.subject, **_when(at=self.at)}
@dataclass(frozen=True)
class Vocabulary:
"""The relations in use, and the ordering that decides a conflict."""
relations: Tuple[str, ...] = ()
rule: Tuple[str, ...] = ()
#: The terms of the precedence order, in the order they apply. The wire
#: member is ``rule``; this is plural because it is a list.
rules: Tuple[str, ...] = ()
bound: int = 0
@classmethod
def read(cls, body: Any) -> "Vocabulary":
return cls(
relations=wire.strings(body, "relations"),
rule=wire.strings(body, "rule"),
rules=wire.strings(body, "rule"),
bound=wire.number(body, "bound"),
)
@@ -300,28 +347,14 @@ class Graph:
)
)
def extract(
self,
text: str,
*,
source: str = "",
subject: str = "",
at: Optional[datetime] = None,
) -> Tuple[Triple, ...]:
def extract(self, source: Source) -> Tuple[Triple, ...]:
"""Read what a document states, without recording any of it."""
body = self.client.read("POST", "/v1/graph/extract", body=_source(text, source, subject, at))
body = self.client.read("POST", "/v1/graph/extract", body=source.write())
return tuple(Triple.read(t) for t in wire.rows(body, "triples"))
def ingest(
self,
text: str,
*,
source: str = "",
subject: str = "",
at: Optional[datetime] = None,
) -> Answer[Wrote]:
def ingest(self, source: Source) -> Answer[Wrote]:
"""Extract and record in one call, answering the same :class:`Wrote`."""
reply = self.client.send("POST", "/v1/graph/ingest", body=_source(text, source, subject, at))
reply = self.client.send("POST", "/v1/graph/ingest", body=source.write())
return answer.read(reply, Wrote.read)
def vocabulary(self) -> Vocabulary:
@@ -329,11 +362,6 @@ class Graph:
return Vocabulary.read(self.client.read("GET", "/v1/graph/vocabulary"))
def _source(text: str, source: str, subject: str, at: Optional[datetime]) -> Dict[str, Any]:
"""The body both readers of a document take."""
return {"text": text, "source": source, "subject": subject, **_when(at=at)}
def _when(**instants: Optional[datetime]) -> Dict[str, str]:
"""The instants that were set, RFC 3339.
+108 -29
View File
@@ -21,13 +21,27 @@ from urllib.parse import quote
from hanzoai import answer
from hanzoai.page import Page
from hanzoai.wire import Reply, flag, rows, text, number, instant
from hanzoai.wire import Reply, flag, rows, text, number, instant, strings
from hanzoai.answer import Answer
if TYPE_CHECKING: # the capability layer is written over the wire, not the generated client
from hanzoai.client import Client
__all__ = ["Doc", "Import", "Reindex", "Connector", "Link", "Sync", "Node", "Edge", "Links", "Kb", "KINDS"]
__all__ = [
"Doc",
"Import",
"Reindex",
"Connector",
"Link",
"Sync",
"Node",
"Edge",
"Links",
"Kb",
"KINDS",
"doctype",
"kind",
]
#: The three doctypes that hold knowledge. `kind` is one of these everywhere.
KINDS = ("page", "memory", "source")
@@ -36,13 +50,34 @@ KINDS = ("page", "memory", "source")
_TEXT = {"page": "body", "memory": "content", "source": "body"}
def doctype(kind: str) -> str:
"""The doctype a kind is stored under: ``page`` addresses ``kb.page``.
A caller who already wrote the address keeps it, so this is safe to apply
twice. It is public because :mod:`hanzoai.search` filters and reports the
same vocabulary, and this is where the vocabulary lives: a knowledge kind is
one word — page, memory, source — everywhere a caller says one.
"""
return kind if "." in kind else "kb." + kind
def kind(doctype: str) -> str:
"""The inverse: ``kb.page`` is ``page``.
A doctype from another corpus — a lexical row carries its own — has no
``kb.`` prefix and passes through unchanged.
"""
return doctype[3:] if doctype.startswith("kb.") else doctype
@dataclass(frozen=True)
class Doc:
"""One knowledge document. `kind` is page, memory or source.
`name` is its id within the kind — a slug for a page, a hash for a memory or
a source. A `put` with a name replaces that document; a `put` without one
creates a document and cloud names it.
`name` is its id within the kind. A page IS its name: the doctype is
autonamed from the slug field, so writing a page names it. A memory and a
source are named by the store, and the name is what a later read or write
addresses them by.
"""
kind: str = ""
@@ -53,15 +88,24 @@ class Doc:
url: str = ""
def write(self) -> Dict[str, Any]:
"""The document's own field data, in the doctype's spelling."""
"""The document's field data, in the doctype's own spelling — and
nothing else.
The body of a framework write is field data, and a member no doctype
declares is dropped before the store sees it. A page keeps its text in
``body`` and its name in ``slug``; a memory keeps its text in
``content``; only a source declares a ``url``. A field the caller left
empty is left out, so the doctype's own required fields refuse the write
and say which one is missing.
"""
out: Dict[str, Any] = {"title": self.title, _TEXT.get(self.kind, "body"): self.body}
if self.project:
out["project"] = self.project
if self.url:
if self.url and self.kind == "source":
out["url"] = self.url
if self.name and self.kind == "page":
out["slug"] = self.name # a page is named by its slug
return out
return {k: v for k, v in out.items() if v}
@classmethod
def read(cls, body: Any, kind: str) -> "Doc":
@@ -81,14 +125,21 @@ class Import:
`imported` counts pages that were stored, not pages that were sent — the
bounds drop pages past the five-thousandth and skip a page the store
refused. Modelled here because cloud declares no response schema.
refused, and `pages` names the ones that landed. Modelled here because cloud
declares no response schema.
"""
format: str = ""
imported: int = 0
pages: Tuple[str, ...] = ()
@classmethod
def read(cls, body: Any) -> "Import":
return cls(imported=number(body, "imported"))
return cls(
format=text(body, "format"),
imported=number(body, "imported"),
pages=strings(body, "pages"),
)
@dataclass(frozen=True)
@@ -231,29 +282,53 @@ class Kb:
self.client = client
def put(self, doc: Doc) -> Answer[Doc]:
"""Write a document. A name that already exists is replaced; absent, one is created."""
"""Write a document.
A name that already stands is replaced; a name that does not is created.
The store decides which: the replace goes out first and a 404 is the
answer that the document is not there yet, so the create follows. That is
one round trip on a revision — the common case for a corpus — and two on
a first write.
It cannot be decided from the name alone. A page IS its slug, so a page
being created carries a name exactly as a page being revised does, and
reading a present name as "it exists" would leave no way to create one.
"""
path = _path(doc.kind)
reply: Reply = (
self.client.send("PUT", path + "/" + _seg(doc.name), body=doc.write())
if doc.name
else self.client.send("POST", path, body=doc.write())
)
fields = doc.write()
reply: Optional[Reply] = None
if doc.name:
reply = self.client.send("PUT", path + "/" + _seg(doc.name), body=fields)
if reply is None or reply.status == 404:
reply = self.client.send("POST", path, body=fields)
return answer.read(reply, lambda body: Doc.read(body, doc.kind))
def get(self, kind: str, name: str) -> Doc:
"""One document by name."""
return Doc.read(self.client.read("GET", _path(kind) + "/" + _seg(name)), kind)
def list(self, kind: str, *, project: Optional[str] = None, limit: Optional[int] = None) -> Page[Doc]:
def list(
self,
kind: str,
*,
project: Optional[str] = None,
order: Optional[str] = None,
limit: Optional[int] = None,
) -> Page[Doc]:
"""The org's documents of one kind, newest-updated first.
`total` is this page's count: cloud's document list reports no total of
its own, and a number invented here would be a number nobody counted.
`order` is ``"<field> [asc|desc]"``. The route bounds by `limit` alone —
it publishes no count and takes no page number — so `total` is this
page's count: a number invented here would be a number nobody counted.
"""
body = self.client.read(
"GET",
_path(kind),
query={"filters": json.dumps({"project": project}) if project else None, "limit": limit},
query={
"filters": json.dumps({"project": project}) if project else None,
"order_by": order,
"limit": limit,
},
)
docs = tuple(Doc.read(d, kind) for d in rows(body, "data"))
return Page(items=docs, total=len(docs))
@@ -262,18 +337,19 @@ class Kb:
"""Remove one document."""
return answer.read(self.client.send("DELETE", _path(kind) + "/" + _seg(name)), lambda _: None)
def import_(self, export: bytes, *, format: str, project: Optional[str] = None) -> Answer[Import]:
def import_(self, format: str, data: bytes, *, project: Optional[str] = None) -> Answer[Import]:
"""File an exported vault as a tree of pages, links intact.
`format` picks the normalizer — obsidian, notion, roam or evernote. The
trailing underscore is PEP 8's escape for a keyword; the word is
``import``.
`format` picks the normalizer — obsidian, notion, roam or evernote —
and `data` is the export itself: a vault zip, a Roam JSON, an Evernote
.enex. `project` narrows every imported page to one scope. The trailing
underscore is PEP 8's escape for a keyword; the word is ``import``.
"""
reply = self.client.send(
"POST",
"/v1/knowledge/import",
query={"format": format, "project": project},
body=export,
body=data,
media="application/octet-stream",
)
return answer.read(reply, Import.read)
@@ -299,9 +375,12 @@ class Kb:
"""Disconnect `provider`."""
return answer.read(self.client.send("DELETE", "/v1/knowledge/connectors/" + _seg(provider)), lambda _: None)
def links(self) -> Links:
"""The corpus's own parent, wikilink and provenance edges."""
return Links.read(self.client.read("GET", "/v1/knowledge/graph"))
def links(self, *, project: Optional[str] = None) -> Links:
"""The corpus's own parent, wikilink and provenance edges.
No `project` reads the whole org.
"""
return Links.read(self.client.read("GET", "/v1/knowledge/graph", query={"project": project}))
def install(self) -> Answer[None]:
"""Create the kb doctypes in this org. Once, before the first `put`.
@@ -316,7 +395,7 @@ def _path(kind: str) -> str:
"""Where one kind's documents live."""
if kind not in KINDS:
raise ValueError("kind is one of {0}, not {1!r}".format(", ".join(KINDS), kind))
return "/v1/framework/kb." + kind
return "/v1/framework/" + doctype(kind)
def _seg(name: str) -> str:
+7 -3
View File
@@ -3,9 +3,13 @@
Policy appears twice and the two speak one vocabulary. Before a call,
:meth:`Policy.check` answers `allow` as a plain boolean — asking whether you may
is a question with an answer, not a refusal. During a call, a request a policy
stops comes back as :class:`hanzoai.Denied` with code ``policy_denied``, or as
:class:`hanzoai.Held` naming the clause a person still has to clear. The clause
in a hold names the same clause a check would have refused on.
stops comes back as :class:`hanzoai.Held` naming the clause a person still has
to clear. The clause in a hold names the same clause a check would have refused
on.
A policy refusal has no code of its own in cloud today, so a call a policy stops
mid-flight arrives with whatever code the response carried, and this module
invents none.
"""
from __future__ import annotations
+9 -18
View File
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Tuple, Optional, Sequence
from datetime import timedelta
from dataclasses import dataclass
from hanzoai import answer
from hanzoai import kb, answer
from hanzoai.wire import Reply, real, rows, text, number
from hanzoai.answer import Answer
@@ -78,6 +78,9 @@ class Hit:
id: str = ""
corpus: str = ""
#: The knowledge kind that matched: ``page``, ``memory`` or ``source``, the
#: same word :attr:`hanzoai.kb.Doc.kind` takes. A hit out of another corpus
#: carries that corpus's own type and keeps it.
kind: str = ""
title: str = ""
url: str = ""
@@ -90,7 +93,7 @@ class Hit:
return cls(
id=text(body, "id"),
corpus=text(body, "corpus"),
kind=_kind(text(body, "doctype")),
kind=kb.kind(text(body, "doctype")),
title=text(body, "title"),
url=text(body, "url"),
project=text(body, "project"),
@@ -151,7 +154,9 @@ class Search:
`mode` is ``auto``, ``text``, ``semantic`` or ``hybrid``. The modes name
retrieval kinds, not backends: a caller chooses how to search, never
which subsystem answers. `kinds` narrows to ``page``, ``memory`` or
``source`` — the same word :attr:`hanzoai.kb.Doc.kind` uses.
``source`` — the same word :attr:`hanzoai.kb.Doc.kind` uses. The route
filters on the doctype address and silently ignores anything that is not
one, so the mapping happens here rather than in a caller's head.
The rerank leg runs through the AI gateway, which is metered, so this
can come back refused on money. That is an arm, not an exception.
@@ -163,24 +168,10 @@ class Search:
"query": query,
"mode": mode,
"project": project,
"doctypes": [_doctype(k) for k in kinds] if kinds else None,
"doctypes": [kb.doctype(k) for k in kinds] if kinds else None,
"index": index,
"limit": limit,
"offset": offset,
},
)
return answer.read(reply, Hits.read)
def _doctype(kind: str) -> str:
"""``page`` addresses ``kb.page``. A caller who already wrote the address keeps it."""
return kind if "." in kind else "kb." + kind
def _kind(doctype: str) -> str:
"""The inverse, so `kind` means the same word here as it does in `kb`.
A hit out of the code corpus carries its own type and keeps it — only the
knowledge address is unwrapped.
"""
return doctype[3:] if doctype.startswith("kb.") else doctype
+16 -5
View File
@@ -24,7 +24,7 @@ import time
from base64 import b64encode
from typing import Any, Optional
from hanzoai.answer import error
from hanzoai.answer import Fault
__all__ = ["Token", "ISSUER", "BASE", "PATH", "EARLY", "TTL"]
@@ -83,7 +83,19 @@ class Token:
return self.issuer + PATH
def token(self) -> str:
"""The live token, minting one when what is held is gone or nearly so."""
"""The live token, minting one when what is held is gone or nearly so.
A client built with no credential raises here rather than at
construction: there is nothing to exchange, so nothing is sent to IAM
and nothing is sent unsigned to the gateway — an unsigned call comes
back a bare 403, which reads as a refusal of the caller rather than the
absence of one.
"""
if not self.id or not self.secret:
raise Fault(
0,
reason="no IAM client credentials: pass id and secret, or set HANZO_CLIENT_ID and HANZO_CLIENT_SECRET",
)
if self._held is not None and self._until - time.monotonic() > EARLY:
return self._held
return self._mint()
@@ -118,15 +130,14 @@ class Token:
# Say which identity was refused. A 401 here reads the same whether
# the id is wrong, the secret is stale, or the app may not use this
# grant, and the reader is holding none of those.
raise error(
raise Fault(
response.status,
"{0} refused client {1}: {2} {3}".format(
reason="{0} refused client {1}: {2} {3}".format(
self.issuer,
self.id,
payload.get("error", ""),
payload.get("error_description", ""),
).rstrip(),
body,
)
seconds = payload.get("expires_in")
+32 -17
View File
@@ -7,9 +7,8 @@ is exactly what the client hands it.
import pytest
from hanzoai import Ok, Cure, Held, Denied, answer
from hanzoai import Ok, Cure, Held, Fault, Denied, answer
from hanzoai.wire import Reply
from hanzoai.cloud.exceptions import ApiException
REFUSAL = {
"type": "about:blank",
@@ -52,49 +51,65 @@ def test_a_402_is_denied_whatever_the_code_says():
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`; the money gate answers
`{error, product, reason, message, cure}`. Both land on the same arm with
the same fields filled, so no capability sniffs for a shape.
`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": "spend_cap_exceeded",
"error": "payment_required",
"product": "inference",
"reason": "the monthly cap is spent",
"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, a.reason) == ("spend_cap_exceeded", "inference", "the monthly cap is spent")
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", ["policy_denied", "entitlement_required", "spend_cap_exceeded", "insufficient_balance"]
)
@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 four
codes say it was refused. Once cloud answers 401 for the other case, the
rule collapses to "402 or 403" and this list goes.
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(ApiException) as caught:
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)
@@ -123,7 +138,7 @@ def test_a_202_that_is_not_a_hold_is_ok():
@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(ApiException) as caught:
with pytest.raises(Fault) as caught:
answer.read(reply(status, {"detail": "no"}), _never)
assert caught.value.status == status
@@ -192,7 +207,7 @@ def test_a_plain_read_answers_its_body():
def test_a_plain_read_that_failed_raises_rather_than_answering_nothing():
with pytest.raises(ApiException) as caught:
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
+103 -18
View File
@@ -16,8 +16,9 @@ from hanzoai import Ok, Held, Denied, answer
from hanzoai.kb import Kb, Doc, Link, Sync, Reindex
from hanzoai.wire import Reply, query as asked
from hanzoai.audit import Audit, Filter
from hanzoai.graph import Fact, Graph
from hanzoai.graph import Fact, Graph, Source
from hanzoai.budget import Money, Budget
from hanzoai.client import _body as sent
from hanzoai.policy import Policy
from hanzoai.search import Search
@@ -41,7 +42,10 @@ class Calls:
self.asked = []
def send(self, method, path, *, query=None, body=None, media="application/json"):
self.asked.append(Ask(method, path, asked(query), body, media))
# `asked` and `sent` are the two lines `Client.send` is, so what a test
# sees is what would go on the wire: a term nobody set is left out of
# the query, and a member nobody set is left out of the body.
self.asked.append(Ask(method, path, asked(query), sent(body), media))
assert self.replies, "the capability made more calls than the test staged"
return self.replies.pop(0)
@@ -101,15 +105,15 @@ def test_an_unbounded_allowance_has_no_remainder_and_no_reset():
assert allowance.used == 4831
def test_balance_is_integer_cents_and_says_which_wallet():
def test_balance_is_integer_minor_units_and_says_which_wallet():
calls = Calls(ok({"balance": 14991307, "holds": 250, "available": 14991057, "account": "org_acme"}))
balance = Budget(calls).balance()
assert calls.asked_once.path == "/v1/billing/balance"
assert balance.available == Money(cents=14991057, currency="USD")
assert balance.held == Money(cents=250, currency="USD")
assert balance.available == Money(minor=14991057, currency="USD")
assert balance.reserved == Money(minor=250, currency="USD"), "the wire says `holds`; `held` is an arm"
assert balance.account == "org_acme"
assert isinstance(balance.available.cents, int), "money is never a float"
assert isinstance(balance.available.minor, int), "money is never a float"
def test_a_plan_fails_to_locked_for_an_app_nobody_mentioned():
@@ -150,7 +154,7 @@ def test_spent_pages_the_charged_ledger():
assert page.total == 2
assert page.items[0].id == "tx_1"
assert page.items[0].model == "zen4"
assert page.items[0].amount == Money(cents=137, currency="USD")
assert page.items[0].amount == Money(minor=137, currency="USD")
assert page.items[1].at == datetime(2026, 9, 1, 11, 30, tzinfo=timezone.utc)
assert list(page) == list(page.items) and len(page) == 2
@@ -210,7 +214,7 @@ def test_a_row_is_read_in_the_names_the_reader_wants():
calls = Calls(ok({"data": [ROW], "total": 1, "status": "ok"}))
page = Audit(calls).list()
assert calls.asked_once.query == {"pageSize": 100, "p": 1}
assert calls.asked_once.query == {}, "a term nobody set is a term the route defaults itself"
event = page.items[0]
assert event.actor == "usr_7", "`sub` is token jargon; the reader wants who did it"
assert event.id == "acme", "`resourceId` sits beside `resource` and the compound says nothing more"
@@ -314,14 +318,16 @@ def test_find_asks_in_retrieval_words_and_addresses_the_doctypes():
assert calls.asked_once.method == "POST"
assert calls.asked_once.path == "/v1/search"
# `page` and `memory` are the words a caller says; `kb.page` and
# `kb.memory` are the addresses the route filters on, and it drops anything
# that is not one — a query narrowed to "page" would come back narrowed to
# nothing. A member nobody set is left out entirely rather than sent null.
assert calls.asked_once.body == {
"query": "q3 incident postmortems",
"mode": "hybrid",
"project": "ops",
"doctypes": ["kb.page", "kb.memory"],
"index": None,
"limit": 10,
"offset": None,
}
@@ -372,10 +378,45 @@ def test_a_page_is_written_by_slug_and_a_named_document_is_replaced():
assert calls.asked_once.method == "PUT"
assert calls.asked_once.path == "/v1/framework/kb.page/runbook"
# The body is field data and nothing else. A page IS its slug — the doctype
# is autonamed from that field — so the name rides as `slug`, and `name` is
# not a field any of the three doctypes declares.
assert calls.asked_once.body == {"title": "Runbook", "body": "restart it", "project": "ops", "slug": "runbook"}
assert a.value == Doc(kind="page", name="runbook", title="Runbook", body="restart it", project="ops")
def test_a_named_page_that_does_not_stand_yet_is_created():
"""It cannot be decided from the name alone.
A page being created carries a name exactly as a page being revised does,
so reading a present name as "it exists" would leave no way to create one.
The store decides: the replace goes first and a 404 says nothing stands
there.
"""
calls = Calls(
Reply(status=404, body={"detail": "document not found"}),
Reply(status=201, body={"name": "runbook", "title": "Runbook", "body": "restart it"}),
)
a = Kb(calls).put(Doc(kind="page", name="runbook", title="Runbook", body="restart it"))
assert [(c.method, c.path) for c in calls.asked] == [
("PUT", "/v1/framework/kb.page/runbook"),
("POST", "/v1/framework/kb.page"),
]
assert a.value.name == "runbook"
def test_a_url_belongs_to_a_source_and_to_nothing_else():
"""Only `kb.source` declares a `url`. On a page it is a field the store drops."""
calls = Calls(ok({"name": "s1", "title": "Post", "body": "b", "url": "https://acme.example/post"}))
Kb(calls).put(Doc(kind="source", title="Post", body="b", url="https://acme.example/post"))
assert calls.asked_once.body["url"] == "https://acme.example/post"
calls = Calls(ok({"name": "runbook"}))
Kb(calls).put(Doc(kind="page", name="runbook", title="Runbook", body="b", url="https://acme.example/post"))
assert "url" not in calls.asked_once.body
def test_a_document_with_no_name_is_created_and_cloud_names_it():
calls = Calls(Reply(status=201, body={"name": "a3f9", "title": "What we decided", "content": "gold tier"}))
a = Kb(calls).put(Doc(kind="memory", title="What we decided", body="gold tier"))
@@ -404,9 +445,9 @@ def test_a_kind_that_is_not_one_of_the_three_is_refused_before_a_request():
def test_list_narrows_by_project_through_the_framework_s_own_filter():
calls = Calls(ok({"data": [{"name": "runbook", "title": "Runbook", "body": "x", "project": "ops"}]}))
page = Kb(calls).list("page", project="ops", limit=50)
page = Kb(calls).list("page", project="ops", order="modified desc", limit=50)
assert calls.asked_once.query == {"filters": '{"project": "ops"}', "limit": 50}
assert calls.asked_once.query == {"filters": '{"project": "ops"}', "order_by": "modified desc", "limit": 50}
assert page.total == 1, "the document list reports no total of its own; an invented one would be a lie"
assert page.items[0].kind == "page"
@@ -446,13 +487,16 @@ def test_a_sync_and_a_reindex_answer_what_they_moved():
def test_an_import_sends_the_export_verbatim_with_the_normalizer_named():
calls = Calls(ok({"imported": 412}))
a = Kb(calls).import_(b"PK\x03\x04vault", format="obsidian", project="ops")
calls = Calls(ok({"format": "obsidian", "imported": 412, "pages": ["runbook", "oncall"]}))
a = Kb(calls).import_("obsidian", b"PK\x03\x04vault", project="ops")
assert calls.asked_once.query == {"format": "obsidian", "project": "ops"}
assert calls.asked_once.body == b"PK\x03\x04vault"
assert calls.asked_once.media == "application/octet-stream"
assert a.value.imported == 412
# What landed, and which pages: the bounds drop pages past the
# five-thousandth and skip a page the store refused.
assert (a.value.format, a.value.imported) == ("obsidian", 412)
assert a.value.pages == ("runbook", "oncall")
def test_the_corpus_map_states_its_own_incompleteness():
@@ -525,6 +569,45 @@ def test_an_assertion_carries_its_provenance_and_its_two_instants():
assert a.value.recorded == 1
def test_the_server_s_three_members_arrive_on_a_read_and_never_ride_back_out():
"""`id`, `by` and `knowable` are the server's.
It mints the content address, stamps the filer from the validated principal
and derives when the row became knowable. A fact read back and asserted
again states the nine members an asserter states and none of the three it
cannot.
"""
read = Calls(
ok(
{
"assertions": [
{
"id": "sha256:9f2c",
"entity": "acme",
"relation": "tier",
"value": "gold",
"at": "2026-09-10T12:00:00Z",
"seen": "2026-09-10T12:00:00Z",
"knowable": "2026-09-10T12:00:05Z",
"source": "crm",
"by": "acme/usr_7",
}
]
}
)
)
(fact,) = Graph(read).read(entity="acme")
assert fact.id == "sha256:9f2c"
assert fact.by == "acme/usr_7"
assert fact.knowable == datetime(2026, 9, 10, 12, 0, 5, tzinfo=timezone.utc)
wrote = Calls(ok({"recorded": 0, "duplicate": 1, "refused": 0, "reasons": []}))
Graph(wrote).assert_([fact])
sent = wrote.asked_once.body["assertions"][0]
assert not {"id", "by", "knowable"} & set(sent)
def test_each_member_is_judged_on_its_own():
"""One malformed fact does not discard the batch, and the reasons say which."""
calls = Calls(ok({"recorded": 1, "duplicate": 1, "refused": 1, "reasons": ["relation is empty"]}))
@@ -627,7 +710,9 @@ def test_extract_reads_what_a_document_states_without_recording_it():
calls = Calls(
ok({"triples": [{"subject": "acme", "predicate": "tier", "object": "gold", "names": False, "section": 2}]})
)
(triple,) = Graph(calls).extract("Acme is on the gold tier.", source="crm-note", subject="acme", at=WHEN)
(triple,) = Graph(calls).extract(
Source(source="crm-note", text="Acme is on the gold tier.", subject="acme", at=WHEN)
)
assert calls.asked_once.path == "/v1/graph/extract"
assert calls.asked_once.body == {
@@ -641,7 +726,7 @@ def test_extract_reads_what_a_document_states_without_recording_it():
def test_ingest_extracts_and_records_in_one_call():
calls = Calls(ok({"recorded": 3, "duplicate": 0, "refused": 0}))
a = Graph(calls).ingest("Acme is on the gold tier.", source="crm-note")
a = Graph(calls).ingest(Source(source="crm-note", text="Acme is on the gold tier."))
assert calls.asked_once.path == "/v1/graph/ingest"
assert a.value.recorded == 3
@@ -652,7 +737,7 @@ def test_the_vocabulary_says_what_decides_a_conflict():
Calls(ok({"relations": ["tier", "owns"], "rule": ["at", "confidence"], "bound": 1000}))
).vocabulary()
assert vocabulary.relations == ("tier", "owns")
assert vocabulary.rule == ("at", "confidence")
assert vocabulary.rules == ("at", "confidence"), "the wire says `rule`; it is a list"
# --------------------------------------------------------------------------
+17 -9
View File
@@ -10,9 +10,8 @@ import json
import pytest
from hanzoai import Held, Grant, Token, Client
from hanzoai import Held, Fault, Grant, Token, Client
from hanzoai.cloud import IamApi
from hanzoai.cloud.exceptions import ApiException
class Reply:
@@ -108,7 +107,7 @@ def test_the_resource_follows_the_endpoint_the_client_was_pointed_at():
def test_the_oauth_answer_is_snake_case():
"""RFC 6749 spells it `access_token`. camelCase here reads as no token at all."""
mint = Transport(Reply(200, {"accessToken": "wrong", "expiresIn": 600}))
with pytest.raises(ApiException) as caught:
with pytest.raises(Fault) as caught:
Token("cli_1", "shh", transport=mint).token()
assert "cli_1" in str(caught.value)
@@ -137,7 +136,7 @@ def test_a_refused_mint_says_which_identity_was_refused():
"""A 401 reads the same whether the id is wrong, the secret is stale, or the
app may not use this grant — and the reader is holding none of those."""
mint = Transport(Reply(401, {"error": "invalid_client", "error_description": "client authentication required"}))
with pytest.raises(ApiException) as caught:
with pytest.raises(Fault) as caught:
Token("cli_1", "shh", transport=mint).token()
assert caught.value.status == 401
@@ -157,11 +156,20 @@ def test_the_client_takes_no_bearer():
assert not taken & {"token", "api_key", "access_token", "key", "bearer"}
def test_a_client_with_no_credential_refuses_to_be_built(monkeypatch):
def test_a_client_with_no_credential_builds_and_fails_at_the_first_call(monkeypatch):
"""Construction never fails on a missing credential; the first call does.
There is nothing to exchange, so nothing is sent to IAM and nothing is sent
unsigned to the gateway — an unsigned call comes back a bare 403, which
reads as a refusal of the caller rather than the absence of one.
"""
for name in ("HANZO_CLIENT_ID", "HANZO_CLIENT_SECRET"):
monkeypatch.delenv(name, raising=False)
with pytest.raises(ApiException) as caught:
Client()
client = Client()
with pytest.raises(Fault) as caught:
client.credential.token()
assert caught.value.status == 0
assert "HANZO_CLIENT_ID" in str(caught.value)
@@ -208,7 +216,7 @@ def test_the_subject_is_url_encoded():
def test_the_act_answer_is_camel_case():
"""The two mints are different endpoints and each keeps its own wire."""
mint = Transport(minted("tok-operator"), Reply(200, {"access_token": "wrong", "expires_in": 600}))
with pytest.raises(ApiException) as caught:
with pytest.raises(Fault) as caught:
operator(mint).as_("usr_7").credential.token()
assert "usr_7" in str(caught.value)
@@ -312,7 +320,7 @@ def test_a_read_that_was_refused_raises_rather_than_answering_nothing():
)
client = operator(mint, calls)
with pytest.raises(ApiException) as caught:
with pytest.raises(Fault) as caught:
client.audit.list()
assert caught.value.status == 401
assert "sign in to view the audit trail" in str(caught.value)