nvisionx 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.
nvisionx/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ from nvisionx import types
2
+ from nvisionx._base_client import DEFAULT_BASE_URL
3
+ from nvisionx._client import AsyncNx, Nx
4
+ from nvisionx._errors import (
5
+ APIConnectionError,
6
+ APIStatusError,
7
+ APITimeoutError,
8
+ AuthenticationError,
9
+ BadRequestError,
10
+ ConflictError,
11
+ InternalServerError,
12
+ JobTimeoutError,
13
+ NotFoundError,
14
+ NxError,
15
+ PaymentRequiredError,
16
+ PermissionDeniedError,
17
+ RateLimitError,
18
+ StreamError,
19
+ UnprocessableEntityError,
20
+ )
21
+ from nvisionx._pagination import AsyncPage, AsyncPaginator, SyncPage
22
+ from nvisionx._streaming import AsyncChatStream, ChatStream
23
+ from nvisionx._version import __version__
24
+
25
+ __all__ = [
26
+ "DEFAULT_BASE_URL",
27
+ "APIConnectionError",
28
+ "APIStatusError",
29
+ "APITimeoutError",
30
+ "AsyncChatStream",
31
+ "AsyncNx",
32
+ "AsyncPage",
33
+ "AsyncPaginator",
34
+ "AuthenticationError",
35
+ "BadRequestError",
36
+ "ChatStream",
37
+ "ConflictError",
38
+ "InternalServerError",
39
+ "JobTimeoutError",
40
+ "NotFoundError",
41
+ "Nx",
42
+ "NxError",
43
+ "PaymentRequiredError",
44
+ "PermissionDeniedError",
45
+ "RateLimitError",
46
+ "StreamError",
47
+ "SyncPage",
48
+ "UnprocessableEntityError",
49
+ "__version__",
50
+ "types",
51
+ ]
nvisionx/_base.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, ValidationError, ValidatorFunctionWrapHandler, WrapValidator
6
+
7
+
8
+ class NxModel(BaseModel):
9
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
10
+
11
+
12
+ def _accept_unknown(value: Any, handler: ValidatorFunctionWrapHandler) -> Any:
13
+ try:
14
+ return handler(value)
15
+ except ValidationError:
16
+ if isinstance(value, (str, int)) and not isinstance(value, bool):
17
+ return value
18
+ raise
19
+
20
+
21
+ Open = WrapValidator(_accept_unknown)
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import random
5
+ import uuid
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import IO, Any, TypeVar
9
+ from urllib.parse import quote
10
+
11
+ import httpx
12
+ from pydantic import BaseModel
13
+
14
+ from nvisionx._errors import NxError, parse_retry_after
15
+ from nvisionx._version import __version__
16
+
17
+ DEFAULT_BASE_URL = "https://platform.us.nvisionx.ai"
18
+ DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
19
+ DEFAULT_MAX_RETRIES = 2
20
+ AGENT_TIMEOUT = httpx.Timeout(300.0, connect=10.0)
21
+ UPLOAD_TIMEOUT = httpx.Timeout(60.0, connect=10.0, write=None)
22
+
23
+ RETRYABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
24
+ INITIAL_RETRY_DELAY = 0.5
25
+ MAX_RETRY_DELAY = 8.0
26
+ MAX_RETRY_AFTER = 60.0
27
+
28
+ ModelT = TypeVar("ModelT", bound=BaseModel)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RequestOptions:
33
+ method: str
34
+ path: str
35
+ params: Mapping[str, Any] | None = None
36
+ json: Any = None
37
+ data: Mapping[str, Any] | None = None
38
+ files: list[Any] | None = None
39
+ headers: Mapping[str, str] = field(default_factory=dict)
40
+ idempotency_key: str | None = None
41
+ timeout: httpx.Timeout | None = None
42
+
43
+
44
+ def path(template: str, **params: str) -> str:
45
+ return template.format(**{k: quote(v, safe="") for k, v in params.items()})
46
+
47
+
48
+ def drop_none(values: Mapping[str, Any]) -> dict[str, Any]:
49
+ return {k: v for k, v in values.items() if v is not None}
50
+
51
+
52
+ class BaseClient:
53
+ api_key: str
54
+ base_url: httpx.URL
55
+ timeout: httpx.Timeout
56
+ max_retries: int
57
+ _owns_http: bool
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ api_key: str | None,
63
+ base_url: str | httpx.URL | None,
64
+ timeout: float | httpx.Timeout | None,
65
+ max_retries: int,
66
+ default_headers: Mapping[str, str] | None,
67
+ ) -> None:
68
+ api_key = api_key or os.environ.get("NX_API_KEY")
69
+ if not api_key:
70
+ raise NxError("No API key: pass api_key= or set NX_API_KEY.")
71
+ self.api_key = api_key
72
+ self.base_url = httpx.URL(str(base_url or os.environ.get("NX_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") + "/")
73
+ if timeout is None:
74
+ self.timeout = DEFAULT_TIMEOUT
75
+ elif isinstance(timeout, httpx.Timeout):
76
+ self.timeout = timeout
77
+ else:
78
+ self.timeout = httpx.Timeout(timeout)
79
+ self.max_retries = max_retries
80
+ self._default_headers = dict(default_headers or {})
81
+
82
+ def _bind_resources(self) -> None:
83
+ raise NotImplementedError
84
+
85
+ @property
86
+ def default_headers(self) -> dict[str, str]:
87
+ return {
88
+ "Authorization": f"Bearer {self.api_key}",
89
+ "Accept": "application/json",
90
+ "User-Agent": f"nvisionx-python/{__version__}",
91
+ **self._default_headers,
92
+ }
93
+
94
+ def _prepare(self, options: RequestOptions) -> RequestOptions:
95
+ if options.method == "GET" or options.idempotency_key:
96
+ return options
97
+ return replace(options, idempotency_key=f"nx-py-{uuid.uuid4()}")
98
+
99
+ def _build_request(self, http: httpx.Client | httpx.AsyncClient, options: RequestOptions) -> httpx.Request:
100
+ headers = {**self.default_headers, **options.headers}
101
+ if options.idempotency_key:
102
+ headers["Idempotency-Key"] = options.idempotency_key
103
+ return http.build_request(
104
+ options.method,
105
+ self.base_url.join(options.path.lstrip("/")),
106
+ params=drop_none(options.params) if options.params else None,
107
+ json=options.json,
108
+ data=options.data,
109
+ files=options.files,
110
+ headers=headers,
111
+ timeout=options.timeout if options.timeout and self.timeout is DEFAULT_TIMEOUT else self.timeout,
112
+ )
113
+
114
+ def _should_retry(self, response: httpx.Response) -> bool:
115
+ return response.status_code in RETRYABLE_STATUSES
116
+
117
+ def _retry_delay(self, attempt: int, response: httpx.Response | None) -> float:
118
+ if response is not None:
119
+ retry_after = parse_retry_after(response)
120
+ if retry_after is not None and retry_after <= MAX_RETRY_AFTER:
121
+ return retry_after
122
+ delay = min(INITIAL_RETRY_DELAY * 2.0**attempt, MAX_RETRY_DELAY)
123
+ return delay * (1 - 0.25 * random.random())
124
+
125
+ @staticmethod
126
+ def _rewindable(options: RequestOptions) -> bool:
127
+ if not options.files:
128
+ return True
129
+ for _, (_, content, _) in options.files:
130
+ if not isinstance(content, bytes) and not (hasattr(content, "seek") and _seekable(content)):
131
+ return False
132
+ return True
133
+
134
+ @staticmethod
135
+ def _rewind(options: RequestOptions) -> None:
136
+ for _, (_, content, _) in options.files or []:
137
+ if not isinstance(content, bytes):
138
+ content.seek(0)
139
+
140
+
141
+ def _seekable(content: IO[bytes]) -> bool:
142
+ try:
143
+ return bool(content.seekable())
144
+ except (AttributeError, OSError, ValueError):
145
+ return False
146
+
147
+
148
+ def parse(cast_to: type[ModelT], response: httpx.Response) -> ModelT:
149
+ return cast_to.model_validate(response.json())
nvisionx/_client.py ADDED
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import time
6
+ from collections.abc import Mapping
7
+ from types import TracebackType
8
+ from typing import TypeVar
9
+
10
+ import httpx
11
+
12
+ from nvisionx._base_client import (
13
+ DEFAULT_MAX_RETRIES,
14
+ BaseClient,
15
+ ModelT,
16
+ RequestOptions,
17
+ parse,
18
+ )
19
+ from nvisionx._errors import APIConnectionError, APITimeoutError, error_from_response
20
+ from nvisionx.resources.agents import Agents, AsyncAgents
21
+ from nvisionx.resources.billing import AsyncBilling, Billing
22
+ from nvisionx.resources.budgets import AsyncBudgets, Budgets
23
+ from nvisionx.resources.chat import AsyncChat, Chat
24
+ from nvisionx.resources.documents import AsyncDocuments, Documents
25
+ from nvisionx.resources.jobs import AsyncJobs, Jobs
26
+ from nvisionx.resources.mcp import AsyncMcp, Mcp
27
+ from nvisionx.resources.search import AsyncSearch, Search
28
+ from nvisionx.resources.sources import AsyncSources, Sources
29
+
30
+ SelfT = TypeVar("SelfT", bound=BaseClient)
31
+
32
+
33
+ def _with_options(
34
+ client: SelfT,
35
+ api_key: str | None,
36
+ base_url: str | httpx.URL | None,
37
+ timeout: float | httpx.Timeout | None,
38
+ max_retries: int | None,
39
+ default_headers: Mapping[str, str] | None,
40
+ ) -> SelfT:
41
+ clone = copy.copy(client)
42
+ if api_key is not None:
43
+ clone.api_key = api_key
44
+ if base_url is not None:
45
+ clone.base_url = httpx.URL(str(base_url).rstrip("/") + "/")
46
+ if timeout is not None:
47
+ clone.timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
48
+ if max_retries is not None:
49
+ clone.max_retries = max_retries
50
+ if default_headers is not None:
51
+ clone._default_headers = {**client._default_headers, **default_headers}
52
+ clone._owns_http = False
53
+ clone._bind_resources()
54
+ return clone
55
+
56
+
57
+ class Nx(BaseClient):
58
+ documents: Documents
59
+ jobs: Jobs
60
+ search: Search
61
+ sources: Sources
62
+ mcp: Mcp
63
+ agents: Agents
64
+ chat: Chat
65
+ billing: Billing
66
+ budgets: Budgets
67
+
68
+ def __init__(
69
+ self,
70
+ *,
71
+ api_key: str | None = None,
72
+ base_url: str | httpx.URL | None = None,
73
+ timeout: float | httpx.Timeout | None = None,
74
+ max_retries: int = DEFAULT_MAX_RETRIES,
75
+ default_headers: Mapping[str, str] | None = None,
76
+ http_client: httpx.Client | None = None,
77
+ ) -> None:
78
+ super().__init__(
79
+ api_key=api_key,
80
+ base_url=base_url,
81
+ timeout=timeout,
82
+ max_retries=max_retries,
83
+ default_headers=default_headers,
84
+ )
85
+ self._http = http_client or httpx.Client(follow_redirects=True)
86
+ self._owns_http = http_client is None
87
+ self._bind_resources()
88
+
89
+ def _bind_resources(self) -> None:
90
+ self.documents = Documents(self)
91
+ self.jobs = Jobs(self)
92
+ self.search = Search(self)
93
+ self.sources = Sources(self)
94
+ self.mcp = Mcp(self)
95
+ self.agents = Agents(self)
96
+ self.chat = Chat(self)
97
+ self.billing = Billing(self)
98
+ self.budgets = Budgets(self)
99
+
100
+ def with_options(
101
+ self,
102
+ *,
103
+ api_key: str | None = None,
104
+ base_url: str | httpx.URL | None = None,
105
+ timeout: float | httpx.Timeout | None = None,
106
+ max_retries: int | None = None,
107
+ default_headers: Mapping[str, str] | None = None,
108
+ ) -> Nx:
109
+ return _with_options(self, api_key, base_url, timeout, max_retries, default_headers)
110
+
111
+ def send(self, options: RequestOptions, *, stream: bool = False) -> httpx.Response:
112
+ options = self._prepare(options)
113
+ rewindable = self._rewindable(options)
114
+ attempt = 0
115
+ while True:
116
+ request = self._build_request(self._http, options)
117
+ retry = attempt < self.max_retries and rewindable
118
+ response: httpx.Response | None = None
119
+ try:
120
+ response = self._http.send(request, stream=stream)
121
+ except httpx.TimeoutException as exc:
122
+ if not retry:
123
+ raise APITimeoutError(request=request) from exc
124
+ except httpx.TransportError as exc:
125
+ if not retry:
126
+ raise APIConnectionError(str(exc) or "Connection error.", request=request) from exc
127
+ if response is not None:
128
+ if response.is_success:
129
+ return response
130
+ if not (retry and self._should_retry(response)):
131
+ if stream:
132
+ response.read()
133
+ response.close()
134
+ raise error_from_response(response)
135
+ response.close()
136
+
137
+ time.sleep(self._retry_delay(attempt, response))
138
+ self._rewind(options)
139
+ attempt += 1
140
+
141
+ def request(self, options: RequestOptions, cast_to: type[ModelT]) -> ModelT:
142
+ return parse(cast_to, self.send(options))
143
+
144
+ def close(self) -> None:
145
+ if self._owns_http:
146
+ self._http.close()
147
+
148
+ def __enter__(self) -> Nx:
149
+ return self
150
+
151
+ def __exit__(
152
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
153
+ ) -> None:
154
+ self.close()
155
+
156
+
157
+ class AsyncNx(BaseClient):
158
+ documents: AsyncDocuments
159
+ jobs: AsyncJobs
160
+ search: AsyncSearch
161
+ sources: AsyncSources
162
+ mcp: AsyncMcp
163
+ agents: AsyncAgents
164
+ chat: AsyncChat
165
+ billing: AsyncBilling
166
+ budgets: AsyncBudgets
167
+
168
+ def __init__(
169
+ self,
170
+ *,
171
+ api_key: str | None = None,
172
+ base_url: str | httpx.URL | None = None,
173
+ timeout: float | httpx.Timeout | None = None,
174
+ max_retries: int = DEFAULT_MAX_RETRIES,
175
+ default_headers: Mapping[str, str] | None = None,
176
+ http_client: httpx.AsyncClient | None = None,
177
+ ) -> None:
178
+ super().__init__(
179
+ api_key=api_key,
180
+ base_url=base_url,
181
+ timeout=timeout,
182
+ max_retries=max_retries,
183
+ default_headers=default_headers,
184
+ )
185
+ self._http = http_client or httpx.AsyncClient(follow_redirects=True)
186
+ self._owns_http = http_client is None
187
+ self._bind_resources()
188
+
189
+ def _bind_resources(self) -> None:
190
+ self.documents = AsyncDocuments(self)
191
+ self.jobs = AsyncJobs(self)
192
+ self.search = AsyncSearch(self)
193
+ self.sources = AsyncSources(self)
194
+ self.mcp = AsyncMcp(self)
195
+ self.agents = AsyncAgents(self)
196
+ self.chat = AsyncChat(self)
197
+ self.billing = AsyncBilling(self)
198
+ self.budgets = AsyncBudgets(self)
199
+
200
+ def with_options(
201
+ self,
202
+ *,
203
+ api_key: str | None = None,
204
+ base_url: str | httpx.URL | None = None,
205
+ timeout: float | httpx.Timeout | None = None,
206
+ max_retries: int | None = None,
207
+ default_headers: Mapping[str, str] | None = None,
208
+ ) -> AsyncNx:
209
+ return _with_options(self, api_key, base_url, timeout, max_retries, default_headers)
210
+
211
+ async def send(self, options: RequestOptions, *, stream: bool = False) -> httpx.Response:
212
+ options = self._prepare(options)
213
+ rewindable = self._rewindable(options)
214
+ attempt = 0
215
+ while True:
216
+ request = self._build_request(self._http, options)
217
+ retry = attempt < self.max_retries and rewindable
218
+ response: httpx.Response | None = None
219
+ try:
220
+ response = await self._http.send(request, stream=stream)
221
+ except httpx.TimeoutException as exc:
222
+ if not retry:
223
+ raise APITimeoutError(request=request) from exc
224
+ except httpx.TransportError as exc:
225
+ if not retry:
226
+ raise APIConnectionError(str(exc) or "Connection error.", request=request) from exc
227
+ if response is not None:
228
+ if response.is_success:
229
+ return response
230
+ if not (retry and self._should_retry(response)):
231
+ if stream:
232
+ await response.aread()
233
+ await response.aclose()
234
+ raise error_from_response(response)
235
+ await response.aclose()
236
+
237
+ await asyncio.sleep(self._retry_delay(attempt, response))
238
+ self._rewind(options)
239
+ attempt += 1
240
+
241
+ async def request(self, options: RequestOptions, cast_to: type[ModelT]) -> ModelT:
242
+ return parse(cast_to, await self.send(options))
243
+
244
+ async def close(self) -> None:
245
+ if self._owns_http:
246
+ await self._http.aclose()
247
+
248
+ async def __aenter__(self) -> AsyncNx:
249
+ return self
250
+
251
+ async def __aexit__(
252
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
253
+ ) -> None:
254
+ await self.close()
nvisionx/_errors.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import httpx
6
+ from pydantic import ValidationError
7
+
8
+ if TYPE_CHECKING:
9
+ from nvisionx._models import ErrorDetail
10
+
11
+
12
+ class NxError(Exception):
13
+ pass
14
+
15
+
16
+ class APIConnectionError(NxError):
17
+ def __init__(self, message: str = "Connection error.", *, request: httpx.Request) -> None:
18
+ super().__init__(message)
19
+ self.request = request
20
+
21
+
22
+ class APITimeoutError(APIConnectionError):
23
+ def __init__(self, *, request: httpx.Request) -> None:
24
+ super().__init__("Request timed out.", request=request)
25
+
26
+
27
+ class JobTimeoutError(NxError):
28
+ def __init__(self, job_id: str, timeout: float) -> None:
29
+ super().__init__(f"Job {job_id} did not finish within {timeout:g}s.")
30
+ self.job_id = job_id
31
+ self.timeout = timeout
32
+
33
+
34
+ class StreamError(NxError):
35
+ pass
36
+
37
+
38
+ class APIStatusError(NxError):
39
+ status_code: int
40
+
41
+ def __init__(self, message: str, *, response: httpx.Response, detail: ErrorDetail | None) -> None:
42
+ super().__init__(message)
43
+ self.message = message
44
+ self.response = response
45
+ self.status_code = response.status_code
46
+ self.detail = detail
47
+
48
+ @property
49
+ def type(self) -> str | None:
50
+ return self.detail.type if self.detail else None
51
+
52
+ def __str__(self) -> str:
53
+ kind = f" {self.type}" if self.type else ""
54
+ return f"{self.status_code}{kind}: {self.message}"
55
+
56
+
57
+ class BadRequestError(APIStatusError):
58
+ pass
59
+
60
+
61
+ class AuthenticationError(APIStatusError):
62
+ pass
63
+
64
+
65
+ class PaymentRequiredError(APIStatusError):
66
+ pass
67
+
68
+
69
+ class PermissionDeniedError(APIStatusError):
70
+ @property
71
+ def required_scope(self) -> str | None:
72
+ return self.detail.required_scope if self.detail else None
73
+
74
+
75
+ class NotFoundError(APIStatusError):
76
+ pass
77
+
78
+
79
+ class ConflictError(APIStatusError):
80
+ pass
81
+
82
+
83
+ class UnprocessableEntityError(APIStatusError):
84
+ pass
85
+
86
+
87
+ class RateLimitError(APIStatusError):
88
+ @property
89
+ def retry_after(self) -> float | None:
90
+ return parse_retry_after(self.response)
91
+
92
+
93
+ class InternalServerError(APIStatusError):
94
+ pass
95
+
96
+
97
+ _BY_STATUS: dict[int, type[APIStatusError]] = {
98
+ 400: BadRequestError,
99
+ 401: AuthenticationError,
100
+ 402: PaymentRequiredError,
101
+ 403: PermissionDeniedError,
102
+ 404: NotFoundError,
103
+ 409: ConflictError,
104
+ 413: BadRequestError,
105
+ 422: UnprocessableEntityError,
106
+ 429: RateLimitError,
107
+ }
108
+
109
+
110
+ def parse_retry_after(response: httpx.Response) -> float | None:
111
+ value = response.headers.get("retry-after")
112
+ if value is None:
113
+ return None
114
+ try:
115
+ return max(float(value), 0.0)
116
+ except ValueError:
117
+ return None
118
+
119
+
120
+ def error_from_response(response: httpx.Response) -> APIStatusError:
121
+ from nvisionx._models import Error
122
+
123
+ detail: ErrorDetail | None = None
124
+ message = response.reason_phrase or f"HTTP {response.status_code}"
125
+ try:
126
+ detail = Error.model_validate(response.json()).error
127
+ message = detail.message
128
+ except (ValueError, ValidationError):
129
+ text = response.text.strip()
130
+ if text:
131
+ message = text[:500]
132
+
133
+ cls = _BY_STATUS.get(response.status_code)
134
+ if cls is None:
135
+ cls = InternalServerError if response.status_code >= 500 else APIStatusError
136
+ return cls(message, response=response, detail=detail)
nvisionx/_files.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import os
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import IO, Union, cast
8
+
9
+ FileContent = Union[bytes, IO[bytes]]
10
+ FileInput = Union[
11
+ str,
12
+ "os.PathLike[str]",
13
+ FileContent,
14
+ tuple[str, FileContent],
15
+ tuple[str, FileContent, str],
16
+ ]
17
+ FilesInput = Union[FileInput, Sequence[FileInput]]
18
+
19
+ HTTPXFile = tuple[str, tuple[str, FileContent, str]]
20
+
21
+
22
+ def _content_type(name: str) -> str:
23
+ return mimetypes.guess_type(name)[0] or "application/octet-stream"
24
+
25
+
26
+ def _one(file: FileInput, opened: list[IO[bytes]]) -> tuple[str, FileContent, str]:
27
+ if isinstance(file, (str, os.PathLike)):
28
+ path = Path(file)
29
+ handle = path.open("rb")
30
+ opened.append(handle)
31
+ return path.name, handle, _content_type(path.name)
32
+ if isinstance(file, tuple):
33
+ if len(file) == 2:
34
+ name, content = file
35
+ return name, content, _content_type(name)
36
+ return file
37
+ if isinstance(file, bytes):
38
+ return "upload", file, "application/octet-stream"
39
+ name = Path(str(getattr(file, "name", "upload"))).name
40
+ return name, file, _content_type(name)
41
+
42
+
43
+ def to_httpx_files(files: FilesInput, opened: list[IO[bytes]]) -> list[HTTPXFile]:
44
+ items: Sequence[FileInput]
45
+ if isinstance(files, (str, bytes, os.PathLike, tuple)) or hasattr(files, "read"):
46
+ items = [cast(FileInput, files)]
47
+ else:
48
+ items = files
49
+ return [("file", _one(item, opened)) for item in items]