courier-encode 0.1.0__tar.gz → 0.1.2__tar.gz

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.
Files changed (52) hide show
  1. courier_encode-0.1.2/.idea/workspace.xml +83 -0
  2. {courier_encode-0.1.0 → courier_encode-0.1.2}/PKG-INFO +2 -2
  3. {courier_encode-0.1.0 → courier_encode-0.1.2}/docs.md +73 -5
  4. {courier_encode-0.1.0 → courier_encode-0.1.2}/pyproject.toml +2 -2
  5. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/__init__.py +2 -0
  6. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/_http.py +11 -0
  7. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/_streaming.py +43 -0
  8. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/messages.py +12 -5
  9. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/relay.py +693 -27
  10. courier_encode-0.1.2/tests/test_streaming.py +338 -0
  11. {courier_encode-0.1.0 → courier_encode-0.1.2}/uv.lock +1 -1
  12. courier_encode-0.1.0/.idea/workspace.xml +0 -122
  13. courier_encode-0.1.0/tests/test_streaming.py +0 -68
  14. {courier_encode-0.1.0 → courier_encode-0.1.2}/.gitignore +0 -0
  15. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/.gitignore +0 -0
  16. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/encode.iml +0 -0
  17. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/inspectionProfiles/Project_Default.xml +0 -0
  18. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  19. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/misc.xml +0 -0
  20. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/modules.xml +0 -0
  21. {courier_encode-0.1.0 → courier_encode-0.1.2}/.idea/vcs.xml +0 -0
  22. {courier_encode-0.1.0 → courier_encode-0.1.2}/LICENSE +0 -0
  23. {courier_encode-0.1.0 → courier_encode-0.1.2}/README.md +0 -0
  24. {courier_encode-0.1.0 → courier_encode-0.1.2}/examples/basic_chat.py +0 -0
  25. {courier_encode-0.1.0 → courier_encode-0.1.2}/examples/intercept.py +0 -0
  26. {courier_encode-0.1.0 → courier_encode-0.1.2}/examples/responses_endpoint.py +0 -0
  27. {courier_encode-0.1.0 → courier_encode-0.1.2}/examples/structured_output.py +0 -0
  28. {courier_encode-0.1.0 → courier_encode-0.1.2}/examples/tools_loop.py +0 -0
  29. {courier_encode-0.1.0 → courier_encode-0.1.2}/examples/whisper_transcribe.py +0 -0
  30. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/_config.py +0 -0
  31. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/_schema.py +0 -0
  32. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/_version.py +0 -0
  33. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/client.py +0 -0
  34. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/errors.py +0 -0
  35. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/py.typed +0 -0
  36. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/responses.py +0 -0
  37. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/session.py +0 -0
  38. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/tools.py +0 -0
  39. {courier_encode-0.1.0 → courier_encode-0.1.2}/src/encode/whisper.py +0 -0
  40. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/__init__.py +0 -0
  41. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/conftest.py +0 -0
  42. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_async.py +0 -0
  43. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_errors.py +0 -0
  44. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_messages.py +0 -0
  45. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_messages_obj.py +0 -0
  46. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_relay_chat.py +0 -0
  47. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_relay_responses.py +0 -0
  48. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_response_format.py +0 -0
  49. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_session.py +0 -0
  50. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_tool_loop.py +0 -0
  51. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_tools_schema.py +0 -0
  52. {courier_encode-0.1.0 → courier_encode-0.1.2}/tests/test_whisper.py +0 -0
