levanto 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
levanto/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """Levanto: a thin, typed Python client for the Levanto Sage decision API.
2
+
3
+ Public surface:
4
+
5
+ * :class:`LevantoClient` / :class:`AsyncLevantoClient` -- the clients.
6
+ * Question types: :class:`YesNo`, :class:`Choice`, :class:`Scale`,
7
+ :class:`Sort`, :class:`Tags`.
8
+ * Sub-objects: :class:`ChoiceOption`, :class:`ScaleLevel`, :class:`TagSpec`,
9
+ :class:`Grounding`.
10
+ * Errors: :class:`LevantoError` and its subclasses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ._http import __version__
16
+ from .client import AsyncLevantoClient, Group, LevantoClient
17
+ from .errors import (
18
+ AuthError,
19
+ LevantoAPIError,
20
+ LevantoError,
21
+ ServiceUnavailableError,
22
+ ValidationError,
23
+ )
24
+ from .types import GroupResult
25
+ from .questions import (
26
+ Choice,
27
+ ChoiceOption,
28
+ Grounding,
29
+ Question,
30
+ Scale,
31
+ ScaleLevel,
32
+ Sort,
33
+ Tags,
34
+ TagSpec,
35
+ YesNo,
36
+ )
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ # clients
41
+ "LevantoClient",
42
+ "AsyncLevantoClient",
43
+ # batch grouping
44
+ "Group",
45
+ "GroupResult",
46
+ # questions
47
+ "YesNo",
48
+ "Choice",
49
+ "Scale",
50
+ "Sort",
51
+ "Tags",
52
+ "Question",
53
+ # sub-objects
54
+ "ChoiceOption",
55
+ "ScaleLevel",
56
+ "TagSpec",
57
+ "Grounding",
58
+ # errors
59
+ "LevantoError",
60
+ "AuthError",
61
+ "ValidationError",
62
+ "ServiceUnavailableError",
63
+ "LevantoAPIError",
64
+ ]
levanto/_http.py ADDED
@@ -0,0 +1,390 @@
1
+ """Shared, transport-agnostic core for both clients.
2
+
3
+ Everything that must behave identically across the sync and async clients
4
+ lives here so the two code paths cannot drift:
5
+
6
+ * request-body construction (content normalization, ``id`` defaulting,
7
+ grounding lifting) and question serialization,
8
+ * response parsing (single envelope + batch alignment),
9
+ * HTTP status -> exception mapping,
10
+ * the retry/backoff policy.
11
+
12
+ Only the actual transport call (``httpx.Client.request`` vs
13
+ ``httpx.AsyncClient.request``) differs, and each client implements its own
14
+ thin loop around :func:`should_retry` / :func:`backoff_delay`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
21
+
22
+ import httpx
23
+
24
+ from .errors import (
25
+ AuthError,
26
+ LevantoAPIError,
27
+ LevantoError,
28
+ ServiceUnavailableError,
29
+ ValidationError,
30
+ )
31
+ from .questions import (
32
+ ChoiceOption,
33
+ Grounding,
34
+ ScaleLevel,
35
+ TagSpec,
36
+ Question,
37
+ )
38
+ from .types import BatchItem, Content, Envelope
39
+
40
+ __version__ = "0.1.0"
41
+ USER_AGENT = f"levanto-python/{__version__}"
42
+
43
+ #: Statuses the client retries (the endpoint is scale-to-zero).
44
+ RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # Content normalization
49
+ # --------------------------------------------------------------------------- #
50
+
51
+
52
+ def normalize_content(document: Content) -> Any:
53
+ """Turn a user ``document`` into a wire ``content`` value.
54
+
55
+ * ``str`` is sent as-is (the API accepts a bare string as text).
56
+ * ``dict`` (e.g. ``{"kind": "text" | "list", ...}``) passes through.
57
+ * ``list`` is wrapped as ``{"kind": "list", "value": items}``.
58
+ """
59
+
60
+ if isinstance(document, str):
61
+ return document
62
+ if isinstance(document, dict):
63
+ return document
64
+ if isinstance(document, (list, tuple)):
65
+ return {"kind": "list", "value": list(document)}
66
+ raise TypeError(
67
+ f"document must be str, dict, or list, got {type(document).__name__}"
68
+ )
69
+
70
+
71
+ # --------------------------------------------------------------------------- #
72
+ # Question / sub-object serialization
73
+ # --------------------------------------------------------------------------- #
74
+
75
+
76
+ def _serialize_option(option: Any) -> Dict[str, Any]:
77
+ if isinstance(option, ChoiceOption):
78
+ wire: Dict[str, Any] = {"option": option.option}
79
+ if option.description is not None:
80
+ wire["description"] = option.description
81
+ return wire
82
+ if isinstance(option, dict):
83
+ return option
84
+ if isinstance(option, str):
85
+ return {"option": option}
86
+ raise TypeError(f"invalid choice option: {option!r}")
87
+
88
+
89
+ def _serialize_level(level: Any) -> Dict[str, Any]:
90
+ if isinstance(level, ScaleLevel):
91
+ wire: Dict[str, Any] = {"level": level.level}
92
+ if level.description is not None:
93
+ wire["description"] = level.description
94
+ return wire
95
+ if isinstance(level, dict):
96
+ return level
97
+ raise TypeError(f"invalid scale level: {level!r}")
98
+
99
+
100
+ def _serialize_tag(tag: Any) -> Dict[str, Any]:
101
+ if isinstance(tag, TagSpec):
102
+ wire: Dict[str, Any] = {"id": tag.id}
103
+ if tag.name is not None:
104
+ wire["name"] = tag.name
105
+ if tag.threshold is not None:
106
+ wire["threshold"] = tag.threshold
107
+ return wire
108
+ if isinstance(tag, dict):
109
+ return tag
110
+ if isinstance(tag, str):
111
+ return {"id": tag}
112
+ raise TypeError(f"invalid tag spec: {tag!r}")
113
+
114
+
115
+ def _serialize_grounding(grounding: Grounding) -> Dict[str, Any]:
116
+ # Thin: send only the fields the caller set; the server fills defaults for
117
+ # the rest. Keeps grounding serialization identical to the JS SDK.
118
+ wire: Dict[str, Any] = {}
119
+ if grounding.trigger is not None:
120
+ wire["trigger"] = grounding.trigger
121
+ if grounding.confidence_floor is not None:
122
+ wire["confidence_floor"] = grounding.confidence_floor
123
+ if grounding.extra:
124
+ wire.update(grounding.extra)
125
+ return wire
126
+
127
+
128
+ def _serialize_question(question: Question, *, default_id: str) -> Dict[str, Any]:
129
+ """Build the wire ``question`` object (excluding grounding).
130
+
131
+ ``id`` is filled with ``default_id`` when the question does not carry one;
132
+ a user-supplied id is preserved.
133
+ """
134
+
135
+ kind = question.kind
136
+ wire: Dict[str, Any] = {
137
+ "id": question.id if question.id is not None else default_id,
138
+ "kind": kind,
139
+ }
140
+
141
+ if kind == "yesno":
142
+ wire["instructions"] = question.instructions
143
+ elif kind == "choice":
144
+ wire["instructions"] = question.instructions
145
+ wire["options"] = [_serialize_option(o) for o in question.options]
146
+ elif kind == "scale":
147
+ wire["instructions"] = question.instructions
148
+ wire["levels"] = [_serialize_level(level) for level in question.levels]
149
+ elif kind == "sort":
150
+ wire["instructions"] = question.instructions
151
+ elif kind == "tags":
152
+ # `instructions` is optional for tags; only emit it when set.
153
+ if getattr(question, "instructions", None) is not None:
154
+ wire["instructions"] = question.instructions
155
+ wire["tags"] = [_serialize_tag(t) for t in question.tags]
156
+ else: # pragma: no cover - defensive
157
+ raise ValueError(f"unknown question kind: {kind!r}")
158
+
159
+ return wire
160
+
161
+
162
+ def _build_request_entry(
163
+ content: Any, question: Question, *, default_id: str
164
+ ) -> Dict[str, Any]:
165
+ """One ``{content, question, grounding?}`` entry.
166
+
167
+ Grounding is a top-level sibling of ``content``/``question`` on the wire,
168
+ even though users attach it to the question for ergonomics. ``Sort`` never
169
+ carries grounding.
170
+ """
171
+
172
+ entry: Dict[str, Any] = {
173
+ "content": content,
174
+ "question": _serialize_question(question, default_id=default_id),
175
+ }
176
+ grounding = getattr(question, "grounding", None)
177
+ if grounding is not None:
178
+ entry["grounding"] = _serialize_grounding(grounding)
179
+ return entry
180
+
181
+
182
+ def build_single_body(document: Content, question: Question) -> Dict[str, Any]:
183
+ """Body for ``POST /decide``. Single-call ids default to the kind string."""
184
+
185
+ content = normalize_content(document)
186
+ return _build_request_entry(content, question, default_id=question.kind)
187
+
188
+
189
+ def _serialize_batch_question(question: Question, *, default_id: str) -> Dict[str, Any]:
190
+ """A batch question: the wire question with grounding *embedded*.
191
+
192
+ Unlike single ``/decide`` (where grounding is a top-level sibling), the
193
+ v0.5 batch API carries grounding inside each question object.
194
+ """
195
+
196
+ wire = _serialize_question(question, default_id=default_id)
197
+ grounding = getattr(question, "grounding", None)
198
+ if grounding is not None:
199
+ wire["grounding"] = _serialize_grounding(grounding)
200
+ return wire
201
+
202
+
203
+ def _build_group_entry(
204
+ document: Content, questions: Sequence[Question]
205
+ ) -> Dict[str, Any]:
206
+ """One request group: ``{content, questions}`` (ids default ``q0, q1, ...``)."""
207
+
208
+ return {
209
+ "content": normalize_content(document),
210
+ "questions": [
211
+ _serialize_batch_question(q, default_id=f"q{i}")
212
+ for i, q in enumerate(questions)
213
+ ],
214
+ }
215
+
216
+
217
+ def build_batch_body(
218
+ document: Content, questions: Sequence[Question]
219
+ ) -> Dict[str, Any]:
220
+ """Body for ``POST /decide/batch``: one document + N questions.
221
+
222
+ Under the v0.5 batch API this is a single group, so the shared document is
223
+ sent once (not repeated per question). Ids default to ``q0, q1, ...``.
224
+ """
225
+
226
+ return {"requests": [_build_group_entry(document, list(questions))]}
227
+
228
+
229
+ def build_groups_body(
230
+ groups: Sequence[Tuple[Content, Sequence[Question]]]
231
+ ) -> Dict[str, Any]:
232
+ """Body for ``POST /decide/batch`` with several ``(document, questions)`` groups."""
233
+
234
+ return {"requests": [_build_group_entry(doc, list(qs)) for doc, qs in groups]}
235
+
236
+
237
+ # --------------------------------------------------------------------------- #
238
+ # Response parsing
239
+ # --------------------------------------------------------------------------- #
240
+
241
+
242
+ def parse_group_answers(
243
+ questions: Sequence[Question], group: Dict[str, Any]
244
+ ) -> List[BatchItem]:
245
+ """Flatten one group's ``answers`` into ``BatchItem``s, aligned to ``questions``.
246
+
247
+ ``id``/``kind`` come from each request's question (with the same ``q{i}``
248
+ id defaulting used when building the request), so answers map back cleanly.
249
+ """
250
+
251
+ answers = (group or {}).get("answers", [])
252
+ items: List[BatchItem] = []
253
+ for i, question in enumerate(questions):
254
+ raw = answers[i] if i < len(answers) else {}
255
+ item: BatchItem = {
256
+ "id": question.id if question.id is not None else f"q{i}",
257
+ "kind": question.kind,
258
+ "ok": bool(raw.get("ok", False)),
259
+ }
260
+ # On success the server nests a full single-decide envelope under
261
+ # "result" ({id, kind, result, meta, grounding_meta?}); flatten it so a
262
+ # batch item reads like a single decide (item["result"] is the bare
263
+ # payload, with meta/grounding_meta lifted alongside it).
264
+ envelope = raw.get("result")
265
+ if isinstance(envelope, dict):
266
+ if "result" in envelope:
267
+ item["result"] = envelope["result"]
268
+ if "meta" in envelope:
269
+ item["meta"] = envelope["meta"]
270
+ if envelope.get("grounding_meta") is not None:
271
+ item["grounding_meta"] = envelope["grounding_meta"]
272
+ if raw.get("error") is not None:
273
+ item["error"] = raw["error"]
274
+ items.append(item)
275
+ return items
276
+
277
+
278
+ def parse_batch(
279
+ questions: Sequence[Question], data: Dict[str, Any]
280
+ ) -> List[BatchItem]:
281
+ """Single-group batch response: flatten ``results[0].answers``."""
282
+
283
+ results = data.get("results", [])
284
+ first = results[0] if results else {}
285
+ return parse_group_answers(questions, first)
286
+
287
+
288
+ # --------------------------------------------------------------------------- #
289
+ # Error mapping
290
+ # --------------------------------------------------------------------------- #
291
+
292
+
293
+ def _extract_detail(response: httpx.Response) -> Optional[str]:
294
+ try:
295
+ data = response.json()
296
+ except Exception:
297
+ text = response.text
298
+ return text or None
299
+ if isinstance(data, dict):
300
+ detail = data.get("detail")
301
+ if isinstance(detail, str):
302
+ return detail
303
+ if detail is not None:
304
+ return str(detail)
305
+ for key in ("message", "error"):
306
+ value = data.get(key)
307
+ if isinstance(value, str):
308
+ return value
309
+ return None
310
+ if isinstance(data, str):
311
+ return data
312
+ return None
313
+
314
+
315
+ def map_error(response: httpx.Response) -> LevantoError:
316
+ """Map a non-2xx response to the appropriate exception instance."""
317
+
318
+ status = response.status_code
319
+ detail = _extract_detail(response)
320
+ message = f"HTTP {status}" + (f": {detail}" if detail else "")
321
+
322
+ if status in (401, 402):
323
+ return AuthError(message, status=status, detail=detail)
324
+ if status in (400, 422):
325
+ return ValidationError(message, status=status, detail=detail)
326
+ if status == 503:
327
+ return ServiceUnavailableError(message, status=status, detail=detail)
328
+ return LevantoAPIError(message, status=status, detail=detail)
329
+
330
+
331
+ def handle_response(response: httpx.Response) -> Any:
332
+ """Return the parsed JSON body for a 2xx response, else raise."""
333
+
334
+ if response.is_success:
335
+ return response.json()
336
+ raise map_error(response)
337
+
338
+
339
+ def parse_single(data: Dict[str, Any]) -> Envelope:
340
+ """The single-decision envelope is returned as-is (already the right shape)."""
341
+
342
+ return data # type: ignore[return-value]
343
+
344
+
345
+ # --------------------------------------------------------------------------- #
346
+ # Retry policy (shared by both transports)
347
+ # --------------------------------------------------------------------------- #
348
+
349
+
350
+ def should_retry(status: int, attempt: int, max_retries: int) -> bool:
351
+ """Whether a response with ``status`` should be retried."""
352
+
353
+ return status in RETRYABLE_STATUSES and attempt < max_retries
354
+
355
+
356
+ def backoff_delay(attempt: int, *, base: float = 0.5, cap: float = 8.0) -> float:
357
+ """Exponential backoff with full jitter, in seconds.
358
+
359
+ ``attempt`` is zero-based (0 for the delay before the first retry).
360
+ """
361
+
362
+ ceiling = min(cap, base * (2 ** attempt))
363
+ return random.uniform(0.0, ceiling)
364
+
365
+
366
+ def default_headers(api_key: str) -> Dict[str, str]:
367
+ return {
368
+ "Authorization": f"Bearer {api_key}",
369
+ "Content-Type": "application/json",
370
+ "User-Agent": USER_AGENT,
371
+ }
372
+
373
+
374
+ __all__ = [
375
+ "normalize_content",
376
+ "build_single_body",
377
+ "build_batch_body",
378
+ "build_groups_body",
379
+ "parse_batch",
380
+ "parse_group_answers",
381
+ "parse_single",
382
+ "handle_response",
383
+ "map_error",
384
+ "should_retry",
385
+ "backoff_delay",
386
+ "default_headers",
387
+ "RETRYABLE_STATUSES",
388
+ "USER_AGENT",
389
+ "__version__",
390
+ ]