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 +51 -0
- nvisionx/_base.py +21 -0
- nvisionx/_base_client.py +149 -0
- nvisionx/_client.py +254 -0
- nvisionx/_errors.py +136 -0
- nvisionx/_files.py +49 -0
- nvisionx/_models.py +1060 -0
- nvisionx/_pagination.py +72 -0
- nvisionx/_streaming.py +156 -0
- nvisionx/_version.py +1 -0
- nvisionx/py.typed +0 -0
- nvisionx/resources/__init__.py +0 -0
- nvisionx/resources/_resource.py +34 -0
- nvisionx/resources/agents.py +55 -0
- nvisionx/resources/billing.py +16 -0
- nvisionx/resources/budgets.py +118 -0
- nvisionx/resources/chat.py +114 -0
- nvisionx/resources/documents.py +218 -0
- nvisionx/resources/jobs.py +64 -0
- nvisionx/resources/mcp.py +25 -0
- nvisionx/resources/search.py +57 -0
- nvisionx/resources/sources.py +288 -0
- nvisionx/types.py +186 -0
- nvisionx-0.1.0.dist-info/METADATA +349 -0
- nvisionx-0.1.0.dist-info/RECORD +27 -0
- nvisionx-0.1.0.dist-info/WHEEL +4 -0
- nvisionx-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
nvisionx/_base_client.py
ADDED
|
@@ -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]
|