@@ -0,0 +1,83 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="ALL" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="9259bfb5-884c-4336-83fd-f640b45e88c5" name="Changes" comment="">
8
+ <change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
9
+ <change beforePath="$PROJECT_DIR$/src/encode/_http.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/_http.py" afterDir="false" />
10
+ <change beforePath="$PROJECT_DIR$/src/encode/relay.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/relay.py" afterDir="false" />
11
+ <change beforePath="$PROJECT_DIR$/tests/test_streaming.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_streaming.py" afterDir="false" />
12
+ <change beforePath="$PROJECT_DIR$/uv.lock" beforeDir="false" afterPath="$PROJECT_DIR$/uv.lock" afterDir="false" />
13
+ </list>
14
+ <option name="SHOW_DIALOG" value="false" />
15
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
16
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
17
+ <option name="LAST_RESOLUTION" value="IGNORE" />
18
+ </component>
19
+ <component name="EmbeddingIndexingInfo">
20
+ <option name="cachedIndexableFilesCount" value="3" />
21
+ <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
22
+ </component>
23
+ <component name="Git.Settings">
24
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
25
+ </component>
26
+ <component name="McpProjectServerCommands">
27
+ <commands />
28
+ <urls />
29
+ </component>
30
+ <component name="ProjectColorInfo"><![CDATA[{
31
+ "associatedIndex": 8,
32
+ "fromUser": false
33
+ }]]></component>
34
+ <component name="ProjectId" id="3DRfPMmybWPzhvemSx3K8mYqPeH" />
35
+ <component name="ProjectViewState">
36
+ <option name="hideEmptyMiddlePackages" value="true" />
37
+ <option name="showLibraryContents" value="true" />
38
+ </component>
39
+ <component name="PropertiesComponent"><![CDATA[{
40
+ "keyToString": {
41
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
42
+ "RunOnceActivity.MCP Project settings loaded": "true",
43
+ "RunOnceActivity.ShowReadmeOnStart": "true",
44
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
45
+ "RunOnceActivity.git.unshallow": "true",
46
+ "RunOnceActivity.typescript.service.memoryLimit.init": "true",
47
+ "codeWithMe.voiceChat.enabledByDefault": "false",
48
+ "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
49
+ "git-widget-placeholder": "main",
50
+ "junie.onboarding.icon.badge.shown": "true",
51
+ "last_opened_file_path": "/Users/jacksonoaks/Documents/business/recursion_ai/encode",
52
+ "node.js.detected.package.eslint": "true",
53
+ "node.js.detected.package.tslint": "true",
54
+ "node.js.selected.package.eslint": "(autodetect)",
55
+ "node.js.selected.package.tslint": "(autodetect)",
56
+ "nodejs_package_manager_path": "npm",
57
+ "to.speed.mode.migration.done": "true",
58
+ "vue.rearranger.settings.migration": "true"
59
+ }
60
+ }]]></component>
61
+ <component name="SharedIndexes">
62
+ <attachedChunks>
63
+ <set>
64
+ <option value="bundled-js-predefined-d6986cc7102b-3bd3a6803838-JavaScript-PY-261.22158.340" />
65
+ <option value="bundled-python-sdk-b63d5a1f7c97-b61e75351b1f-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.22158.340" />
66
+ </set>
67
+ </attachedChunks>
68
+ </component>
69
+ <component name="TaskManager">
70
+ <task active="true" id="Default" summary="Default task">
71
+ <changelist id="9259bfb5-884c-4336-83fd-f640b45e88c5" name="Changes" comment="" />
72
+ <created>1778249777914</created>
73
+ <option name="number" value="Default" />
74
+ <option name="presentableId" value="Default" />
75
+ <updated>1778249777914</updated>
76
+ <workItem from="1778249778942" duration="11261000" />
77
+ </task>
78
+ <servers />
79
+ </component>
80
+ <component name="TypeScriptGeneratedFilesManager">
81
+ <option name="version" value="3" />
82
+ </component>
83
+ </project>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: courier-encode
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for OpenAI-compatible inference endpoints (Courier and friends) with auto tool-call loops, structured outputs, and Whisper.
5
5
  Project-URL: Homepage, https://getcourier.ai
6
6
  Project-URL: Documentation, https://getcourier.ai/docs
@@ -211,7 +211,7 @@ License-File: LICENSE
211
211
  Keywords: agents,courier,llm,openai,sdk,tool-calling,whisper
212
212
  Classifier: Development Status :: 3 - Alpha
213
213
  Classifier: Intended Audience :: Developers
214
- Classifier: License :: OSI Approved :: MIT License
214
+ Classifier: License :: OSI Approved :: Apache Software License
215
215
  Classifier: Programming Language :: Python :: 3
216
216
  Classifier: Programming Language :: Python :: 3.10
217
217
  Classifier: Programming Language :: Python :: 3.11
@@ -232,7 +232,7 @@ print(out.tool_calls[0].name) # "get_weather"
232
232
  print(out.tool_calls[0].result) # {"city": "Denver", "temp_f": 72}
