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.
@@ -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)