haveapi-client 0.30.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.
- haveapi_client/__init__.py +62 -0
- haveapi_client/_auth/__init__.py +139 -0
- haveapi_client/_protocol/__init__.py +52 -0
- haveapi_client/_protocol/description.py +176 -0
- haveapi_client/_protocol/execution.py +61 -0
- haveapi_client/_protocol/input.py +161 -0
- haveapi_client/_protocol/models.py +166 -0
- haveapi_client/_protocol/request.py +345 -0
- haveapi_client/_protocol/response.py +97 -0
- haveapi_client/_resources/__init__.py +196 -0
- haveapi_client/_resources/state.py +266 -0
- haveapi_client/_transport/__init__.py +155 -0
- haveapi_client/async_client.py +209 -0
- haveapi_client/client.py +203 -0
- haveapi_client/exceptions.py +88 -0
- haveapi_client/operations.py +190 -0
- haveapi_client/pagination.py +202 -0
- haveapi_client/py.typed +0 -0
- haveapi_client/response.py +303 -0
- haveapi_client-0.30.0.dist-info/METADATA +198 -0
- haveapi_client-0.30.0.dist-info/RECORD +23 -0
- haveapi_client-0.30.0.dist-info/WHEEL +4 -0
- haveapi_client-0.30.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Dynamic clients for self-describing HaveAPI APIs."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from ._auth import (
|
|
6
|
+
AsyncAuthenticationChallenge,
|
|
7
|
+
Authenticated,
|
|
8
|
+
AuthenticationChallenge,
|
|
9
|
+
BasicAuth,
|
|
10
|
+
OAuth2Auth,
|
|
11
|
+
TokenAuth,
|
|
12
|
+
)
|
|
13
|
+
from ._protocol import RequestOptions
|
|
14
|
+
from .async_client import AsyncClient
|
|
15
|
+
from .client import Client
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
ActionError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
HaveAPIError,
|
|
20
|
+
NotConnectedError,
|
|
21
|
+
OperationFailedError,
|
|
22
|
+
OperationTimeoutError,
|
|
23
|
+
PaginationError,
|
|
24
|
+
ProtocolError,
|
|
25
|
+
StaleDescriptionError,
|
|
26
|
+
TransportError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from .operations import AsyncOperation, Operation
|
|
30
|
+
from .pagination import IdCursor
|
|
31
|
+
from .response import ResourceObject, Response
|
|
32
|
+
|
|
33
|
+
__version__ = version("haveapi-client")
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ActionError",
|
|
37
|
+
"AsyncAuthenticationChallenge",
|
|
38
|
+
"AsyncClient",
|
|
39
|
+
"AsyncOperation",
|
|
40
|
+
"Authenticated",
|
|
41
|
+
"AuthenticationChallenge",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"BasicAuth",
|
|
44
|
+
"Client",
|
|
45
|
+
"HaveAPIError",
|
|
46
|
+
"IdCursor",
|
|
47
|
+
"NotConnectedError",
|
|
48
|
+
"OAuth2Auth",
|
|
49
|
+
"Operation",
|
|
50
|
+
"OperationFailedError",
|
|
51
|
+
"OperationTimeoutError",
|
|
52
|
+
"PaginationError",
|
|
53
|
+
"ProtocolError",
|
|
54
|
+
"RequestOptions",
|
|
55
|
+
"ResourceObject",
|
|
56
|
+
"Response",
|
|
57
|
+
"StaleDescriptionError",
|
|
58
|
+
"TokenAuth",
|
|
59
|
+
"TransportError",
|
|
60
|
+
"ValidationError",
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Explicit authentication providers and resumable login outcomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from haveapi_client._protocol import Description, ParameterSpec
|
|
13
|
+
from haveapi_client.exceptions import AuthenticationError
|
|
14
|
+
|
|
15
|
+
_HEADER_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z")
|
|
16
|
+
_FORBIDDEN = {
|
|
17
|
+
"host",
|
|
18
|
+
"content-length",
|
|
19
|
+
"content-type",
|
|
20
|
+
"transfer-encoding",
|
|
21
|
+
"connection",
|
|
22
|
+
"trailer",
|
|
23
|
+
"te",
|
|
24
|
+
"upgrade",
|
|
25
|
+
"proxy-authorization",
|
|
26
|
+
"proxy-connection",
|
|
27
|
+
"accept",
|
|
28
|
+
"accept-language",
|
|
29
|
+
"user-agent",
|
|
30
|
+
"cookie",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthProvider(Protocol):
|
|
35
|
+
@property
|
|
36
|
+
def method(self) -> str: ...
|
|
37
|
+
|
|
38
|
+
def headers(self, description: Description) -> Mapping[str, str]: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
42
|
+
result: dict[str, str] = {}
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
for name, value in headers.items():
|
|
45
|
+
if (
|
|
46
|
+
not isinstance(name, str)
|
|
47
|
+
or not _HEADER_NAME.fullmatch(name)
|
|
48
|
+
or name.lower() in _FORBIDDEN
|
|
49
|
+
or name.lower() in seen
|
|
50
|
+
):
|
|
51
|
+
raise AuthenticationError("Invalid authentication header name")
|
|
52
|
+
if not isinstance(value, str) or any(
|
|
53
|
+
ord(c) < 32 or ord(c) > 126 for c in value
|
|
54
|
+
):
|
|
55
|
+
raise AuthenticationError("Invalid authentication header value")
|
|
56
|
+
seen.add(name.lower())
|
|
57
|
+
result[name] = value
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BasicAuth:
|
|
63
|
+
username: str = field(repr=False)
|
|
64
|
+
password: str = field(repr=False)
|
|
65
|
+
method = "basic"
|
|
66
|
+
|
|
67
|
+
def headers(self, description: Description) -> Mapping[str, str]:
|
|
68
|
+
if ":" in self.username or any(
|
|
69
|
+
c in self.username + self.password for c in "\r\n"
|
|
70
|
+
):
|
|
71
|
+
raise AuthenticationError("Invalid Basic credentials")
|
|
72
|
+
encoded = base64.b64encode(f"{self.username}:{self.password}".encode()).decode(
|
|
73
|
+
"ascii"
|
|
74
|
+
)
|
|
75
|
+
return {"Authorization": f"Basic {encoded}"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class TokenAuth:
|
|
80
|
+
token: str = field(repr=False)
|
|
81
|
+
method = "token"
|
|
82
|
+
|
|
83
|
+
def headers(self, description: Description) -> Mapping[str, str]:
|
|
84
|
+
provider = description.authentication.get(self.method)
|
|
85
|
+
header = provider.get("http_header") if provider is not None else None
|
|
86
|
+
if not isinstance(header, str):
|
|
87
|
+
raise AuthenticationError("Token provider has no valid HTTP header")
|
|
88
|
+
return validate_headers({header: self.token})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class OAuth2Auth:
|
|
93
|
+
access_token: str = field(repr=False)
|
|
94
|
+
method = "oauth2"
|
|
95
|
+
|
|
96
|
+
def headers(self, description: Description) -> Mapping[str, str]:
|
|
97
|
+
return validate_headers({"Authorization": f"Bearer {self.access_token}"})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Authenticated:
|
|
102
|
+
token: str = field(repr=False)
|
|
103
|
+
valid_to: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class AuthenticationChallenge:
|
|
108
|
+
next_action: str
|
|
109
|
+
input: Mapping[str, ParameterSpec]
|
|
110
|
+
valid_to: str | None
|
|
111
|
+
_submit: Callable[
|
|
112
|
+
[Mapping[str, object]], Authenticated | AuthenticationChallenge
|
|
113
|
+
] = field(repr=False, compare=False)
|
|
114
|
+
|
|
115
|
+
def __post_init__(self) -> None:
|
|
116
|
+
object.__setattr__(self, "input", MappingProxyType(dict(self.input)))
|
|
117
|
+
|
|
118
|
+
def submit(
|
|
119
|
+
self, params: Mapping[str, object]
|
|
120
|
+
) -> Authenticated | AuthenticationChallenge:
|
|
121
|
+
return self._submit(params)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class AsyncAuthenticationChallenge:
|
|
126
|
+
next_action: str
|
|
127
|
+
input: Mapping[str, ParameterSpec]
|
|
128
|
+
valid_to: str | None
|
|
129
|
+
_submit: Callable[
|
|
130
|
+
[Mapping[str, object]], Awaitable[Authenticated | AsyncAuthenticationChallenge]
|
|
131
|
+
] = field(repr=False, compare=False)
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
object.__setattr__(self, "input", MappingProxyType(dict(self.input)))
|
|
135
|
+
|
|
136
|
+
async def submit(
|
|
137
|
+
self, params: Mapping[str, object]
|
|
138
|
+
) -> Authenticated | AsyncAuthenticationChallenge:
|
|
139
|
+
return await self._submit(params)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Transport-independent HaveAPI protocol 2 core."""
|
|
2
|
+
|
|
3
|
+
from .description import (
|
|
4
|
+
lookup_action,
|
|
5
|
+
lookup_resource,
|
|
6
|
+
parse_description,
|
|
7
|
+
parse_resources,
|
|
8
|
+
)
|
|
9
|
+
from .execution import AsyncExecutor, SyncExecutor
|
|
10
|
+
from .input import coerce_input
|
|
11
|
+
from .models import (
|
|
12
|
+
ActionSpec,
|
|
13
|
+
CallContext,
|
|
14
|
+
DecodedResponse,
|
|
15
|
+
Description,
|
|
16
|
+
InputObject,
|
|
17
|
+
InputParams,
|
|
18
|
+
LayoutSpec,
|
|
19
|
+
ParameterSpec,
|
|
20
|
+
RequestOptions,
|
|
21
|
+
RequestSpec,
|
|
22
|
+
ResourceSpec,
|
|
23
|
+
WireResponse,
|
|
24
|
+
)
|
|
25
|
+
from .request import build_request, resolve_url
|
|
26
|
+
from .response import decode_envelope, decode_response
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ActionSpec",
|
|
30
|
+
"AsyncExecutor",
|
|
31
|
+
"CallContext",
|
|
32
|
+
"DecodedResponse",
|
|
33
|
+
"Description",
|
|
34
|
+
"InputObject",
|
|
35
|
+
"InputParams",
|
|
36
|
+
"LayoutSpec",
|
|
37
|
+
"ParameterSpec",
|
|
38
|
+
"RequestOptions",
|
|
39
|
+
"RequestSpec",
|
|
40
|
+
"ResourceSpec",
|
|
41
|
+
"SyncExecutor",
|
|
42
|
+
"WireResponse",
|
|
43
|
+
"build_request",
|
|
44
|
+
"coerce_input",
|
|
45
|
+
"decode_envelope",
|
|
46
|
+
"decode_response",
|
|
47
|
+
"lookup_action",
|
|
48
|
+
"lookup_resource",
|
|
49
|
+
"parse_description",
|
|
50
|
+
"parse_resources",
|
|
51
|
+
"resolve_url",
|
|
52
|
+
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Parse HaveAPI descriptions without rewriting server-owned names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ProtocolError
|
|
10
|
+
from .models import (
|
|
11
|
+
ActionSpec,
|
|
12
|
+
Description,
|
|
13
|
+
LayoutSpec,
|
|
14
|
+
ParameterSpec,
|
|
15
|
+
ResourceSpec,
|
|
16
|
+
frozen_mapping,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
PATH_PARAMETER = re.compile(r"\{([a-zA-Z0-9_-]+)\}")
|
|
20
|
+
LAYOUTS = frozenset(("object", "object_list", "hash", "hash_list"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mapping(value: object, label: str) -> Mapping[str, object]:
|
|
24
|
+
if not isinstance(value, Mapping) or not all(isinstance(k, str) for k in value):
|
|
25
|
+
raise ProtocolError(f"{label} must be an object with string keys")
|
|
26
|
+
return cast(Mapping[str, object], value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _string(value: object, label: str) -> str:
|
|
30
|
+
if not isinstance(value, str) or not value:
|
|
31
|
+
raise ProtocolError(f"{label} must be a nonempty string")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _boolean(raw: Mapping[str, object], name: str) -> bool:
|
|
36
|
+
value = raw.get(name, False)
|
|
37
|
+
if value is None:
|
|
38
|
+
return False
|
|
39
|
+
if not isinstance(value, bool):
|
|
40
|
+
raise ProtocolError(f"{name} must be boolean")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_layout(value: object) -> LayoutSpec | None:
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
raw = mapping(value, "layout")
|
|
48
|
+
layout = _string(raw.get("layout"), "layout type")
|
|
49
|
+
if layout not in LAYOUTS:
|
|
50
|
+
raise ProtocolError(f"unsupported layout: {layout}")
|
|
51
|
+
namespace = raw.get("namespace")
|
|
52
|
+
if namespace is False or namespace is None:
|
|
53
|
+
namespace = None
|
|
54
|
+
elif not isinstance(namespace, str) or not namespace:
|
|
55
|
+
raise ProtocolError("namespace must be a nonempty string or false")
|
|
56
|
+
parameters = {}
|
|
57
|
+
for name, entry in mapping(raw.get("parameters", {}), "parameters").items():
|
|
58
|
+
spec = mapping(entry, f"parameter {name}")
|
|
59
|
+
parameters[name] = ParameterSpec(
|
|
60
|
+
name,
|
|
61
|
+
_string(spec.get("type"), "parameter type"),
|
|
62
|
+
_boolean(spec, "required"),
|
|
63
|
+
_boolean(spec, "nullable"),
|
|
64
|
+
spec,
|
|
65
|
+
)
|
|
66
|
+
return LayoutSpec(layout, namespace, parameters, raw)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _action(name: str, value: object, resource_path: tuple[str, ...]) -> ActionSpec:
|
|
70
|
+
raw = mapping(value, f"action {name}")
|
|
71
|
+
method = _string(raw.get("method"), "action method").upper()
|
|
72
|
+
if method not in {"GET", "DELETE", "POST", "PUT"}:
|
|
73
|
+
raise ProtocolError(f"unsupported action method: {method}")
|
|
74
|
+
path = _string(raw.get("path"), "action path")
|
|
75
|
+
unmatched = PATH_PARAMETER.sub("", path)
|
|
76
|
+
if "{" in unmatched or "}" in unmatched:
|
|
77
|
+
raise ProtocolError("invalid action path template")
|
|
78
|
+
aliases = raw.get("aliases", ())
|
|
79
|
+
if not isinstance(aliases, (list, tuple)) or not all(
|
|
80
|
+
isinstance(v, str) for v in aliases
|
|
81
|
+
):
|
|
82
|
+
raise ProtocolError("action aliases must be an array of strings")
|
|
83
|
+
meta = mapping(raw.get("meta") or {}, "action metadata")
|
|
84
|
+
for scope in ("global", "object"):
|
|
85
|
+
if meta.get(scope) is not None:
|
|
86
|
+
scope_raw = mapping(meta[scope], f"{scope} metadata")
|
|
87
|
+
parse_layout(scope_raw.get("input"))
|
|
88
|
+
parse_layout(scope_raw.get("output"))
|
|
89
|
+
return ActionSpec(
|
|
90
|
+
name,
|
|
91
|
+
resource_path,
|
|
92
|
+
method,
|
|
93
|
+
path,
|
|
94
|
+
tuple(PATH_PARAMETER.findall(path)),
|
|
95
|
+
tuple(cast(list[str], aliases)),
|
|
96
|
+
parse_layout(raw.get("input")),
|
|
97
|
+
parse_layout(raw.get("output")),
|
|
98
|
+
meta,
|
|
99
|
+
_boolean(raw, "blocking"),
|
|
100
|
+
_boolean(raw, "auth"),
|
|
101
|
+
raw,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_resources(
|
|
106
|
+
raw: Mapping[str, object], *, parent_path: tuple[str, ...] = ()
|
|
107
|
+
) -> Mapping[str, ResourceSpec]:
|
|
108
|
+
resources = {}
|
|
109
|
+
for name, value in mapping(raw, "resources").items():
|
|
110
|
+
spec = mapping(value, f"resource {name}")
|
|
111
|
+
path = (*parent_path, name)
|
|
112
|
+
actions = {
|
|
113
|
+
action_name: _action(action_name, entry, path)
|
|
114
|
+
for action_name, entry in mapping(
|
|
115
|
+
spec.get("actions", {}), "actions"
|
|
116
|
+
).items()
|
|
117
|
+
}
|
|
118
|
+
resources[name] = ResourceSpec(
|
|
119
|
+
name,
|
|
120
|
+
path,
|
|
121
|
+
parse_resources(
|
|
122
|
+
mapping(spec.get("resources", {}), "nested resources"), parent_path=path
|
|
123
|
+
),
|
|
124
|
+
actions,
|
|
125
|
+
spec,
|
|
126
|
+
)
|
|
127
|
+
return frozen_mapping(resources)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_description(
|
|
131
|
+
envelope: Mapping[str, object], *, api_version: str | None = None
|
|
132
|
+
) -> Description:
|
|
133
|
+
raw = mapping(envelope, "description envelope")
|
|
134
|
+
if raw.get("status") is not True:
|
|
135
|
+
raise ProtocolError("description envelope must indicate success")
|
|
136
|
+
protocol = raw.get("version")
|
|
137
|
+
if not isinstance(protocol, str) or not re.fullmatch(r"2\.[0-9]+", protocol):
|
|
138
|
+
raise ProtocolError(
|
|
139
|
+
"unsupported or missing protocol version (expected major 2)"
|
|
140
|
+
)
|
|
141
|
+
body = mapping(raw.get("response"), "API description")
|
|
142
|
+
resources = parse_resources(mapping(body.get("resources"), "resources"))
|
|
143
|
+
authentication = {
|
|
144
|
+
name: mapping(value, f"authentication {name}")
|
|
145
|
+
for name, value in mapping(
|
|
146
|
+
body.get("authentication", {}), "authentication"
|
|
147
|
+
).items()
|
|
148
|
+
}
|
|
149
|
+
meta = mapping(body.get("meta", {}), "metadata description")
|
|
150
|
+
namespace = _string(meta.get("namespace", "_meta"), "metadata namespace")
|
|
151
|
+
return Description(
|
|
152
|
+
protocol, api_version, resources, authentication, namespace, body
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def lookup_resource(description: Description, path: tuple[str, ...]) -> ResourceSpec:
|
|
157
|
+
if not path:
|
|
158
|
+
raise KeyError("resource path cannot be empty")
|
|
159
|
+
resources = description.resources
|
|
160
|
+
for name in path:
|
|
161
|
+
resource = resources[name]
|
|
162
|
+
resources = resource.resources
|
|
163
|
+
return resource
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def lookup_action(
|
|
167
|
+
description: Description, resource_path: tuple[str, ...], name: str
|
|
168
|
+
) -> ActionSpec:
|
|
169
|
+
resource = lookup_resource(description, resource_path)
|
|
170
|
+
if name in resource.actions:
|
|
171
|
+
return resource.actions[name]
|
|
172
|
+
matches = [a for a in resource.actions.values() if name in a.aliases]
|
|
173
|
+
if len(matches) != 1:
|
|
174
|
+
reason = "ambiguous" if matches else "unknown"
|
|
175
|
+
raise KeyError(f"{reason} action or alias: {name}")
|
|
176
|
+
return matches[0]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Execution boundary shared by the facade and response helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
|
+
|
|
8
|
+
from .models import CallContext, Description, InputParams, RequestOptions
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..response import Response
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncExecutor(Protocol):
|
|
15
|
+
@property
|
|
16
|
+
def description(self) -> Description: ...
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def generation(self) -> int: ...
|
|
20
|
+
|
|
21
|
+
def resource(
|
|
22
|
+
self,
|
|
23
|
+
path: tuple[str, ...],
|
|
24
|
+
*,
|
|
25
|
+
bound_args: tuple[str | int, ...] = (),
|
|
26
|
+
path_params: Mapping[str, str | int] | None = None,
|
|
27
|
+
) -> Any: ...
|
|
28
|
+
|
|
29
|
+
def execute(
|
|
30
|
+
self,
|
|
31
|
+
context: CallContext,
|
|
32
|
+
*,
|
|
33
|
+
params: InputParams | None = None,
|
|
34
|
+
meta: Mapping[str, object] | None = None,
|
|
35
|
+
options: RequestOptions | None = None,
|
|
36
|
+
) -> Response[Any]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncExecutor(Protocol):
|
|
40
|
+
@property
|
|
41
|
+
def description(self) -> Description: ...
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def generation(self) -> int: ...
|
|
45
|
+
|
|
46
|
+
def resource(
|
|
47
|
+
self,
|
|
48
|
+
path: tuple[str, ...],
|
|
49
|
+
*,
|
|
50
|
+
bound_args: tuple[str | int, ...] = (),
|
|
51
|
+
path_params: Mapping[str, str | int] | None = None,
|
|
52
|
+
) -> Any: ...
|
|
53
|
+
|
|
54
|
+
async def execute(
|
|
55
|
+
self,
|
|
56
|
+
context: CallContext,
|
|
57
|
+
*,
|
|
58
|
+
params: InputParams | None = None,
|
|
59
|
+
meta: Mapping[str, object] | None = None,
|
|
60
|
+
options: RequestOptions | None = None,
|
|
61
|
+
) -> Response[Any]: ...
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Strict typed inputs from doc/typed-input-validation.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
|
|
10
|
+
from ..exceptions import ValidationError
|
|
11
|
+
from .models import InputObject, InputParams, LayoutSpec, ParameterSpec
|
|
12
|
+
|
|
13
|
+
_INTEGER = re.compile(r"[+-]?[0-9]+\Z")
|
|
14
|
+
_FLOAT = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z")
|
|
15
|
+
_DATETIME = re.compile(
|
|
16
|
+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}"
|
|
17
|
+
r"(?:T[0-9]{2}:[0-9]{2}(?::[0-9]{2}(?:\.[0-9]+)?)?"
|
|
18
|
+
r"(?:Z|[+-][0-9]{2}:?[0-9]{2}))?\Z"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def json_value(value: object) -> object:
|
|
23
|
+
"""Copy only finite JSON values; never stringify unknown objects."""
|
|
24
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
25
|
+
return value
|
|
26
|
+
if isinstance(value, float) and math.isfinite(value):
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, Mapping) and all(isinstance(k, str) for k in value):
|
|
29
|
+
return {k: json_value(v) for k, v in value.items()}
|
|
30
|
+
if isinstance(value, (list, tuple)):
|
|
31
|
+
return [json_value(v) for v in value]
|
|
32
|
+
raise ValueError("not a finite JSON value")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _coerce(spec: ParameterSpec, value: object) -> object:
|
|
36
|
+
if value is None or (
|
|
37
|
+
spec.nullable and isinstance(value, str) and not value.strip()
|
|
38
|
+
):
|
|
39
|
+
if spec.nullable and not spec.required:
|
|
40
|
+
return None
|
|
41
|
+
raise ValueError("cannot be null")
|
|
42
|
+
kind = spec.type
|
|
43
|
+
if kind == "Integer":
|
|
44
|
+
if type(value) is int:
|
|
45
|
+
return value
|
|
46
|
+
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
|
|
47
|
+
return int(value)
|
|
48
|
+
if isinstance(value, str) and _INTEGER.fullmatch(value.strip()):
|
|
49
|
+
return int(value.strip(), 10)
|
|
50
|
+
raise ValueError("not a valid integer")
|
|
51
|
+
if kind == "Float":
|
|
52
|
+
if type(value) in (int, float) or (
|
|
53
|
+
isinstance(value, str) and _FLOAT.fullmatch(value.strip())
|
|
54
|
+
):
|
|
55
|
+
try:
|
|
56
|
+
number = float(value) # type: ignore[arg-type]
|
|
57
|
+
if math.isfinite(number):
|
|
58
|
+
return number
|
|
59
|
+
except (ValueError, OverflowError):
|
|
60
|
+
pass
|
|
61
|
+
raise ValueError("not a valid float")
|
|
62
|
+
if kind == "Boolean":
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
return value
|
|
65
|
+
if type(value) is int and value in (0, 1):
|
|
66
|
+
return bool(value)
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
token = value.strip().lower()
|
|
69
|
+
if token in ("true", "t", "yes", "y", "1"):
|
|
70
|
+
return True
|
|
71
|
+
if token in ("false", "f", "no", "n", "0"):
|
|
72
|
+
return False
|
|
73
|
+
raise ValueError("not a valid boolean")
|
|
74
|
+
if kind in ("String", "Text"):
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return value
|
|
77
|
+
if isinstance(value, bool):
|
|
78
|
+
return "true" if value else "false"
|
|
79
|
+
if type(value) is int or isinstance(value, float) and math.isfinite(value):
|
|
80
|
+
return str(value)
|
|
81
|
+
raise ValueError("not a valid string")
|
|
82
|
+
if kind == "Resource":
|
|
83
|
+
if type(value) is int and value >= 0:
|
|
84
|
+
return value
|
|
85
|
+
if isinstance(value, str) and re.fullmatch(r"[0-9]+", value.strip()):
|
|
86
|
+
return int(value.strip())
|
|
87
|
+
raise ValueError("not a valid resource ID")
|
|
88
|
+
if kind == "Datetime":
|
|
89
|
+
if isinstance(value, datetime):
|
|
90
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
91
|
+
raise ValueError("not in ISO 8601 format")
|
|
92
|
+
value = value.isoformat()
|
|
93
|
+
elif isinstance(value, date):
|
|
94
|
+
value = value.isoformat()
|
|
95
|
+
if isinstance(value, str) and _DATETIME.fullmatch(value):
|
|
96
|
+
try:
|
|
97
|
+
if "T" in value:
|
|
98
|
+
# fromisoformat validates dates and clock/offset components.
|
|
99
|
+
datetime.fromisoformat(value)
|
|
100
|
+
offset = re.search(r"[+-]([0-9]{2}):?([0-9]{2})$", value)
|
|
101
|
+
if offset and (int(offset[1]) > 23 or int(offset[2]) > 59):
|
|
102
|
+
raise ValueError("invalid offset")
|
|
103
|
+
else:
|
|
104
|
+
date.fromisoformat(value)
|
|
105
|
+
return value
|
|
106
|
+
except ValueError:
|
|
107
|
+
pass
|
|
108
|
+
raise ValueError("not in ISO 8601 format")
|
|
109
|
+
if kind == "Custom":
|
|
110
|
+
return json_value(value)
|
|
111
|
+
raise ValueError(f"unsupported input parameter type: {kind}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _coerce_object(layout: LayoutSpec, params: InputObject) -> InputObject:
|
|
115
|
+
if not all(isinstance(k, str) for k in params):
|
|
116
|
+
raise ValidationError("input keys must be strings")
|
|
117
|
+
errors: dict[str, object] = {
|
|
118
|
+
k: ["unknown parameter"] for k in params if k not in layout.parameters
|
|
119
|
+
}
|
|
120
|
+
result = {}
|
|
121
|
+
for name, spec in layout.parameters.items():
|
|
122
|
+
if name not in params:
|
|
123
|
+
if spec.required:
|
|
124
|
+
errors[name] = ["required parameter is missing"]
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
result[name] = _coerce(spec, params[name])
|
|
128
|
+
except ValueError as error:
|
|
129
|
+
errors[name] = [str(error)]
|
|
130
|
+
if errors:
|
|
131
|
+
raise ValidationError("input parameters are not valid", errors=errors)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def coerce_input(layout: LayoutSpec | None, params: InputParams | None) -> InputParams:
|
|
136
|
+
if layout is None:
|
|
137
|
+
if params:
|
|
138
|
+
raise ValidationError("action does not accept input parameters")
|
|
139
|
+
return {}
|
|
140
|
+
is_list = layout.layout in ("object_list", "hash_list")
|
|
141
|
+
if params is None:
|
|
142
|
+
params = [] if is_list else {}
|
|
143
|
+
if is_list:
|
|
144
|
+
if not isinstance(params, Sequence) or isinstance(params, (str, bytes)):
|
|
145
|
+
raise ValidationError("input layout requires a list of objects")
|
|
146
|
+
result = []
|
|
147
|
+
errors: dict[str, object] = {}
|
|
148
|
+
for index, row in enumerate(params):
|
|
149
|
+
if not isinstance(row, Mapping):
|
|
150
|
+
errors[str(index)] = ["input row must be an object"]
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
result.append(_coerce_object(layout, row))
|
|
154
|
+
except ValidationError as error:
|
|
155
|
+
errors[str(index)] = error.errors
|
|
156
|
+
if errors:
|
|
157
|
+
raise ValidationError("input parameters are not valid", errors=errors)
|
|
158
|
+
return result
|
|
159
|
+
if not isinstance(params, Mapping):
|
|
160
|
+
raise ValidationError("input layout requires an object")
|
|
161
|
+
return _coerce_object(layout, params)
|