querri 1.0.2__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.
- querri/__init__.py +51 -0
- querri/_auth.py +818 -0
- querri/_base_client.py +379 -0
- querri/_client.py +395 -0
- querri/_config.py +140 -0
- querri/_convenience.py +538 -0
- querri/_exceptions.py +180 -0
- querri/_pagination.py +278 -0
- querri/_streaming.py +663 -0
- querri/_user_client.py +193 -0
- querri/_version.py +1 -0
- querri/cli/__init__.py +21 -0
- querri/cli/_app.py +246 -0
- querri/cli/_context.py +216 -0
- querri/cli/_image.py +234 -0
- querri/cli/_output.py +293 -0
- querri/cli/audit.py +68 -0
- querri/cli/auth.py +556 -0
- querri/cli/chat.py +1255 -0
- querri/cli/chats.py +358 -0
- querri/cli/dashboards.py +294 -0
- querri/cli/embed.py +220 -0
- querri/cli/files.py +214 -0
- querri/cli/keys.py +304 -0
- querri/cli/policies.py +424 -0
- querri/cli/projects.py +916 -0
- querri/cli/sharing.py +403 -0
- querri/cli/sources.py +532 -0
- querri/cli/steps.py +123 -0
- querri/cli/usage.py +99 -0
- querri/cli/users.py +223 -0
- querri/cli/views.py +449 -0
- querri/cli/whoami.py +78 -0
- querri/py.typed +0 -0
- querri/resources/__init__.py +1 -0
- querri/resources/audit.py +119 -0
- querri/resources/dashboards.py +295 -0
- querri/resources/embed.py +268 -0
- querri/resources/files.py +145 -0
- querri/resources/keys.py +191 -0
- querri/resources/policies.py +492 -0
- querri/resources/projects.py +808 -0
- querri/resources/sharing.py +330 -0
- querri/resources/sources.py +511 -0
- querri/resources/usage.py +106 -0
- querri/resources/users.py +303 -0
- querri/resources/views.py +347 -0
- querri/types/__init__.py +104 -0
- querri/types/audit.py +21 -0
- querri/types/chat.py +51 -0
- querri/types/dashboard.py +51 -0
- querri/types/data.py +61 -0
- querri/types/embed.py +38 -0
- querri/types/file.py +18 -0
- querri/types/key.py +36 -0
- querri/types/policy.py +105 -0
- querri/types/project.py +158 -0
- querri/types/sharing.py +19 -0
- querri/types/usage.py +39 -0
- querri/types/user.py +33 -0
- querri-1.0.2.dist-info/METADATA +310 -0
- querri-1.0.2.dist-info/RECORD +64 -0
- querri-1.0.2.dist-info/WHEEL +4 -0
- querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_exceptions.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Exception hierarchy for the Querri SDK.
|
|
2
|
+
|
|
3
|
+
Maps Stripe-style API error responses to typed Python exceptions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QuerriError(Exception):
|
|
10
|
+
"""Base exception for all SDK errors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.message = message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIError(QuerriError):
|
|
18
|
+
"""HTTP error from the Querri API.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
status: HTTP status code.
|
|
22
|
+
type: Error type from the API response (e.g., "authentication_error").
|
|
23
|
+
code: Error code from the API response (e.g., "invalid_api_key").
|
|
24
|
+
doc_url: Link to documentation about this error.
|
|
25
|
+
request_id: Unique request identifier for support.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
status: int,
|
|
33
|
+
type: str | None = None,
|
|
34
|
+
code: str | None = None,
|
|
35
|
+
doc_url: str | None = None,
|
|
36
|
+
request_id: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status = status
|
|
40
|
+
self.type = type
|
|
41
|
+
self.code = code
|
|
42
|
+
self.doc_url = doc_url
|
|
43
|
+
self.request_id = request_id
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return (
|
|
47
|
+
f"{self.__class__.__name__}("
|
|
48
|
+
f"status={self.status}, "
|
|
49
|
+
f"type={self.type!r}, "
|
|
50
|
+
f"code={self.code!r}, "
|
|
51
|
+
f"message={self.message!r}"
|
|
52
|
+
f")"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthenticationError(APIError):
|
|
57
|
+
"""401 — Invalid or expired API key."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PermissionError(APIError):
|
|
61
|
+
"""403 — Insufficient scope or access denied."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotFoundError(APIError):
|
|
65
|
+
"""404 — Resource not found."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ValidationError(APIError):
|
|
69
|
+
"""400 — Bad request parameters."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConflictError(APIError):
|
|
73
|
+
"""409 — Duplicate resource or conflict."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RateLimitError(APIError):
|
|
77
|
+
"""429 — Rate limited.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
retry_after: Seconds to wait before retrying (from Retry-After header).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
message: str,
|
|
86
|
+
*,
|
|
87
|
+
status: int = 429,
|
|
88
|
+
retry_after: float | None = None,
|
|
89
|
+
**kwargs: object,
|
|
90
|
+
) -> None:
|
|
91
|
+
# Extract only the kwargs that APIError accepts
|
|
92
|
+
api_kwargs = {
|
|
93
|
+
k: v
|
|
94
|
+
for k, v in kwargs.items()
|
|
95
|
+
if k in ("type", "code", "doc_url", "request_id")
|
|
96
|
+
}
|
|
97
|
+
super().__init__(message, status=status, **api_kwargs) # type: ignore[arg-type]
|
|
98
|
+
self.retry_after = retry_after
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ServerError(APIError):
|
|
102
|
+
"""500+ — Internal server error."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class StreamError(QuerriError):
|
|
106
|
+
"""SSE stream issues."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class StreamTimeoutError(StreamError):
|
|
110
|
+
"""Stream didn't produce data within the expected time."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StreamCancelledError(StreamError):
|
|
114
|
+
"""Stream was cancelled."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ConfigError(QuerriError):
|
|
118
|
+
"""Missing API key, invalid configuration, etc."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Maps HTTP status codes to exception subclasses. Codes not listed here
|
|
122
|
+
# fall through to the base ``APIError``. Multiple 5xx codes share ``ServerError``.
|
|
123
|
+
_STATUS_MAP: dict[int, type[APIError]] = {
|
|
124
|
+
400: ValidationError,
|
|
125
|
+
401: AuthenticationError,
|
|
126
|
+
403: PermissionError,
|
|
127
|
+
404: NotFoundError,
|
|
128
|
+
409: ConflictError,
|
|
129
|
+
429: RateLimitError,
|
|
130
|
+
500: ServerError,
|
|
131
|
+
502: ServerError,
|
|
132
|
+
503: ServerError,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def raise_for_status(
|
|
137
|
+
status: int,
|
|
138
|
+
body: dict[str, object],
|
|
139
|
+
*,
|
|
140
|
+
request_id: str | None = None,
|
|
141
|
+
retry_after: float | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Raise the appropriate exception for an error response.
|
|
144
|
+
|
|
145
|
+
Parses Stripe-style error response bodies of the form
|
|
146
|
+
``{"error": {"type": ..., "code": ..., "message": ...}}``.
|
|
147
|
+
Falls back to a generic message if the body format doesn't match.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
status: HTTP status code.
|
|
151
|
+
body: Parsed JSON response body (expects Stripe-style {"error": {...}}).
|
|
152
|
+
request_id: Request ID from response headers.
|
|
153
|
+
retry_after: Retry-After value from response headers.
|
|
154
|
+
"""
|
|
155
|
+
error = body.get("error", {})
|
|
156
|
+
if isinstance(error, dict):
|
|
157
|
+
err_type = str(error.get("type", ""))
|
|
158
|
+
err_code = str(error.get("code", ""))
|
|
159
|
+
err_message = str(error.get("message", f"HTTP {status}"))
|
|
160
|
+
err_doc_url = error.get("doc_url")
|
|
161
|
+
else:
|
|
162
|
+
err_type = ""
|
|
163
|
+
err_code = ""
|
|
164
|
+
err_message = str(body) if body else f"HTTP {status}"
|
|
165
|
+
err_doc_url = None
|
|
166
|
+
|
|
167
|
+
exc_class = _STATUS_MAP.get(status, APIError)
|
|
168
|
+
|
|
169
|
+
kwargs: dict[str, object] = {
|
|
170
|
+
"status": status,
|
|
171
|
+
"type": err_type or None,
|
|
172
|
+
"code": err_code or None,
|
|
173
|
+
"doc_url": str(err_doc_url) if err_doc_url else None,
|
|
174
|
+
"request_id": request_id,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if exc_class is RateLimitError:
|
|
178
|
+
kwargs["retry_after"] = retry_after
|
|
179
|
+
|
|
180
|
+
raise exc_class(err_message, **kwargs) # type: ignore[arg-type]
|
querri/_pagination.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Auto-paginating iterators for list endpoints.
|
|
2
|
+
|
|
3
|
+
Supports both cursor-based (after/has_more) and offset-based (page/page_size)
|
|
4
|
+
pagination patterns transparently.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Iterator
|
|
10
|
+
from typing import Any, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ._base_client import AsyncHTTPClient, SyncHTTPClient
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T", bound=BaseModel)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncPage(Generic[T]):
|
|
20
|
+
"""A single page of results with metadata."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
data: list[T],
|
|
25
|
+
has_more: bool,
|
|
26
|
+
next_cursor: str | None = None,
|
|
27
|
+
total: int | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.data = data
|
|
30
|
+
self.has_more = has_more
|
|
31
|
+
self.next_cursor = next_cursor
|
|
32
|
+
self.total = total
|
|
33
|
+
|
|
34
|
+
def __len__(self) -> int:
|
|
35
|
+
return len(self.data)
|
|
36
|
+
|
|
37
|
+
def __iter__(self) -> Iterator[T]:
|
|
38
|
+
return iter(self.data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SyncCursorPage(Generic[T]):
|
|
42
|
+
"""Auto-paginating cursor-based iterator.
|
|
43
|
+
|
|
44
|
+
Usage::
|
|
45
|
+
|
|
46
|
+
for user in client.users.list():
|
|
47
|
+
print(user.email)
|
|
48
|
+
|
|
49
|
+
# Or get a single page
|
|
50
|
+
page = client.users.list(limit=20)
|
|
51
|
+
users = page.data
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
http: SyncHTTPClient,
|
|
57
|
+
path: str,
|
|
58
|
+
model: type[T],
|
|
59
|
+
params: dict[str, Any] | None = None,
|
|
60
|
+
data_key: str = "data",
|
|
61
|
+
) -> None:
|
|
62
|
+
self._http = http
|
|
63
|
+
self._path = path
|
|
64
|
+
self._model = model
|
|
65
|
+
self._params = params or {}
|
|
66
|
+
self._data_key = data_key
|
|
67
|
+
self._first_page: SyncPage[T] | None = None
|
|
68
|
+
|
|
69
|
+
def _fetch_page(self, params: dict[str, Any]) -> SyncPage[T]:
|
|
70
|
+
"""Fetch one page from the API and parse the response body.
|
|
71
|
+
|
|
72
|
+
Handles three response formats:
|
|
73
|
+
1. Paginated envelope: ``{"data": [...], "has_more": bool, "next_cursor": str}``
|
|
74
|
+
2. Offset-based fallback: ``{"data": [...], "page": int, "total_pages": int}``
|
|
75
|
+
3. Plain list: ``[...]`` (internal API returns raw arrays)
|
|
76
|
+
|
|
77
|
+
This lets the same iterator work with both the public and internal APIs.
|
|
78
|
+
"""
|
|
79
|
+
response = self._http.get(self._path, params=params)
|
|
80
|
+
body = response.json()
|
|
81
|
+
|
|
82
|
+
# Internal API may return a plain list instead of a paginated envelope
|
|
83
|
+
if isinstance(body, list):
|
|
84
|
+
items = [self._model.model_validate(item) for item in body]
|
|
85
|
+
return SyncPage(data=items, has_more=False)
|
|
86
|
+
|
|
87
|
+
items_raw = body.get(self._data_key, [])
|
|
88
|
+
items = [self._model.model_validate(item) for item in items_raw]
|
|
89
|
+
|
|
90
|
+
has_more = body.get("has_more", False)
|
|
91
|
+
next_cursor = body.get("next_cursor")
|
|
92
|
+
|
|
93
|
+
# Offset-based fallback
|
|
94
|
+
if next_cursor is None and not has_more:
|
|
95
|
+
page_num = body.get("page")
|
|
96
|
+
total_pages = body.get("total_pages")
|
|
97
|
+
if page_num is not None and total_pages is not None:
|
|
98
|
+
has_more = page_num < total_pages
|
|
99
|
+
if has_more:
|
|
100
|
+
next_cursor = str(page_num + 1)
|
|
101
|
+
|
|
102
|
+
total = body.get("total")
|
|
103
|
+
|
|
104
|
+
return SyncPage(
|
|
105
|
+
data=items,
|
|
106
|
+
has_more=has_more,
|
|
107
|
+
next_cursor=next_cursor,
|
|
108
|
+
total=total,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _ensure_first_page(self) -> SyncPage[T]:
|
|
112
|
+
"""Lazy-fetch and cache the first page.
|
|
113
|
+
|
|
114
|
+
Subsequent calls return the cached page.
|
|
115
|
+
"""
|
|
116
|
+
if self._first_page is None:
|
|
117
|
+
self._first_page = self._fetch_page(self._params)
|
|
118
|
+
return self._first_page
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def data(self) -> list[T]:
|
|
122
|
+
"""Items from the first page."""
|
|
123
|
+
return self._ensure_first_page().data
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def has_more(self) -> bool:
|
|
127
|
+
"""Whether more pages exist beyond the current one."""
|
|
128
|
+
return self._ensure_first_page().has_more
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def next_cursor(self) -> str | None:
|
|
132
|
+
"""Opaque cursor for fetching the next page, or None."""
|
|
133
|
+
return self._ensure_first_page().next_cursor
|
|
134
|
+
|
|
135
|
+
def first(self) -> T | None:
|
|
136
|
+
"""Get the first item, or None if empty."""
|
|
137
|
+
data = self.data
|
|
138
|
+
return data[0] if data else None
|
|
139
|
+
|
|
140
|
+
def to_list(self) -> list[T]:
|
|
141
|
+
"""Consume all pages and return items as a flat list."""
|
|
142
|
+
return list(self)
|
|
143
|
+
|
|
144
|
+
def __iter__(self) -> Iterator[T]:
|
|
145
|
+
"""Auto-paginate through all results."""
|
|
146
|
+
page = self._ensure_first_page()
|
|
147
|
+
yield from page.data
|
|
148
|
+
|
|
149
|
+
while page.has_more and page.next_cursor:
|
|
150
|
+
# "after" is the standard cursor param name across all Querri list endpoints
|
|
151
|
+
params = {**self._params, "after": page.next_cursor}
|
|
152
|
+
page = self._fetch_page(params)
|
|
153
|
+
yield from page.data
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AsyncPage(Generic[T]):
|
|
157
|
+
"""A single async page of results."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
data: list[T],
|
|
162
|
+
has_more: bool,
|
|
163
|
+
next_cursor: str | None = None,
|
|
164
|
+
total: int | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
self.data = data
|
|
167
|
+
self.has_more = has_more
|
|
168
|
+
self.next_cursor = next_cursor
|
|
169
|
+
self.total = total
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AsyncCursorPage(Generic[T]):
|
|
173
|
+
"""Auto-paginating async cursor-based iterator.
|
|
174
|
+
|
|
175
|
+
Usage::
|
|
176
|
+
|
|
177
|
+
async for user in client.users.list():
|
|
178
|
+
print(user.email)
|
|
179
|
+
|
|
180
|
+
# Or get a single page
|
|
181
|
+
page = client.users.list(limit=20)
|
|
182
|
+
users = await page.get_data()
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
http: AsyncHTTPClient,
|
|
188
|
+
path: str,
|
|
189
|
+
model: type[T],
|
|
190
|
+
params: dict[str, Any] | None = None,
|
|
191
|
+
data_key: str = "data",
|
|
192
|
+
) -> None:
|
|
193
|
+
self._http = http
|
|
194
|
+
self._path = path
|
|
195
|
+
self._model = model
|
|
196
|
+
self._params = params or {}
|
|
197
|
+
self._data_key = data_key
|
|
198
|
+
self._first_page: AsyncPage[T] | None = None
|
|
199
|
+
|
|
200
|
+
async def _fetch_page(self, params: dict[str, Any]) -> AsyncPage[T]:
|
|
201
|
+
"""Fetch one page from the API and parse the response body.
|
|
202
|
+
|
|
203
|
+
Handles three response formats:
|
|
204
|
+
1. Paginated envelope: ``{"data": [...], "has_more": bool, "next_cursor": str}``
|
|
205
|
+
2. Offset-based fallback: ``{"data": [...], "page": int, "total_pages": int}``
|
|
206
|
+
3. Plain list: ``[...]`` (internal API returns raw arrays)
|
|
207
|
+
|
|
208
|
+
This lets the same iterator work with both the public and internal APIs.
|
|
209
|
+
"""
|
|
210
|
+
response = await self._http.get(self._path, params=params)
|
|
211
|
+
body = response.json()
|
|
212
|
+
|
|
213
|
+
# Internal API may return a plain list instead of a paginated envelope
|
|
214
|
+
if isinstance(body, list):
|
|
215
|
+
items = [self._model.model_validate(item) for item in body]
|
|
216
|
+
return AsyncPage(data=items, has_more=False)
|
|
217
|
+
|
|
218
|
+
items_raw = body.get(self._data_key, [])
|
|
219
|
+
items = [self._model.model_validate(item) for item in items_raw]
|
|
220
|
+
|
|
221
|
+
has_more = body.get("has_more", False)
|
|
222
|
+
next_cursor = body.get("next_cursor")
|
|
223
|
+
|
|
224
|
+
if next_cursor is None and not has_more:
|
|
225
|
+
page_num = body.get("page")
|
|
226
|
+
total_pages = body.get("total_pages")
|
|
227
|
+
if page_num is not None and total_pages is not None:
|
|
228
|
+
has_more = page_num < total_pages
|
|
229
|
+
if has_more:
|
|
230
|
+
next_cursor = str(page_num + 1)
|
|
231
|
+
|
|
232
|
+
total = body.get("total")
|
|
233
|
+
|
|
234
|
+
return AsyncPage(
|
|
235
|
+
data=items,
|
|
236
|
+
has_more=has_more,
|
|
237
|
+
next_cursor=next_cursor,
|
|
238
|
+
total=total,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def _ensure_first_page(self) -> AsyncPage[T]:
|
|
242
|
+
"""Lazy-fetch and cache the first page.
|
|
243
|
+
|
|
244
|
+
Subsequent calls return the cached page.
|
|
245
|
+
"""
|
|
246
|
+
if self._first_page is None:
|
|
247
|
+
self._first_page = await self._fetch_page(self._params)
|
|
248
|
+
return self._first_page
|
|
249
|
+
|
|
250
|
+
async def get_data(self) -> list[T]:
|
|
251
|
+
"""Items from the first page.
|
|
252
|
+
|
|
253
|
+
Unlike ``SyncCursorPage.data`` (a property), this must be awaited::
|
|
254
|
+
|
|
255
|
+
data = await page.get_data()
|
|
256
|
+
"""
|
|
257
|
+
page = await self._ensure_first_page()
|
|
258
|
+
return page.data
|
|
259
|
+
|
|
260
|
+
async def first(self) -> T | None:
|
|
261
|
+
page = await self._ensure_first_page()
|
|
262
|
+
return page.data[0] if page.data else None
|
|
263
|
+
|
|
264
|
+
async def to_list(self) -> list[T]:
|
|
265
|
+
"""Consume all pages and return items as a flat list."""
|
|
266
|
+
return [item async for item in self]
|
|
267
|
+
|
|
268
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
269
|
+
page = await self._ensure_first_page()
|
|
270
|
+
for item in page.data:
|
|
271
|
+
yield item
|
|
272
|
+
|
|
273
|
+
while page.has_more and page.next_cursor:
|
|
274
|
+
# "after" is the standard cursor param name across all Querri list endpoints
|
|
275
|
+
params = {**self._params, "after": page.next_cursor}
|
|
276
|
+
page = await self._fetch_page(params)
|
|
277
|
+
for item in page.data:
|
|
278
|
+
yield item
|