233
233
  ```
234
234
 
235
- The loop runs until the model stops calling tools (or hits `max_tool_iterations`, default `8`).
235
+ The loop runs until the model stops calling tools. There is no cap by default — pass `max_tool_iterations=N` if you want to bail on a runaway model. See [Capping iterations](#capping-iterations-optional) below.
236
236
 
237
237
  ### What the SDK does for you
238
238
 
@@ -406,7 +406,7 @@ The tool loop and intercept work identically across both endpoints — `function
406
406
 
407
407
  ## Streaming
408
408
 
409
- Set `stream=True` and iterate the handle:
409
+ Set `stream=True` and iterate the handle. The handle yields `StreamEvent` objects — parsed Python values, not raw SSE bytes. The upstream HTTP connection is held open with `httpx.stream()`, so backpressure works end-to-end.
410
410
 
411
411
  ```python
412
412
  handle = encode.relay(
@@ -419,14 +419,82 @@ for event in handle:
419
419
  print(event.data, end="", flush=True)
420
420
  ```
421
421
 
422
- For `/v1/responses`, events use the typed names from the spec (`response.output_text.delta`, `response.completed`, etc.):
422
+ ### `StreamEvent`
423
+
424
+ Every event carries three fields:
425
+
426
+ | field | type | meaning |
427
+ | ------- | ---------------- | ----------------------------------------------------------------------- |
428
+ | `type` | `str` | event kind (see tables below) |
429
+ | `data` | `Any` | the parsed payload — a `str` for text deltas, `dict`/`list` otherwise |
430
+ | `raw` | `dict` \| `None` | the full upstream chunk as parsed JSON (re-serialize this if proxying) |
431
+
432
+ ### Event types — `/v1/chat/completions`
433
+
434
+ | `event.type` | `event.data` |
435
+ | -------------------- | --------------------------------------------------------------------- |
436
+ | `content.delta` | `str` — the next token of assistant text |
437
+ | `tool_calls.delta` | `list[dict]` — partial tool-call fragments (raw upstream deltas) |
438
+ | `tool_call.start` | `{id, name, arguments: dict, iteration}` — fully assembled tool call about to run |
439
+ | `tool_call.result` | `{id, result, result_serialized, duration_ms, iteration}` — tool returned successfully |
440
+ | `tool_call.error` | `{id, error, iteration}` — tool raised; loop continues |
441
+ | `iteration.end` | `{iteration, had_tool_calls}` — one tool-loop iteration completed |
442
+ | `finish` | `str` — final finish reason (`"stop"`, `"length"`, `"tool_calls"`, …) |
443
+
444
+ The new `tool_call.*` events fire **only when `tools=` is set**. Without tools, you only see `content.delta` / `finish`. The raw `tool_calls.delta` events still fire when the model emits tool-call fragments — most chat-UI consumers can ignore them and key off `tool_call.start` / `tool_call.result` instead.
445
+
446
+ ### Event types — `/v1/responses`
447
+
448
+ For the responses endpoint, upstream events are passed through with their `type` field intact (`response.output_text.delta`, `response.completed`, …) and `event.data` set to the entire parsed event dict. When `tools=` is set, encode also synthesizes `content.delta` (mapped from `response.output_text.delta`) and the same `tool_call.start` / `tool_call.result` / `tool_call.error` / `iteration.end` events as chat, so a single consumer can handle both endpoints uniformly.
423
449
 
424
450
  ```python
425
451
  for event in encode.relay(model="m", input="hi", stream=True):
426
452
  print(event.type, event.data)
427
453
  ```
428
454
 
