mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user