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,196 @@
1
+ """Immutable resource and action navigation; only calls perform I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from types import MappingProxyType
8
+ from typing import Any
9
+
10
+ from haveapi_client._protocol import (
11
+ CallContext,
12
+ InputParams,
13
+ RequestOptions,
14
+ lookup_action,
15
+ lookup_resource,
16
+ )
17
+ from haveapi_client.exceptions import StaleDescriptionError
18
+
19
+
20
+ def _path_params(
21
+ old: Mapping[str, str | int], new: Mapping[str, str | int] | None
22
+ ) -> Mapping[str, str | int]:
23
+ result = dict(old)
24
+ for key, value in (new or {}).items():
25
+ if key in result and result[key] != value:
26
+ raise ValueError(f"Conflicting path parameter: {key}")
27
+ result[key] = value
28
+ return MappingProxyType(result)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Resource:
33
+ _executor: Any = field(repr=False, compare=False)
34
+ _path: tuple[str, ...]
35
+ _bound_args: tuple[str | int, ...] = field(default=(), repr=False)
36
+ _path_params: Mapping[str, str | int] = field(default_factory=dict, repr=False)
37
+ _generation: int = 0
38
+ _async: bool = False
39
+
40
+ def __post_init__(self) -> None:
41
+ object.__setattr__(self, "_path", tuple(self._path))
42
+ object.__setattr__(self, "_bound_args", tuple(self._bound_args))
43
+ object.__setattr__(
44
+ self, "_path_params", MappingProxyType(dict(self._path_params))
45
+ )
46
+
47
+ def _check(self) -> None:
48
+ if self._executor.generation != self._generation:
49
+ raise StaleDescriptionError("Resource belongs to an old description")
50
+
51
+ @property
52
+ def resources(self) -> Mapping[str, Resource]:
53
+ self._check()
54
+ spec = lookup_resource(self._executor.description, self._path)
55
+ return MappingProxyType(
56
+ {
57
+ name: Resource(
58
+ self._executor,
59
+ child.resource_path,
60
+ self._bound_args,
61
+ self._path_params,
62
+ self._generation,
63
+ self._async,
64
+ )
65
+ for name, child in spec.resources.items()
66
+ }
67
+ )
68
+
69
+ @property
70
+ def actions(self) -> Mapping[str, Action]:
71
+ self._check()
72
+ spec = lookup_resource(self._executor.description, self._path)
73
+ return MappingProxyType({name: self._action(name) for name in spec.actions})
74
+
75
+ def _action(self, name: str) -> Action:
76
+ self._check()
77
+ spec = lookup_action(self._executor.description, self._path, name)
78
+ return Action(
79
+ self._executor,
80
+ CallContext(spec, self._bound_args, self._path_params, self._generation),
81
+ self._async,
82
+ )
83
+
84
+ def __getattr__(self, name: str) -> Any:
85
+ if name.startswith("_"):
86
+ raise AttributeError(name)
87
+ self._check()
88
+ spec = lookup_resource(self._executor.description, self._path)
89
+ has_action = name in spec.actions or any(
90
+ name in a.aliases for a in spec.actions.values()
91
+ )
92
+ if name in spec.resources:
93
+ if has_action:
94
+ raise AttributeError(
95
+ f"Ambiguous resource/action {name!r}; use exact-name maps"
96
+ )
97
+ return self.resources[name]
98
+ if has_action:
99
+ return self._action(name)
100
+ raise AttributeError(name)
101
+
102
+ def __getitem__(self, value: str | int) -> Resource:
103
+ self._check()
104
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
105
+ raise TypeError("Path bindings must be strings or integers")
106
+ return Resource(
107
+ self._executor,
108
+ self._path,
109
+ (*self._bound_args, value),
110
+ self._path_params,
111
+ self._generation,
112
+ self._async,
113
+ )
114
+
115
+ def __dir__(self) -> list[str]:
116
+ self._check()
117
+ spec = lookup_resource(self._executor.description, self._path)
118
+ return sorted(set(super().__dir__()) | set(spec.resources) | set(spec.actions))
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Action:
123
+ _executor: Any = field(repr=False, compare=False)
124
+ _context: CallContext = field(repr=False)
125
+ _async: bool = False
126
+
127
+ def _binding(self, path_params: Mapping[str, str | int] | None) -> CallContext:
128
+ if self._executor.generation != self._context.generation:
129
+ raise StaleDescriptionError("Action belongs to an old description")
130
+ return CallContext(
131
+ self._context.action,
132
+ self._context.bound_args,
133
+ _path_params(self._context.path_params, path_params),
134
+ self._context.generation,
135
+ )
136
+
137
+ def __call__(self, **params: object) -> Any:
138
+ return self.call(params=params)
139
+
140
+ def call(
141
+ self,
142
+ *,
143
+ params: InputParams | None = None,
144
+ meta: Mapping[str, object] | None = None,
145
+ path_params: Mapping[str, str | int] | None = None,
146
+ options: RequestOptions | None = None,
147
+ ) -> Any:
148
+ return self._executor.execute(
149
+ self._binding(path_params), params=params, meta=meta, options=options
150
+ )
151
+
152
+ def iter_pages(
153
+ self,
154
+ *,
155
+ strategy: Any,
156
+ page_size: int = 100,
157
+ params: Mapping[str, object] | None = None,
158
+ meta: Mapping[str, object] | None = None,
159
+ path_params: Mapping[str, str | int] | None = None,
160
+ options: RequestOptions | None = None,
161
+ ) -> Any:
162
+ from haveapi_client.pagination import aiter_pages, iter_pages
163
+
164
+ function = aiter_pages if self._async else iter_pages
165
+ return function(
166
+ self._executor,
167
+ self._binding(path_params),
168
+ strategy=strategy,
169
+ page_size=page_size,
170
+ params=params,
171
+ meta=meta,
172
+ options=options,
173
+ )
174
+
175
+ def iter_all(
176
+ self,
177
+ *,
178
+ strategy: Any,
179
+ page_size: int = 100,
180
+ params: Mapping[str, object] | None = None,
181
+ meta: Mapping[str, object] | None = None,
182
+ path_params: Mapping[str, str | int] | None = None,
183
+ options: RequestOptions | None = None,
184
+ ) -> Any:
185
+ from haveapi_client.pagination import aiter_all, iter_all
186
+
187
+ function = aiter_all if self._async else iter_all
188
+ return function(
189
+ self._executor,
190
+ self._binding(path_params),
191
+ strategy=strategy,
192
+ page_size=page_size,
193
+ params=params,
194
+ meta=meta,
195
+ options=options,
196
+ )
@@ -0,0 +1,266 @@
1
+ """Shared pure connection state and concurrency gates for both client facades."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import threading
7
+ from collections.abc import Iterator, Mapping
8
+ from contextlib import contextmanager
9
+ from typing import Any
10
+
11
+ from haveapi_client._auth import AuthProvider, validate_headers
12
+ from haveapi_client._protocol import (
13
+ ActionSpec,
14
+ CallContext,
15
+ Description,
16
+ InputParams,
17
+ ParameterSpec,
18
+ RequestSpec,
19
+ build_request,
20
+ lookup_resource,
21
+ parse_resources,
22
+ )
23
+ from haveapi_client._resources import Resource
24
+ from haveapi_client._transport import normalize_base_url, validate_destination
25
+ from haveapi_client.exceptions import (
26
+ AuthenticationError,
27
+ NotConnectedError,
28
+ ProtocolError,
29
+ StaleDescriptionError,
30
+ )
31
+
32
+
33
+ class ClientState:
34
+ def __init__(
35
+ self,
36
+ base_url: str,
37
+ *,
38
+ version: str | None,
39
+ auth: AuthProvider | None,
40
+ language: str | None,
41
+ asynchronous: bool,
42
+ ) -> None:
43
+ self._base_url = normalize_base_url(base_url)
44
+ if version is not None and (
45
+ not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9._-]*", version)
46
+ ):
47
+ raise ValueError("Invalid API version")
48
+ if language is not None and any(ord(c) < 32 or ord(c) > 126 for c in language):
49
+ raise ValueError("Invalid language header")
50
+ self._version = version
51
+ self._auth = auth
52
+ self._language = language
53
+ self._description: Description | None = None
54
+ self._generation = 0
55
+ self._asynchronous = asynchronous
56
+ self._lock = threading.RLock()
57
+ self._active = 0
58
+ self._mutating = False
59
+ self._closed = False
60
+ self._login: object | None = None
61
+
62
+ def __repr__(self) -> str:
63
+ return f"{type(self).__name__}(connected={self._description is not None})"
64
+
65
+ @property
66
+ def description(self) -> Description:
67
+ if self._description is None:
68
+ raise NotConnectedError("Call connect() before accessing API resources")
69
+ return self._description
70
+
71
+ @property
72
+ def generation(self) -> int:
73
+ return self._generation
74
+
75
+ @property
76
+ def resources(self) -> Mapping[str, Resource]:
77
+ from types import MappingProxyType
78
+
79
+ return MappingProxyType(
80
+ {name: self.resource((name,)) for name in self.description.resources}
81
+ )
82
+
83
+ def resource(
84
+ self,
85
+ path: tuple[str, ...],
86
+ *,
87
+ bound_args: tuple[str | int, ...] = (),
88
+ path_params: Mapping[str, str | int] | None = None,
89
+ ) -> Resource:
90
+ lookup_resource(self.description, path)
91
+ return Resource(
92
+ self,
93
+ path,
94
+ bound_args,
95
+ path_params or {},
96
+ self.generation,
97
+ self._asynchronous,
98
+ )
99
+
100
+ def __getattr__(self, name: str) -> Any:
101
+ if name.startswith("_"):
102
+ raise AttributeError(name)
103
+ if name in self.description.resources:
104
+ return self.resource((name,))
105
+ raise AttributeError(name)
106
+
107
+ def __dir__(self) -> list[str]:
108
+ return sorted(
109
+ set(super().__dir__())
110
+ | set(self._description.resources if self._description else ())
111
+ )
112
+
113
+ @contextmanager
114
+ def _mutation(self) -> Iterator[None]:
115
+ # Never hold a thread lock across network I/O or an await. The same
116
+ # short reservation gate handles threads and independently scheduled tasks.
117
+ with self._lock:
118
+ if self._closed:
119
+ raise NotConnectedError("Client is closed")
120
+ if self._active or self._mutating:
121
+ raise AuthenticationError(
122
+ "Client state is in use; retry the explicit state change later"
123
+ )
124
+ self._mutating = True
125
+ try:
126
+ yield
127
+ finally:
128
+ with self._lock:
129
+ self._mutating = False
130
+
131
+ @contextmanager
132
+ def _request(self, context: CallContext) -> Iterator[Description]:
133
+ with self._lock:
134
+ if context.generation != self.generation:
135
+ raise StaleDescriptionError("Action belongs to an old description")
136
+ if self._mutating:
137
+ raise AuthenticationError(
138
+ "Client authentication or description is changing"
139
+ )
140
+ description = self.description
141
+ self._active += 1
142
+ try:
143
+ yield description
144
+ finally:
145
+ with self._lock:
146
+ self._active -= 1
147
+
148
+ def _invalidate(self) -> None:
149
+ self._generation += 1
150
+ self._description = None
151
+ self._login = None
152
+
153
+ def _install(self, description: Description) -> None:
154
+ self._generation += 1
155
+ self._description = description
156
+ self._login = None
157
+
158
+ def _headers(
159
+ self, description: Description | None, auth: AuthProvider | None
160
+ ) -> dict[str, str]:
161
+ headers = {"Accept": "application/json", "User-Agent": "haveapi-client-python"}
162
+ if self._language:
163
+ headers["Accept-Language"] = self._language
164
+ if auth is not None:
165
+ if description is None or auth.method not in description.authentication:
166
+ raise AuthenticationError("Authentication method is not advertised")
167
+ headers.update(validate_headers(auth.headers(description)))
168
+ return headers
169
+
170
+ def _discovery_request(
171
+ self,
172
+ description: Description | None = None,
173
+ auth: AuthProvider | None = None,
174
+ ) -> RequestSpec:
175
+ url = self._base_url + (
176
+ f"v{self._version}/" if self._version is not None else ""
177
+ )
178
+ validate_destination(self._base_url, url)
179
+ query = () if self._version is not None else (("describe", "default"),)
180
+ return RequestSpec(
181
+ "OPTIONS", url, query, self._headers(description, auth), None
182
+ )
183
+
184
+ def _action_request(
185
+ self,
186
+ description: Description,
187
+ context: CallContext,
188
+ *,
189
+ auth: AuthProvider | None,
190
+ params: InputParams | None = None,
191
+ meta: Mapping[str, object] | None = None,
192
+ ) -> RequestSpec:
193
+ request = build_request(
194
+ description, context, base_url=self._base_url, params=params, meta=meta
195
+ )
196
+ validate_destination(self._base_url, request.url)
197
+ headers = dict(request.headers)
198
+ headers.update(self._headers(description, auth))
199
+ return RequestSpec(
200
+ request.method, request.url, request.query, headers, request.content
201
+ )
202
+
203
+ def _token_action(self, description: Description, name: str) -> ActionSpec:
204
+ provider = description.authentication.get("token")
205
+ raw = provider.get("resources") if provider is not None else None
206
+ if not isinstance(raw, Mapping):
207
+ raise AuthenticationError("Token authentication is not advertised")
208
+ resources = parse_resources(raw)
209
+ token = resources.get("token")
210
+ if token is None or name not in token.actions:
211
+ raise AuthenticationError("Token authentication action is not advertised")
212
+ return token.actions[name]
213
+
214
+ def _login_result(self, data: object) -> tuple[bool, str, str | None, str | None]:
215
+ if not isinstance(data, Mapping) or not isinstance(data.get("complete"), bool):
216
+ raise ProtocolError("Invalid token authentication result")
217
+ token = data.get("token")
218
+ valid_to = data.get("valid_to")
219
+ complete = data["complete"]
220
+ next_action = data.get("next_action")
221
+ if (
222
+ not isinstance(token, str)
223
+ or not token
224
+ or (valid_to is not None and not isinstance(valid_to, str))
225
+ or (not complete and (not isinstance(next_action, str) or not next_action))
226
+ ):
227
+ raise ProtocolError("Invalid token authentication result")
228
+ return (
229
+ complete,
230
+ token,
231
+ valid_to,
232
+ next_action if isinstance(next_action, str) else None,
233
+ )
234
+
235
+ def _prepare_challenge(
236
+ self,
237
+ description: Description,
238
+ next_action: str,
239
+ ) -> tuple[Mapping[str, ParameterSpec], object]:
240
+ """Validate a next step before installing its nonce under _mutation."""
241
+ action = self._token_action(description, next_action)
242
+ if action.input is None or "token" not in action.input.parameters:
243
+ raise ProtocolError(
244
+ "Authentication continuation must describe a token input"
245
+ )
246
+ fields = {
247
+ key: value
248
+ for key, value in action.input.parameters.items()
249
+ if key != "token"
250
+ }
251
+ nonce = object()
252
+ self._login = nonce
253
+ return fields, nonce
254
+
255
+ def _continuation_params(
256
+ self,
257
+ nonce: object,
258
+ token: str,
259
+ values: Mapping[str, object],
260
+ ) -> Mapping[str, object]:
261
+ """Check the current challenge and copy input without consuming retries."""
262
+ if self._login is not nonce:
263
+ raise AuthenticationError("Authentication challenge is no longer current")
264
+ if "token" in values:
265
+ raise AuthenticationError("Continuation token cannot be overridden")
266
+ return {**values, "token": token}
@@ -0,0 +1,155 @@
1
+ """HTTPX adapters. Requests never inherit injected-client auth or cookies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from urllib.parse import unquote, urlencode, urlsplit
7
+
8
+ import httpx
9
+
10
+ from haveapi_client._protocol import (
11
+ RequestOptions,
12
+ RequestSpec,
13
+ WireResponse,
14
+ resolve_url,
15
+ )
16
+ from haveapi_client.exceptions import ProtocolError, TransportError
17
+
18
+
19
+ def finite_timeout(value: float) -> float:
20
+ if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
21
+ raise ValueError("timeout must be positive and finite")
22
+ return value
23
+
24
+
25
+ def normalize_base_url(value: str) -> str:
26
+ if any(ord(c) < 32 or ord(c) == 127 for c in value) or "\\" in value:
27
+ raise ValueError("base_url contains unsafe characters")
28
+ url = urlsplit(value)
29
+ if (
30
+ url.scheme not in {"https", "http"}
31
+ or not url.hostname
32
+ or url.username is not None
33
+ or url.password is not None
34
+ or url.query
35
+ or url.fragment
36
+ ):
37
+ raise ValueError(
38
+ "base_url must be an HTTP(S) URL without credentials, query or fragment"
39
+ )
40
+ try:
41
+ if url.port is not None and not 0 < url.port <= 65535:
42
+ raise ValueError("Invalid port")
43
+ except ValueError as exc:
44
+ raise ValueError("base_url has an invalid port") from exc
45
+ if any(segment in {".", ".."} for segment in unquote(url.path).split("/")):
46
+ raise ValueError("base_url must not contain path traversal")
47
+ try:
48
+ resolve_url(value, "")
49
+ except ProtocolError:
50
+ raise ValueError("base_url contains an unsafe path") from None
51
+ return value.rstrip("/") + "/"
52
+
53
+
54
+ def validate_destination(base_url: str, destination: str) -> None:
55
+ base = urlsplit(base_url)
56
+ url = urlsplit(destination)
57
+ try:
58
+ same_origin = (
59
+ url.scheme.lower(),
60
+ url.hostname,
61
+ url.port or (443 if url.scheme == "https" else 80),
62
+ ) == (
63
+ base.scheme.lower(),
64
+ base.hostname,
65
+ base.port or (443 if base.scheme == "https" else 80),
66
+ )
67
+ except ValueError as exc:
68
+ raise ProtocolError("Invalid destination URL") from exc
69
+ if (
70
+ not same_origin
71
+ or url.username is not None
72
+ or url.password is not None
73
+ or url.fragment
74
+ or not url.path.startswith(base.path)
75
+ ):
76
+ raise ProtocolError("Request destination is outside the configured API")
77
+
78
+
79
+ def _request(
80
+ spec: RequestSpec, timeout: float, options: RequestOptions | None
81
+ ) -> httpx.Request:
82
+ effective = (
83
+ timeout if options is None or options.timeout is None else options.timeout
84
+ )
85
+ finite_timeout(effective)
86
+ # HTTPX params groups duplicate names, which changes Rack's row boundaries
87
+ # for arrays of objects. Preserve the protocol core's complete pair order.
88
+ query = urlencode(spec.query).encode("ascii")
89
+ url = httpx.URL(spec.url).copy_with(query=query or None)
90
+ return httpx.Request(
91
+ spec.method,
92
+ url,
93
+ headers=spec.headers,
94
+ content=spec.content,
95
+ extensions={"timeout": httpx.Timeout(effective).as_dict()},
96
+ )
97
+
98
+
99
+ class SyncTransport:
100
+ def __init__(
101
+ self, *, timeout: float, http_client: httpx.Client | None = None
102
+ ) -> None:
103
+ self.timeout = finite_timeout(timeout)
104
+ self.owned = http_client is None
105
+ self.client = (
106
+ http_client if http_client is not None else httpx.Client(timeout=timeout)
107
+ )
108
+
109
+ def send(
110
+ self, spec: RequestSpec, *, options: RequestOptions | None = None
111
+ ) -> WireResponse:
112
+ try:
113
+ response = self.client.send(
114
+ _request(spec, self.timeout, options), auth=None, follow_redirects=False
115
+ )
116
+ except httpx.HTTPError:
117
+ # HTTPX exception messages can include request URLs and credentials.
118
+ raise TransportError("HTTP request failed") from None
119
+ return WireResponse(
120
+ response.status_code, dict(response.headers), response.content
121
+ )
122
+
123
+ def close(self) -> None:
124
+ if self.owned:
125
+ self.client.close()
126
+
127
+
128
+ class AsyncTransport:
129
+ def __init__(
130
+ self, *, timeout: float, http_client: httpx.AsyncClient | None = None
131
+ ) -> None:
132
+ self.timeout = finite_timeout(timeout)
133
+ self.owned = http_client is None
134
+ self.client = (
135
+ http_client
136
+ if http_client is not None
137
+ else httpx.AsyncClient(timeout=timeout)
138
+ )
139
+
140
+ async def send(
141
+ self, spec: RequestSpec, *, options: RequestOptions | None = None
142
+ ) -> WireResponse:
143
+ try:
144
+ response = await self.client.send(
145
+ _request(spec, self.timeout, options), auth=None, follow_redirects=False
146
+ )
147
+ except httpx.HTTPError:
148
+ raise TransportError("HTTP request failed") from None
149
+ return WireResponse(
150
+ response.status_code, dict(response.headers), response.content
151
+ )
152
+
153
+ async def aclose(self) -> None:
154
+ if self.owned:
155
+ await self.client.aclose()