429
- > Streaming with auto-tool-loop is not supported in v0.1.0 — you can only iterate streams when `tools=None`. Use `stream=False` with tools, then re-issue a streaming call yourself if you want to stream the final answer.
455
+ ### Streaming with tools (auto-loop)
456
+
457
+ Pass `tools=` and `stream=True` together. The SDK runs the same auto-tool-loop as `stream=False` but yields events as the iteration proceeds — content tokens forward to the consumer in real time, and each tool dispatch fires `tool_call.start` / `tool_call.result` (or `.error`) events the consumer can render in a chat UI.
458
+
459
+ ```python
460
+ def get_weather(city: str) -> dict:
461
+ """Get current weather by city."""
462
+ return {"city": city, "temp_f": 72}
463
+
464
+ for ev in encode.relay(
465
+ model="gpt-4o-mini",
466
+ messages=[{"role": "user", "content": "What's the weather in Denver?"}],
467
+ tools=[get_weather],
468
+ stream=True,
469
+ ):
470
+ if ev.type == "content.delta":
471
+ print(ev.data, end="", flush=True)
472
+ elif ev.type == "tool_call.start":
473
+ print(f"\n[calling {ev.data['name']}({ev.data['arguments']})]")
474
+ elif ev.type == "tool_call.result":
475
+ print(f"[result: {ev.data['result']}]")
476
+ elif ev.type == "tool_call.error":
477
+ print(f"[tool error: {ev.data['error']}]")
478
+ ```
479
+
480
+ `max_tool_iterations` still works — passing a cap raises `MaxToolIterationsError` (with `.partial` carrying the streamed-so-far state) the moment the cap is exceeded.
481
+
482
+ `Messages` containers passed as `messages=` are mutated when the stream finishes, just like the non-stream path. If the consumer abandons the iterator early (breaks out of the loop), the container is **not** updated — drain the iterator if you want the absorption.
483
+
484
+ ### Async streaming
485
+
486
+ Use `relay_async` with `async for`:
487
+
488
+ ```python
489
+ handle = encode.relay_async(model="m", messages=[...], tools=[get_weather], stream=True)
490
+ async for event in handle:
491
+ if event.type == "content.delta":
492
+ print(event.data, end="", flush=True)
493
+ ```
494
+
495
+ ### Restrictions
496
+
497
+ - **`response_format` is not supported when streaming.** Combining the two raises `ValueError` immediately — structured output isn't meaningful mid-stream.
430
498
 
431
499
  ---
432
500
 
@@ -691,7 +759,7 @@ async for event in AsyncRelayHandle: ...
691
759
  # Models
692
760
  encode.Message, Messages, Conversation, TextContent, ImageContent, AudioContent
693
761
  encode.RelayResponse, WhisperResponse, ToolCallRecord, AssistantTurn, Usage
694
- encode.InterceptEvent
762
+ encode.InterceptEvent, StreamEvent
695
763
 
696
764
  # Errors (all inherit CourierError)
697
765
  encode.AuthError, InvalidRequestError, InvalidToolCallError, InvalidToolChoiceError,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "courier-encode"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Python SDK for OpenAI-compatible inference endpoints (Courier and friends) with auto tool-call loops, structured outputs, and Whisper."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ keywords = ["openai", "courier", "llm", "sdk", "agents", "tool-calling", "whispe
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
16
16
  "Intended Audience :: Developers",
17
- "License :: OSI Approved :: MIT License",
17
+ "License :: OSI Approved :: Apache Software License",
18
18
  "Programming Language :: Python :: 3",
19
19
  "Programming Language :: Python :: 3.10",
20
20
  "Programming Language :: Python :: 3.11",
@@ -22,6 +22,7 @@ from . import _config
22
22
  # Auto-load .env once on import (opt-out via ENCODE_DISABLE_DOTENV=1).
23
23
  _config.load_dotenv_once()
24
24
 
25
+ from ._streaming import StreamEvent
25
26
  from ._version import __version__
26
27
  from .client import AsyncClient, Client
