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.
Files changed (77) hide show
  1. elmo_sdk/__init__.py +191 -0
  2. elmo_sdk/_internal/__init__.py +0 -0
  3. elmo_sdk/_internal/binding/__init__.py +0 -0
  4. elmo_sdk/_internal/binding/rest/__init__.py +0 -0
  5. elmo_sdk/_internal/binding/rest/binding.py +152 -0
  6. elmo_sdk/_internal/binding/rest/encode.py +16 -0
  7. elmo_sdk/_internal/binding/rest/errors.py +110 -0
  8. elmo_sdk/_internal/binding/rest/url.py +104 -0
  9. elmo_sdk/_internal/codec/__init__.py +0 -0
  10. elmo_sdk/_internal/codec/json.py +115 -0
  11. elmo_sdk/_internal/codec/registry.py +34 -0
  12. elmo_sdk/_internal/core/__init__.py +0 -0
  13. elmo_sdk/_internal/core/backoff.py +171 -0
  14. elmo_sdk/_internal/core/client.py +77 -0
  15. elmo_sdk/_internal/core/config.py +71 -0
  16. elmo_sdk/_internal/core/dispatch.py +321 -0
  17. elmo_sdk/_internal/core/env.py +42 -0
  18. elmo_sdk/_internal/core/errors.py +475 -0
  19. elmo_sdk/_internal/core/features.py +182 -0
  20. elmo_sdk/_internal/core/metadata.py +55 -0
  21. elmo_sdk/_internal/core/missing.py +21 -0
  22. elmo_sdk/_internal/core/params.py +134 -0
  23. elmo_sdk/_internal/core/protocol.py +19 -0
  24. elmo_sdk/_internal/core/response.py +157 -0
  25. elmo_sdk/_internal/core/text.py +15 -0
  26. elmo_sdk/_internal/core/types.py +397 -0
  27. elmo_sdk/_internal/feature/__init__.py +0 -0
  28. elmo_sdk/_internal/feature/auth.py +281 -0
  29. elmo_sdk/_internal/feature/interceptors.py +103 -0
  30. elmo_sdk/_internal/feature/logger.py +238 -0
  31. elmo_sdk/_internal/feature/retry.py +318 -0
  32. elmo_sdk/_internal/page/__init__.py +0 -0
  33. elmo_sdk/_internal/page/page.py +495 -0
  34. elmo_sdk/_internal/transport/__init__.py +0 -0
  35. elmo_sdk/_internal/transport/httpx.py +434 -0
  36. elmo_sdk/client.py +768 -0
  37. elmo_sdk/py.typed +0 -0
  38. elmo_sdk/resources/__init__.py +266 -0
  39. elmo_sdk/resources/analytics.py +899 -0
  40. elmo_sdk/resources/brands.py +668 -0
  41. elmo_sdk/resources/competitors.py +499 -0
  42. elmo_sdk/resources/identity.py +112 -0
  43. elmo_sdk/resources/models.py +114 -0
  44. elmo_sdk/resources/opportunities.py +158 -0
  45. elmo_sdk/resources/organizations.py +373 -0
  46. elmo_sdk/resources/prompts.py +616 -0
  47. elmo_sdk/resources/reports.py +377 -0
  48. elmo_sdk/resources/runs.py +271 -0
  49. elmo_sdk/resources/shared/__init__.py +30 -0
  50. elmo_sdk/resources/shared/auth.py +5 -0
  51. elmo_sdk/resources/shared/request_options.py +110 -0
  52. elmo_sdk/resources/snapshots.py +180 -0
  53. elmo_sdk/resources/tags.py +144 -0
  54. elmo_sdk/resources/tools.py +188 -0
  55. elmo_sdk/types/__init__.py +401 -0
  56. elmo_sdk/types/analytics.py +422 -0
  57. elmo_sdk/types/brands.py +152 -0
  58. elmo_sdk/types/competitors.py +80 -0
  59. elmo_sdk/types/identity.py +84 -0
  60. elmo_sdk/types/models.py +30 -0
  61. elmo_sdk/types/opportunities.py +111 -0
  62. elmo_sdk/types/organizations.py +165 -0
  63. elmo_sdk/types/prompts.py +128 -0
  64. elmo_sdk/types/reports.py +245 -0
  65. elmo_sdk/types/runs.py +124 -0
  66. elmo_sdk/types/shared/__init__.py +36 -0
  67. elmo_sdk/types/shared/common.py +22 -0
  68. elmo_sdk/types/shared/error.py +23 -0
  69. elmo_sdk/types/shared/mention_entry.py +44 -0
  70. elmo_sdk/types/shared/pagination.py +18 -0
  71. elmo_sdk/types/snapshots.py +79 -0
  72. elmo_sdk/types/tags.py +37 -0
  73. elmo_sdk/types/tools.py +74 -0
  74. elmo_sdk-0.1.0.dist-info/METADATA +343 -0
  75. elmo_sdk-0.1.0.dist-info/RECORD +77 -0
  76. elmo_sdk-0.1.0.dist-info/WHEEL +4 -0
  77. 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