elmo-sdk 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.
- elmo_sdk/__init__.py +191 -0
- elmo_sdk/_internal/__init__.py +0 -0
- elmo_sdk/_internal/binding/__init__.py +0 -0
- elmo_sdk/_internal/binding/rest/__init__.py +0 -0
- elmo_sdk/_internal/binding/rest/binding.py +152 -0
- elmo_sdk/_internal/binding/rest/encode.py +16 -0
- elmo_sdk/_internal/binding/rest/errors.py +110 -0
- elmo_sdk/_internal/binding/rest/url.py +104 -0
- elmo_sdk/_internal/codec/__init__.py +0 -0
- elmo_sdk/_internal/codec/json.py +115 -0
- elmo_sdk/_internal/codec/registry.py +34 -0
- elmo_sdk/_internal/core/__init__.py +0 -0
- elmo_sdk/_internal/core/backoff.py +171 -0
- elmo_sdk/_internal/core/client.py +77 -0
- elmo_sdk/_internal/core/config.py +71 -0
- elmo_sdk/_internal/core/dispatch.py +321 -0
- elmo_sdk/_internal/core/env.py +42 -0
- elmo_sdk/_internal/core/errors.py +475 -0
- elmo_sdk/_internal/core/features.py +182 -0
- elmo_sdk/_internal/core/metadata.py +55 -0
- elmo_sdk/_internal/core/missing.py +21 -0
- elmo_sdk/_internal/core/params.py +134 -0
- elmo_sdk/_internal/core/protocol.py +19 -0
- elmo_sdk/_internal/core/response.py +157 -0
- elmo_sdk/_internal/core/text.py +15 -0
- elmo_sdk/_internal/core/types.py +397 -0
- elmo_sdk/_internal/feature/__init__.py +0 -0
- elmo_sdk/_internal/feature/auth.py +281 -0
- elmo_sdk/_internal/feature/interceptors.py +103 -0
- elmo_sdk/_internal/feature/logger.py +238 -0
- elmo_sdk/_internal/feature/retry.py +318 -0
- elmo_sdk/_internal/page/__init__.py +0 -0
- elmo_sdk/_internal/page/page.py +495 -0
- elmo_sdk/_internal/transport/__init__.py +0 -0
- elmo_sdk/_internal/transport/httpx.py +434 -0
- elmo_sdk/client.py +768 -0
- elmo_sdk/py.typed +0 -0
- elmo_sdk/resources/__init__.py +266 -0
- elmo_sdk/resources/analytics.py +899 -0
- elmo_sdk/resources/brands.py +668 -0
- elmo_sdk/resources/competitors.py +499 -0
- elmo_sdk/resources/identity.py +112 -0
- elmo_sdk/resources/models.py +114 -0
- elmo_sdk/resources/opportunities.py +158 -0
- elmo_sdk/resources/organizations.py +373 -0
- elmo_sdk/resources/prompts.py +616 -0
- elmo_sdk/resources/reports.py +377 -0
- elmo_sdk/resources/runs.py +271 -0
- elmo_sdk/resources/shared/__init__.py +30 -0
- elmo_sdk/resources/shared/auth.py +5 -0
- elmo_sdk/resources/shared/request_options.py +110 -0
- elmo_sdk/resources/snapshots.py +180 -0
- elmo_sdk/resources/tags.py +144 -0
- elmo_sdk/resources/tools.py +188 -0
- elmo_sdk/types/__init__.py +401 -0
- elmo_sdk/types/analytics.py +422 -0
- elmo_sdk/types/brands.py +152 -0
- elmo_sdk/types/competitors.py +80 -0
- elmo_sdk/types/identity.py +84 -0
- elmo_sdk/types/models.py +30 -0
- elmo_sdk/types/opportunities.py +111 -0
- elmo_sdk/types/organizations.py +165 -0
- elmo_sdk/types/prompts.py +128 -0
- elmo_sdk/types/reports.py +245 -0
- elmo_sdk/types/runs.py +124 -0
- elmo_sdk/types/shared/__init__.py +36 -0
- elmo_sdk/types/shared/common.py +22 -0
- elmo_sdk/types/shared/error.py +23 -0
- elmo_sdk/types/shared/mention_entry.py +44 -0
- elmo_sdk/types/shared/pagination.py +18 -0
- elmo_sdk/types/snapshots.py +79 -0
- elmo_sdk/types/tags.py +37 -0
- elmo_sdk/types/tools.py +74 -0
- elmo_sdk-0.1.0.dist-info/METADATA +343 -0
- elmo_sdk-0.1.0.dist-info/RECORD +77 -0
- elmo_sdk-0.1.0.dist-info/WHEEL +4 -0
- elmo_sdk-0.1.0.dist-info/licenses/LICENSE.md +21 -0
elmo_sdk/__init__.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ._internal.binding.rest.errors import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
InternalServerError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PaymentRequiredError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
UnprocessableEntityError,
|
|
17
|
+
)
|
|
18
|
+
from ._internal.core.backoff import Jitter, RetryAfterHeader
|
|
19
|
+
from ._internal.core.errors import (
|
|
20
|
+
APIError,
|
|
21
|
+
DecodeError,
|
|
22
|
+
ElmoError,
|
|
23
|
+
FailureBody,
|
|
24
|
+
MissingCredentialError,
|
|
25
|
+
TransportError,
|
|
26
|
+
TransportTimeoutError,
|
|
27
|
+
)
|
|
28
|
+
from ._internal.core.missing import MISSING, Missing
|
|
29
|
+
from ._internal.core.response import Response
|
|
30
|
+
from ._internal.core.types import (
|
|
31
|
+
AsyncAuthResolver,
|
|
32
|
+
AsyncAuthValue,
|
|
33
|
+
AsyncCredentialValue,
|
|
34
|
+
AuthResolver,
|
|
35
|
+
AuthScheme,
|
|
36
|
+
AuthToken,
|
|
37
|
+
AuthValue,
|
|
38
|
+
CallTimeout,
|
|
39
|
+
CredentialValue,
|
|
40
|
+
PhasedTimeout,
|
|
41
|
+
PreparedRequest,
|
|
42
|
+
RawResponse,
|
|
43
|
+
Result,
|
|
44
|
+
RetryOptions,
|
|
45
|
+
RetryValue,
|
|
46
|
+
TimeoutPolicy,
|
|
47
|
+
TimeoutValue,
|
|
48
|
+
)
|
|
49
|
+
from ._internal.feature.interceptors import (
|
|
50
|
+
ErrorHook,
|
|
51
|
+
Interceptors,
|
|
52
|
+
RequestHook,
|
|
53
|
+
ResponseHook,
|
|
54
|
+
)
|
|
55
|
+
from ._internal.feature.logger import LogLevel
|
|
56
|
+
from ._internal.page.page import AsyncPage, Page
|
|
57
|
+
from ._internal.transport.httpx import AsyncHttpxLike, HttpxLike
|
|
58
|
+
from .client import AsyncElmo, Elmo, __version__
|
|
59
|
+
from .resources.shared.request_options import AsyncRequestOptions, RequestOptions
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"MISSING",
|
|
63
|
+
"APIError",
|
|
64
|
+
"AsyncAuthResolver",
|
|
65
|
+
"AsyncAuthValue",
|
|
66
|
+
"AsyncCredentialValue",
|
|
67
|
+
"AsyncElmo",
|
|
68
|
+
"AsyncHttpxLike",
|
|
69
|
+
"AsyncPage",
|
|
70
|
+
"AsyncRequestOptions",
|
|
71
|
+
"AuthResolver",
|
|
72
|
+
"AuthScheme",
|
|
73
|
+
"AuthToken",
|
|
74
|
+
"AuthValue",
|
|
75
|
+
"AuthenticationError",
|
|
76
|
+
"BadRequestError",
|
|
77
|
+
"CallTimeout",
|
|
78
|
+
"ConflictError",
|
|
79
|
+
"CredentialValue",
|
|
80
|
+
"DecodeError",
|
|
81
|
+
"Elmo",
|
|
82
|
+
"ElmoError",
|
|
83
|
+
"ErrorHook",
|
|
84
|
+
"FailureBody",
|
|
85
|
+
"HttpxLike",
|
|
86
|
+
"Interceptors",
|
|
87
|
+
"InternalServerError",
|
|
88
|
+
"Jitter",
|
|
89
|
+
"LogLevel",
|
|
90
|
+
"Missing",
|
|
91
|
+
"MissingCredentialError",
|
|
92
|
+
"NotFoundError",
|
|
93
|
+
"Page",
|
|
94
|
+
"PaymentRequiredError",
|
|
95
|
+
"PermissionDeniedError",
|
|
96
|
+
"PhasedTimeout",
|
|
97
|
+
"PreparedRequest",
|
|
98
|
+
"RateLimitError",
|
|
99
|
+
"RawResponse",
|
|
100
|
+
"RequestHook",
|
|
101
|
+
"RequestOptions",
|
|
102
|
+
"Response",
|
|
103
|
+
"ResponseHook",
|
|
104
|
+
"Result",
|
|
105
|
+
"RetryAfterHeader",
|
|
106
|
+
"RetryOptions",
|
|
107
|
+
"RetryValue",
|
|
108
|
+
"TimeoutPolicy",
|
|
109
|
+
"TimeoutValue",
|
|
110
|
+
"TransportError",
|
|
111
|
+
"TransportTimeoutError",
|
|
112
|
+
"UnprocessableEntityError",
|
|
113
|
+
"__version__",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_LAZY_EXPORTS = {
|
|
118
|
+
"APIError": ("._internal.core.errors", "APIError"),
|
|
119
|
+
"AsyncAuthResolver": ("._internal.core.types", "AsyncAuthResolver"),
|
|
120
|
+
"AsyncAuthValue": ("._internal.core.types", "AsyncAuthValue"),
|
|
121
|
+
"AsyncCredentialValue": ("._internal.core.types", "AsyncCredentialValue"),
|
|
122
|
+
"AsyncElmo": (".client", "AsyncElmo"),
|
|
123
|
+
"AsyncHttpxLike": ("._internal.transport.httpx", "AsyncHttpxLike"),
|
|
124
|
+
"AsyncPage": ("._internal.page.page", "AsyncPage"),
|
|
125
|
+
"AsyncRequestOptions": (".resources.shared.request_options", "AsyncRequestOptions"),
|
|
126
|
+
"AuthResolver": ("._internal.core.types", "AuthResolver"),
|
|
127
|
+
"AuthScheme": ("._internal.core.types", "AuthScheme"),
|
|
128
|
+
"AuthToken": ("._internal.core.types", "AuthToken"),
|
|
129
|
+
"AuthValue": ("._internal.core.types", "AuthValue"),
|
|
130
|
+
"AuthenticationError": ("._internal.binding.rest.errors", "AuthenticationError"),
|
|
131
|
+
"BadRequestError": ("._internal.binding.rest.errors", "BadRequestError"),
|
|
132
|
+
"CallTimeout": ("._internal.core.types", "CallTimeout"),
|
|
133
|
+
"ConflictError": ("._internal.binding.rest.errors", "ConflictError"),
|
|
134
|
+
"CredentialValue": ("._internal.core.types", "CredentialValue"),
|
|
135
|
+
"DecodeError": ("._internal.core.errors", "DecodeError"),
|
|
136
|
+
"Elmo": (".client", "Elmo"),
|
|
137
|
+
"ElmoError": ("._internal.core.errors", "ElmoError"),
|
|
138
|
+
"ErrorHook": ("._internal.feature.interceptors", "ErrorHook"),
|
|
139
|
+
"FailureBody": ("._internal.core.errors", "FailureBody"),
|
|
140
|
+
"HttpxLike": ("._internal.transport.httpx", "HttpxLike"),
|
|
141
|
+
"Interceptors": ("._internal.feature.interceptors", "Interceptors"),
|
|
142
|
+
"InternalServerError": ("._internal.binding.rest.errors", "InternalServerError"),
|
|
143
|
+
"Jitter": ("._internal.core.backoff", "Jitter"),
|
|
144
|
+
"LogLevel": ("._internal.feature.logger", "LogLevel"),
|
|
145
|
+
"MISSING": ("._internal.core.missing", "MISSING"),
|
|
146
|
+
"Missing": ("._internal.core.missing", "Missing"),
|
|
147
|
+
"MissingCredentialError": ("._internal.core.errors", "MissingCredentialError"),
|
|
148
|
+
"NotFoundError": ("._internal.binding.rest.errors", "NotFoundError"),
|
|
149
|
+
"Page": ("._internal.page.page", "Page"),
|
|
150
|
+
"PaymentRequiredError": ("._internal.binding.rest.errors", "PaymentRequiredError"),
|
|
151
|
+
"PermissionDeniedError": (
|
|
152
|
+
"._internal.binding.rest.errors",
|
|
153
|
+
"PermissionDeniedError",
|
|
154
|
+
),
|
|
155
|
+
"PhasedTimeout": ("._internal.core.types", "PhasedTimeout"),
|
|
156
|
+
"PreparedRequest": ("._internal.core.types", "PreparedRequest"),
|
|
157
|
+
"RateLimitError": ("._internal.binding.rest.errors", "RateLimitError"),
|
|
158
|
+
"RawResponse": ("._internal.core.types", "RawResponse"),
|
|
159
|
+
"RequestHook": ("._internal.feature.interceptors", "RequestHook"),
|
|
160
|
+
"RequestOptions": (".resources.shared.request_options", "RequestOptions"),
|
|
161
|
+
"Response": ("._internal.core.response", "Response"),
|
|
162
|
+
"ResponseHook": ("._internal.feature.interceptors", "ResponseHook"),
|
|
163
|
+
"Result": ("._internal.core.types", "Result"),
|
|
164
|
+
"RetryAfterHeader": ("._internal.core.backoff", "RetryAfterHeader"),
|
|
165
|
+
"RetryOptions": ("._internal.core.types", "RetryOptions"),
|
|
166
|
+
"RetryValue": ("._internal.core.types", "RetryValue"),
|
|
167
|
+
"TimeoutPolicy": ("._internal.core.types", "TimeoutPolicy"),
|
|
168
|
+
"TimeoutValue": ("._internal.core.types", "TimeoutValue"),
|
|
169
|
+
"TransportError": ("._internal.core.errors", "TransportError"),
|
|
170
|
+
"TransportTimeoutError": ("._internal.core.errors", "TransportTimeoutError"),
|
|
171
|
+
"UnprocessableEntityError": (
|
|
172
|
+
"._internal.binding.rest.errors",
|
|
173
|
+
"UnprocessableEntityError",
|
|
174
|
+
),
|
|
175
|
+
"__version__": (".client", "__version__"),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if not TYPE_CHECKING:
|
|
180
|
+
|
|
181
|
+
def __getattr__(name: str) -> object:
|
|
182
|
+
if name not in _LAZY_EXPORTS:
|
|
183
|
+
raise AttributeError(f"module {__name__} has no attribute {name}")
|
|
184
|
+
module, attribute = _LAZY_EXPORTS[name]
|
|
185
|
+
value = getattr(import_module(module, __name__), attribute)
|
|
186
|
+
globals()[name] = value
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def __dir__() -> list[str]:
|
|
191
|
+
return list(__all__)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ...codec.registry import essence_of
|
|
10
|
+
from ...core.config import base_url_of
|
|
11
|
+
from ...core.errors import ElmoError
|
|
12
|
+
from ...core.types import (
|
|
13
|
+
Credential,
|
|
14
|
+
OperationDescriptor,
|
|
15
|
+
PreparedRequest,
|
|
16
|
+
RawResponse,
|
|
17
|
+
Result,
|
|
18
|
+
)
|
|
19
|
+
from .errors import to_api_error, unreadable_body
|
|
20
|
+
from .url import BuildURL
|
|
21
|
+
|
|
22
|
+
DEFAULT_ACCEPT = "application/json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def place_in_header(credential: Credential, request: PreparedRequest) -> None:
|
|
26
|
+
request.meta[credential.name.lower()] = credential.value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Placer = Callable[[Credential, PreparedRequest], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PLACERS: dict[str, Placer] = {"header": place_in_header}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parser_for(content_type: str) -> str:
|
|
36
|
+
if not content_type:
|
|
37
|
+
return "text"
|
|
38
|
+
essence = essence_of(content_type)
|
|
39
|
+
if essence == "application/json" or essence.endswith("+json"):
|
|
40
|
+
return "json"
|
|
41
|
+
if essence.startswith("text/"):
|
|
42
|
+
return "text"
|
|
43
|
+
return "bytes"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def asks_for_json(request: PreparedRequest) -> bool:
|
|
47
|
+
accept = request.meta.get("accept", "")
|
|
48
|
+
return accept != "" and all(parser_for(one) == "json" for one in accept.split(","))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def json_or_text(text: str) -> Any:
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(text)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
WINDOWS_1252 = ("ascii", "iso8859-1")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def charset_of(content_type: str) -> str | None:
|
|
62
|
+
for param in content_type.split(";")[1:]:
|
|
63
|
+
name, _, value = param.partition("=")
|
|
64
|
+
if name.strip().lower() == "charset":
|
|
65
|
+
return value.strip().strip('"')
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_text(raw: RawResponse, json_body: bool, ok: bool) -> str:
|
|
70
|
+
label = None if json_body else charset_of(raw.headers.get("content-type", ""))
|
|
71
|
+
try:
|
|
72
|
+
name = codecs.lookup(label or "utf-8").name
|
|
73
|
+
except LookupError:
|
|
74
|
+
name = "utf-8"
|
|
75
|
+
if name in WINDOWS_1252:
|
|
76
|
+
name = "cp1252"
|
|
77
|
+
elif name == "utf-8":
|
|
78
|
+
name = "utf-8-sig"
|
|
79
|
+
try:
|
|
80
|
+
return raw.content.decode(name, "strict" if ok else "replace")
|
|
81
|
+
except UnicodeDecodeError as cause:
|
|
82
|
+
raise unreadable_body(raw, cause) from cause
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class RestBinding:
|
|
87
|
+
build_url: BuildURL
|
|
88
|
+
accept: str | None = DEFAULT_ACCEPT
|
|
89
|
+
name: str = "rest"
|
|
90
|
+
|
|
91
|
+
def apply_auth(self, credential: Credential, request: PreparedRequest) -> None:
|
|
92
|
+
place = PLACERS.get(credential.location)
|
|
93
|
+
if place is None:
|
|
94
|
+
where = credential.location
|
|
95
|
+
address = request.operation.address
|
|
96
|
+
raise ElmoError(
|
|
97
|
+
f'This client places no credential in the {where}, so "{address}" cannot be authenticated.',
|
|
98
|
+
)
|
|
99
|
+
place(credential, request)
|
|
100
|
+
|
|
101
|
+
def read_error(
|
|
102
|
+
self, result: Result, request: PreparedRequest
|
|
103
|
+
) -> BaseException | None:
|
|
104
|
+
response = result.response
|
|
105
|
+
if (
|
|
106
|
+
response is None
|
|
107
|
+
or 200 <= response.status_code < 300
|
|
108
|
+
and result.error is None
|
|
109
|
+
):
|
|
110
|
+
return None
|
|
111
|
+
return to_api_error(response.status_code, result.error, response)
|
|
112
|
+
|
|
113
|
+
def read_result(self, raw: RawResponse, request: PreparedRequest) -> Result:
|
|
114
|
+
ok = 200 <= raw.status_code < 300
|
|
115
|
+
if raw.status_code in (204, 205):
|
|
116
|
+
return Result(response=raw) if ok else Result(error=None, response=raw)
|
|
117
|
+
parser = parser_for(raw.headers.get("content-type", ""))
|
|
118
|
+
if parser == "bytes":
|
|
119
|
+
value: Any = raw.content
|
|
120
|
+
else:
|
|
121
|
+
text = read_text(raw, parser == "json", ok)
|
|
122
|
+
if text == "":
|
|
123
|
+
value = None
|
|
124
|
+
elif parser == "json":
|
|
125
|
+
try:
|
|
126
|
+
value = json.loads(text)
|
|
127
|
+
except ValueError as cause:
|
|
128
|
+
if ok:
|
|
129
|
+
raise unreadable_body(raw, cause) from cause
|
|
130
|
+
value = text
|
|
131
|
+
else:
|
|
132
|
+
value = json_or_text(text) if asks_for_json(request) else text
|
|
133
|
+
return (
|
|
134
|
+
Result(data=value, response=raw)
|
|
135
|
+
if ok
|
|
136
|
+
else Result(error=value, response=raw)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def resolve_address(
|
|
140
|
+
self, operation: OperationDescriptor, options: dict[str, Any]
|
|
141
|
+
) -> str:
|
|
142
|
+
return self.build_url(
|
|
143
|
+
address=operation.address,
|
|
144
|
+
base_url=base_url_of(options, operation.base_url),
|
|
145
|
+
path=options.get("path"),
|
|
146
|
+
query=options.get("query"),
|
|
147
|
+
serialization=operation.serialization,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_rest_binding(build_url: BuildURL) -> RestBinding:
|
|
152
|
+
return RestBinding(build_url=build_url)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from ...core.text import to_text
|
|
7
|
+
|
|
8
|
+
_UNRESERVED = "!'()*-._~"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def encode_component(value: Any) -> str:
|
|
12
|
+
return quote(to_text(value), safe=_UNRESERVED)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def encode_name(name: str) -> str:
|
|
16
|
+
return encode_component(name).replace("%5B", "[").replace("%5D", "]")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from ...core.errors import APIError, DecodeError, safe_address
|
|
7
|
+
from ...core.types import RawResponse
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ....types.shared.error import Error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def unreadable_body(
|
|
14
|
+
response: RawResponse, cause: BaseException | None = None
|
|
15
|
+
) -> DecodeError:
|
|
16
|
+
url = getattr(response, "url", "") or ""
|
|
17
|
+
where = f' for "{safe_address(url)}"' if url else ""
|
|
18
|
+
headers: Mapping[str, str] = getattr(response, "headers", None) or {}
|
|
19
|
+
content_type = headers.get("content-type")
|
|
20
|
+
said = f' Its content type said "{content_type}".' if content_type else ""
|
|
21
|
+
error = DecodeError(
|
|
22
|
+
f"The API answered {response.status_code}{where} with a body this client could not read.{said}",
|
|
23
|
+
response=response,
|
|
24
|
+
)
|
|
25
|
+
error.__cause__ = cause
|
|
26
|
+
return error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BadRequestError(APIError["Error"]):
|
|
30
|
+
"""The API could not read the request, so it did not act on it."""
|
|
31
|
+
|
|
32
|
+
data: Error | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(APIError["Error"]):
|
|
36
|
+
"""No usable credential reached the API: it was missing, unreadable, or rejected."""
|
|
37
|
+
|
|
38
|
+
data: Error | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaymentRequiredError(APIError["Error"]):
|
|
42
|
+
"""The plan or the quota on this account does not cover the call."""
|
|
43
|
+
|
|
44
|
+
data: Error | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PermissionDeniedError(APIError["Error"]):
|
|
48
|
+
"""The credential was accepted, but it does not grant this call."""
|
|
49
|
+
|
|
50
|
+
data: Error | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotFoundError(APIError["Error"]):
|
|
54
|
+
"""Nothing is at this address, or the credential may not see what is."""
|
|
55
|
+
|
|
56
|
+
data: Error | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConflictError(APIError["Error"]):
|
|
60
|
+
"""The call collided with the resource's current state: a duplicate, or a concurrent
|
|
61
|
+
change.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
data: Error | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnprocessableEntityError(APIError):
|
|
68
|
+
"""The request was read, and its contents were rejected."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RateLimitError(APIError["Error"]):
|
|
72
|
+
"""Too many calls. `retry_after_seconds` carries how long the API asked to wait."""
|
|
73
|
+
|
|
74
|
+
data: Error | None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
APIErrorClass = Callable[..., APIError]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
BY_STATUS: dict[int, APIErrorClass] = {
|
|
81
|
+
400: BadRequestError,
|
|
82
|
+
401: AuthenticationError,
|
|
83
|
+
402: PaymentRequiredError,
|
|
84
|
+
403: PermissionDeniedError,
|
|
85
|
+
404: NotFoundError,
|
|
86
|
+
409: ConflictError,
|
|
87
|
+
422: UnprocessableEntityError,
|
|
88
|
+
429: RateLimitError,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InternalServerError(APIError["Error"]):
|
|
93
|
+
"""The API failed after accepting the call. Every status from 500 up arrives as
|
|
94
|
+
this.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
data: Error | None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def to_api_error(
|
|
101
|
+
status: int,
|
|
102
|
+
body: Any,
|
|
103
|
+
response: RawResponse,
|
|
104
|
+
unauthenticated: Callable[[], str] | None = None,
|
|
105
|
+
) -> APIError:
|
|
106
|
+
failure = BY_STATUS.get(status) or (
|
|
107
|
+
InternalServerError if status >= 500 else APIError
|
|
108
|
+
)
|
|
109
|
+
note = unauthenticated() if unauthenticated and status in (401, 403) else None
|
|
110
|
+
return failure(status, body, response, note)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from typing import Any, Protocol, cast
|
|
6
|
+
|
|
7
|
+
from ...codec.json import dumped
|
|
8
|
+
from ...core.errors import empty_path_parameter
|
|
9
|
+
from ...core.types import SerializationDescriptor
|
|
10
|
+
from .encode import encode_component, encode_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BuildURL(Protocol):
|
|
14
|
+
def __call__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
address: str,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
path: dict[str, Any] | None = None,
|
|
20
|
+
query: dict[str, Any] | None = None,
|
|
21
|
+
serialization: SerializationDescriptor | None = None,
|
|
22
|
+
) -> str: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PATH_PARAM_RE = re.compile(r"\{[^{}]+\}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ABSOLUTE_URL_RE = re.compile(r"^[a-z][a-z\d+.-]*://", re.IGNORECASE | re.ASCII)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def join_url(base_url: str | None, path: str) -> str:
|
|
32
|
+
base = (base_url or "").partition("#")[0]
|
|
33
|
+
kept, _, query = base.partition("?")
|
|
34
|
+
if kept.endswith("/") and path.startswith("/"):
|
|
35
|
+
path = path[1:]
|
|
36
|
+
joined = kept + path
|
|
37
|
+
if not query:
|
|
38
|
+
return joined
|
|
39
|
+
return f"{joined}{'&' if '?' in joined else '?'}{query}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def address_url(address: str, base_url: str | None) -> str:
|
|
43
|
+
absolute = bool(ABSOLUTE_URL_RE.match(address))
|
|
44
|
+
path_url = address if absolute or address.startswith("/") else "/" + address
|
|
45
|
+
return path_url if absolute else join_url(base_url, path_url)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def path_value(value: Any) -> str:
|
|
49
|
+
if isinstance(value, (list, tuple)):
|
|
50
|
+
return ",".join(encode_component(item) for item in cast(Sequence[Any], value))
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
for key, item in cast(dict[str, Any], value).items():
|
|
54
|
+
parts.append(encode_component(key))
|
|
55
|
+
parts.append(encode_component(item))
|
|
56
|
+
return ",".join(parts)
|
|
57
|
+
return encode_component(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def filled_url(address: str, base_url: str | None, path: dict[str, Any] | None) -> str:
|
|
61
|
+
path = dumped(path)
|
|
62
|
+
url = address_url(address, base_url)
|
|
63
|
+
if not path:
|
|
64
|
+
return url
|
|
65
|
+
|
|
66
|
+
def substitute(match: re.Match[str]) -> str:
|
|
67
|
+
name = match.group()[1:-1]
|
|
68
|
+
value = path.get(name)
|
|
69
|
+
if value is None or value == "":
|
|
70
|
+
raise empty_path_parameter(name, address)
|
|
71
|
+
return path_value(value)
|
|
72
|
+
|
|
73
|
+
return PATH_PARAM_RE.sub(substitute, url)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def with_query(url: str, query: dict[str, Any] | None) -> str:
|
|
77
|
+
query = dumped(query)
|
|
78
|
+
if not query:
|
|
79
|
+
return url
|
|
80
|
+
search: list[str] = []
|
|
81
|
+
for name, value in query.items():
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
pairs: Iterable[tuple[str, Any]] = cast(dict[str, Any], value).items()
|
|
84
|
+
elif isinstance(value, (list, tuple)):
|
|
85
|
+
pairs = [(name, item) for item in cast(Sequence[Any], value)]
|
|
86
|
+
else:
|
|
87
|
+
pairs = [(name, value)]
|
|
88
|
+
for key, item in pairs:
|
|
89
|
+
if item is not None:
|
|
90
|
+
search.append(f"{encode_name(key)}={encode_component(item)}")
|
|
91
|
+
if not search:
|
|
92
|
+
return url
|
|
93
|
+
return f"{url}{'&' if '?' in url else '?'}{'&'.join(search)}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def simple_url(
|
|
97
|
+
*,
|
|
98
|
+
address: str,
|
|
99
|
+
base_url: str | None = None,
|
|
100
|
+
path: dict[str, Any] | None = None,
|
|
101
|
+
query: dict[str, Any] | None = None,
|
|
102
|
+
serialization: SerializationDescriptor | None = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
return with_query(filled_url(address, base_url, path), query)
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import numbers
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, time, timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from ipaddress import IPv4Address, IPv6Address
|
|
11
|
+
from typing import Any, TypeGuard, cast
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from ..core.errors import ElmoError
|
|
15
|
+
from ..core.types import EncodedBody
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_mapping(value: Any) -> TypeGuard[Mapping[Any, Any]]:
|
|
19
|
+
return isinstance(value, Mapping)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_sequence(value: Any) -> bool:
|
|
23
|
+
if isinstance(value, (str, bytes, bytearray, memoryview)):
|
|
24
|
+
return False
|
|
25
|
+
return isinstance(value, Sequence)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def iso_duration(value: timedelta) -> str:
|
|
29
|
+
sign = "-" if value < timedelta(0) else ""
|
|
30
|
+
value = abs(value)
|
|
31
|
+
hours, rest = divmod(value.seconds, 3600)
|
|
32
|
+
minutes, seconds = divmod(rest, 60)
|
|
33
|
+
clock = ""
|
|
34
|
+
if hours:
|
|
35
|
+
clock += f"{hours}H"
|
|
36
|
+
if minutes:
|
|
37
|
+
clock += f"{minutes}M"
|
|
38
|
+
if value.microseconds:
|
|
39
|
+
clock += f"{seconds}.{value.microseconds:06d}".rstrip("0") + "S"
|
|
40
|
+
elif seconds or not (value.days or clock):
|
|
41
|
+
clock += f"{seconds}S"
|
|
42
|
+
days = f"{value.days}D" if value.days else ""
|
|
43
|
+
return f"{sign}P{days}T{clock}" if clock else f"{sign}P{days}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def type_name(value: Any) -> str:
|
|
47
|
+
kind: type[Any] = value.__class__
|
|
48
|
+
if kind.__module__ == "builtins":
|
|
49
|
+
return kind.__qualname__
|
|
50
|
+
return f"{kind.__module__}.{kind.__qualname__}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def as_json(value: Any) -> Any:
|
|
54
|
+
dump = getattr(value, "model_dump", None)
|
|
55
|
+
if dump is not None:
|
|
56
|
+
return dump(by_alias=True, exclude_unset=True, mode="json")
|
|
57
|
+
serializer = getattr(value.__class__, "__pydantic_serializer__", None)
|
|
58
|
+
if serializer is not None:
|
|
59
|
+
return serializer.to_python(value, mode="json")
|
|
60
|
+
if isinstance(value, Enum):
|
|
61
|
+
return value.value
|
|
62
|
+
if isinstance(value, (date, time)):
|
|
63
|
+
return value.isoformat()
|
|
64
|
+
if isinstance(value, timedelta):
|
|
65
|
+
return iso_duration(value)
|
|
66
|
+
if isinstance(value, (Decimal, IPv4Address, IPv6Address, UUID)):
|
|
67
|
+
return str(value)
|
|
68
|
+
if is_mapping(value):
|
|
69
|
+
return dict(value)
|
|
70
|
+
if isinstance(value, (set, frozenset)) or is_sequence(value):
|
|
71
|
+
return list(cast(Iterable[Any], value))
|
|
72
|
+
if isinstance(value, numbers.Integral):
|
|
73
|
+
return int(value)
|
|
74
|
+
if isinstance(value, numbers.Real):
|
|
75
|
+
return float(value)
|
|
76
|
+
raise ElmoError(f"A value of type {type_name(value)} cannot be written as JSON.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def json_text(value: Any) -> str:
|
|
80
|
+
try:
|
|
81
|
+
return json.dumps(
|
|
82
|
+
value,
|
|
83
|
+
allow_nan=False,
|
|
84
|
+
default=as_json,
|
|
85
|
+
ensure_ascii=False,
|
|
86
|
+
separators=(",", ":"),
|
|
87
|
+
)
|
|
88
|
+
except (RecursionError, TypeError, ValueError) as error:
|
|
89
|
+
if str(error).startswith("Out of range float values"):
|
|
90
|
+
raise ElmoError("NaN and Infinity cannot be written as JSON.") from None
|
|
91
|
+
raise ElmoError(f"A value cannot be written as JSON: {error}") from error
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class JSONCodec:
|
|
96
|
+
media_types: list[str] = field(default_factory=lambda: ["application/json"])
|
|
97
|
+
name: str = "json"
|
|
98
|
+
|
|
99
|
+
def encode(self, value: Any) -> EncodedBody:
|
|
100
|
+
return EncodedBody(
|
|
101
|
+
payload=json_text(value).encode("utf-8"), content_type="application/json"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
json_codec = JSONCodec()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dumped(value: Any) -> Any:
|
|
109
|
+
if hasattr(value, "model_dump"):
|
|
110
|
+
return as_json(value)
|
|
111
|
+
if is_mapping(value):
|
|
112
|
+
return {key: dumped(item) for key, item in value.items()}
|
|
113
|
+
if is_sequence(value):
|
|
114
|
+
return [dumped(item) for item in cast(Sequence[Any], value)]
|
|
115
|
+
return value
|