27
28
  from .errors import (
@@ -77,6 +78,7 @@ __all__ = [
77
78
  "RelayHandle",
78
79
  "AsyncRelayHandle",
79
80
  "InterceptEvent",
81
+ "StreamEvent",
80
82
  "whisper",
81
83
  "whisper_async",
82
84
  "Message",
@@ -47,6 +47,17 @@ def parse_body(resp: httpx.Response) -> Any:
47
47
  def raise_for_status(resp: httpx.Response) -> None:
48
48
  if resp.is_success:
49
49
  return
50
+ # Idempotent for already-buffered responses; required for sync streaming.
51
+ resp.read()
52
+ body = parse_body(resp)
53
+ raise errors.from_envelope(body, status=resp.status_code)
54
+
55
+
56
+ async def araise_for_status(resp: httpx.Response) -> None:
57
+ if resp.is_success:
58
+ return
59
+ # Required for responses obtained via async_client.stream(...).
60
+ await resp.aread()
50
61
  body = parse_body(resp)
51
62
  raise errors.from_envelope(body, status=resp.status_code)
52
63
 
@@ -95,3 +95,46 @@ async def aiter_responses(resp: httpx.Response) -> AsyncIterator[StreamEvent]:
95
95
  continue
96
96
  etype = event.get("type", "unknown")
97
97
  yield StreamEvent(type=etype, data=event, raw=event)
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Chat tool-call delta accumulator
102
+ #
103
+ # Chat completions emits tool calls as a sequence of partial deltas keyed by
104
+ # `index`. Each chunk may contribute id/type/function.name on first arrival
105
+ # and append more bytes to function.arguments on subsequent chunks. The relay
106
+ # tool-loop needs the assembled list at end-of-stream to dispatch tools.
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def accumulate_chat_tool_calls(
111
+ buf: dict[int, dict[str, Any]],
112
+ deltas: list[dict[str, Any]] | None,
113
+ ) -> None:
114
+ """Merge a chunk's `tool_calls` delta list into ``buf`` keyed by index.
115
+
116
+ Mutates ``buf`` in place. Safe to call with a ``None`` or empty deltas list.
117
+ """
118
+ if not deltas:
119
+ return
120
+ for d in deltas:
121
+ idx = d.get("index", 0)
122
+ slot = buf.setdefault(
123
+ idx, {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
124
+ )
125
+ if d.get("id"):
126
+ slot["id"] = d["id"]
127
+ if d.get("type"):
128
+ slot["type"] = d["type"]
129
+ fn = d.get("function") or {}
130
+ if fn.get("name"):
131
+ slot["function"]["name"] = fn["name"]
132
+ if fn.get("arguments"):
133
+ slot["function"]["arguments"] += fn["arguments"]
134
+
135
+
136
+ def finalize_chat_tool_calls(
137
+ buf: dict[int, dict[str, Any]],
138
+ ) -> list[dict[str, Any]]:
139
+ """Return the buffered tool calls sorted by index, in /v1/chat shape."""
140
+ return [buf[k] for k in sorted(buf)]
@@ -14,7 +14,7 @@ import mimetypes
14
14
  from collections.abc import Iterable, Iterator, Sequence
15
15
  from os import PathLike
16
16
  from pathlib import Path
17
- from typing import TYPE_CHECKING, Any, Literal
17
+ from typing import TYPE_CHECKING, Any, Literal, overload
18
18
 
19
19
  from pydantic import BaseModel, ConfigDict
20
20
 
@@ -193,15 +193,16 @@ def _coerce_content(content: Any) -> Any:
193
193
  return content
194
194
 
195
195
 
196
- class Messages:
196
+ class Messages(Sequence[dict[str, Any]]):
197
197
  """Mutable conversation container.
198
198
 
199
199
  Pass to ``relay()`` / ``relay_async()`` as ``messages=`` and the SDK will
200
200
  append the new turns in place after the loop completes. Plain lists work
201
201
  too — they are not mutated.
202
202
 
203
- Quacks like a list (``__len__``, ``__iter__``, ``__getitem__``, ``__bool__``)
204
- and exposes chainable adders for ergonomic construction.
203
+ Implements :class:`collections.abc.Sequence`, so it satisfies
204
+ ``Sequence[Any]`` parameter types and supports ``len()``, iteration,
205
+ indexing, slicing, ``in``, ``index()``, ``count()``, etc.
205
206
 
206
207
  Example:
207
208
  m = (
@@ -282,7 +283,13 @@ class Messages:
282
283
  def __iter__(self) -> Iterator[dict[str, Any]]:
283
284
  return iter(self._items)
284
285
 
285
- def __getitem__(self, idx: int) -> dict[str, Any]:
286
+ @overload
287
+ def __getitem__(self, idx: int) -> dict[str, Any]: ...
288
+ @overload
289
+ def __getitem__(self, idx: slice) -> list[dict[str, Any]]: ...
290
+ def __getitem__(
291
+ self, idx: int | slice
292
+ ) -> dict[str, Any] | list[dict[str, Any]]:
286
293
  return self._items[idx]
287
294
 
288
295
  def __bool__(self) -> bool: