offering-protocol 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 (80) hide show
  1. offering_protocol/__init__.py +7 -0
  2. offering_protocol/agent/__init__.py +66 -0
  3. offering_protocol/agent/agent.py +135 -0
  4. offering_protocol/agent/cache.py +51 -0
  5. offering_protocol/agent/capabilities.py +262 -0
  6. offering_protocol/agent/client.py +667 -0
  7. offering_protocol/agent/details.py +227 -0
  8. offering_protocol/agent/schema.py +129 -0
  9. offering_protocol/core/__init__.py +64 -0
  10. offering_protocol/core/models.py +475 -0
  11. offering_protocol/core/references.py +135 -0
  12. offering_protocol/core/schemas/action-relation.schema.json +9 -0
  13. offering_protocol/core/schemas/action-request.schema.json +18 -0
  14. offering_protocol/core/schemas/action.schema.json +56 -0
  15. offering_protocol/core/schemas/attribute-schema-reference.schema.json +6 -0
  16. offering_protocol/core/schemas/authentication-requirement.schema.json +11 -0
  17. offering_protocol/core/schemas/capability-identifier.schema.json +9 -0
  18. offering_protocol/core/schemas/capability-link.schema.json +14 -0
  19. offering_protocol/core/schemas/collection-search-request.schema.json +45 -0
  20. offering_protocol/core/schemas/collection.schema.json +72 -0
  21. offering_protocol/core/schemas/detail-fields.schema.json +16 -0
  22. offering_protocol/core/schemas/enrollment-protocol.schema.json +15 -0
  23. offering_protocol/core/schemas/filter-capability-source.schema.json +41 -0
  24. offering_protocol/core/schemas/filter-definition-page.schema.json +25 -0
  25. offering_protocol/core/schemas/filter-definition.schema.json +63 -0
  26. offering_protocol/core/schemas/filter-expression.schema.json +45 -0
  27. offering_protocol/core/schemas/filter-operator.schema.json +14 -0
  28. offering_protocol/core/schemas/filter-type.schema.json +14 -0
  29. offering_protocol/core/schemas/filter-unit.schema.json +45 -0
  30. offering_protocol/core/schemas/http-action-target.schema.json +36 -0
  31. offering_protocol/core/schemas/invalid-parameter.schema.json +55 -0
  32. offering_protocol/core/schemas/local-resource-identifier-list.schema.json +10 -0
  33. offering_protocol/core/schemas/local-resource-identifier.schema.json +10 -0
  34. offering_protocol/core/schemas/mcp-endpoint.schema.json +29 -0
  35. offering_protocol/core/schemas/offering-search-request.schema.json +68 -0
  36. offering_protocol/core/schemas/offering-search-response.schema.json +20 -0
  37. offering_protocol/core/schemas/offering.schema.json +92 -0
  38. offering_protocol/core/schemas/openapi-action-target.schema.json +20 -0
  39. offering_protocol/core/schemas/operation-descriptor.schema.json +27 -0
  40. offering_protocol/core/schemas/page-envelope.schema.json +25 -0
  41. offering_protocol/core/schemas/page-limit.schema.json +8 -0
  42. offering_protocol/core/schemas/payment-option.schema.json +24 -0
  43. offering_protocol/core/schemas/payment-protocol.schema.json +34 -0
  44. offering_protocol/core/schemas/price-preview.schema.json +133 -0
  45. offering_protocol/core/schemas/problem-code.schema.json +9 -0
  46. offering_protocol/core/schemas/problem-details.schema.json +66 -0
  47. offering_protocol/core/schemas/protocol-version.schema.json +8 -0
  48. offering_protocol/core/schemas/refinement-bucket.schema.json +28 -0
  49. offering_protocol/core/schemas/refinement-group.schema.json +24 -0
  50. offering_protocol/core/schemas/representation.schema.json +11 -0
  51. offering_protocol/core/schemas/resource-identity.schema.json +27 -0
  52. offering_protocol/core/schemas/resource-image.schema.json +40 -0
  53. offering_protocol/core/schemas/resource-reference.schema.json +19 -0
  54. offering_protocol/core/schemas/schema-reference.schema.json +15 -0
  55. offering_protocol/core/schemas/search-capabilities.schema.json +26 -0
  56. offering_protocol/core/schemas/service-branding-image.schema.json +23 -0
  57. offering_protocol/core/schemas/service-branding.schema.json +19 -0
  58. offering_protocol/core/schemas/service-document.schema.json +337 -0
  59. offering_protocol/core/schemas/service-openapi.schema.json +15 -0
  60. offering_protocol/core/schemas/service-origin.schema.json +9 -0
  61. offering_protocol/core/schemas/service-protocols.schema.json +101 -0
  62. offering_protocol/core/schemas/sort-capability-source.schema.json +41 -0
  63. offering_protocol/core/schemas/sort-definition-page.schema.json +25 -0
  64. offering_protocol/core/schemas/sort-definition.schema.json +35 -0
  65. offering_protocol/core/schemas/sort-key.schema.json +28 -0
  66. offering_protocol/core/schemas/top-level-document.schema.json +16 -0
  67. offering_protocol/core/schemas/trust-protocol.schema.json +15 -0
  68. offering_protocol/core/validation.py +390 -0
  69. offering_protocol/directory/__init__.py +51 -0
  70. offering_protocol/directory/client.py +206 -0
  71. offering_protocol/directory/models.py +103 -0
  72. offering_protocol/directory/transport.py +145 -0
  73. offering_protocol/py.typed +1 -0
  74. offering_protocol/service/__init__.py +32 -0
  75. offering_protocol/service/service.py +444 -0
  76. offering_protocol/service/static_catalog.py +258 -0
  77. offering_protocol-0.1.0.dist-info/METADATA +362 -0
  78. offering_protocol-0.1.0.dist-info/RECORD +80 -0
  79. offering_protocol-0.1.0.dist-info/WHEEL +4 -0
  80. offering_protocol-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,667 @@
1
+ """Agent-side ODP Service client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, replace
9
+ from datetime import datetime, timedelta
10
+ from email.utils import parsedate_to_datetime
11
+ from enum import StrEnum
12
+ from typing import TYPE_CHECKING, cast
13
+ from urllib.parse import parse_qsl, urlencode, urljoin, urlsplit, urlunsplit
14
+
15
+ from offering_protocol.agent.cache import Cache, CacheFallbacks, CacheRecord, MemoryCache, utc_now
16
+ from offering_protocol.core import (
17
+ Collection,
18
+ CollectionSearchRequest,
19
+ Offering,
20
+ OfferingPage,
21
+ OfferingSearchRequest,
22
+ Operation,
23
+ Page,
24
+ Representation,
25
+ ServiceDocument,
26
+ build_operation_url,
27
+ derive_service_origin,
28
+ parse_collection,
29
+ parse_collection_page,
30
+ parse_offering,
31
+ parse_offering_page,
32
+ parse_problem_response,
33
+ parse_service_document,
34
+ resolve_continuation,
35
+ )
36
+ from offering_protocol.directory.transport import (
37
+ HttpRequest,
38
+ HttpResponse,
39
+ HttpxTransport,
40
+ Transport,
41
+ TransportError,
42
+ )
43
+
44
+ if TYPE_CHECKING:
45
+ from offering_protocol.agent.capabilities import SearchCapabilityCatalog
46
+ from offering_protocol.agent.details import OfferingDetails, ResolvedAction
47
+
48
+ MEDIA_TYPE = "application/odp+json"
49
+ _MAXIMUM_DOCUMENT_BYTES = 65_536
50
+ _MAXIMUM_RESOURCE_BYTES = 524_288
51
+ _MAXIMUM_REDIRECTS = 5
52
+
53
+
54
+ class Freshness(StrEnum):
55
+ FETCHED = "fetched"
56
+ FRESH = "fresh"
57
+ REVALIDATED = "revalidated"
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Inspection:
62
+ document: ServiceDocument
63
+ final_url: str
64
+ freshness: Freshness
65
+ requested_url: str
66
+ service_origin: str
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class TraversalOptions:
71
+ max_items: int = 10_000
72
+ max_pages: int = 16
73
+
74
+
75
+ class AgentError(RuntimeError):
76
+ """Base error for Service discovery operations."""
77
+
78
+
79
+ class UnsupportedOperationError(AgentError):
80
+ def __init__(self, operation: Operation) -> None:
81
+ super().__init__(f"ODP Service does not advertise {operation.value}")
82
+ self.operation = operation
83
+
84
+
85
+ class ServiceRequestError(AgentError):
86
+ def __init__(self, status: int, message: str, headers: dict[str, str]) -> None:
87
+ super().__init__(f"ODP request failed with HTTP {status}: {message}")
88
+ self.status = status
89
+ self.headers = headers
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class _FetchedResponse:
94
+ body: bytes
95
+ final_url: str
96
+ freshness: Freshness
97
+
98
+
99
+ class ServiceClient:
100
+ def __init__(
101
+ self,
102
+ service_url: str,
103
+ *,
104
+ accept_language: str | None = None,
105
+ allow_local_network: bool = False,
106
+ cache: Cache | None = None,
107
+ cache_fallbacks: CacheFallbacks | None = None,
108
+ cache_partition: str = "anonymous",
109
+ supporting_transport: Transport | None = None,
110
+ transport: Transport | None = None,
111
+ ) -> None:
112
+ self.service_origin = derive_service_origin(service_url)
113
+ self._accept_language = accept_language
114
+ self._cache = cache or MemoryCache()
115
+ self._cache_fallbacks = cache_fallbacks or CacheFallbacks()
116
+ self._cache_partition = cache_partition
117
+ self._owns_transport = transport is None
118
+ self._transport = transport or HttpxTransport(allow_local_network=allow_local_network)
119
+ self._supporting_transport = supporting_transport or self._transport
120
+
121
+ async def __aenter__(self) -> ServiceClient:
122
+ return self
123
+
124
+ async def __aexit__(self, *args: object) -> None:
125
+ await self.aclose()
126
+
127
+ async def aclose(self) -> None:
128
+ if self._owns_transport:
129
+ await self._transport.aclose()
130
+
131
+ async def inspect(self) -> Inspection:
132
+ requested_url = f"{self.service_origin}/.well-known/odp"
133
+ response = await self._request_cached(
134
+ "GET",
135
+ requested_url,
136
+ b"",
137
+ _MAXIMUM_DOCUMENT_BYTES,
138
+ self._cache_fallbacks.service_document,
139
+ parse_service_document,
140
+ )
141
+ return Inspection(
142
+ document=parse_service_document(response.body),
143
+ final_url=response.final_url,
144
+ freshness=response.freshness,
145
+ requested_url=requested_url,
146
+ service_origin=self.service_origin,
147
+ )
148
+
149
+ async def get_offering_details(self, identifier: str) -> OfferingDetails:
150
+ from offering_protocol.agent.details import get_offering_details
151
+
152
+ return await get_offering_details(self, identifier)
153
+
154
+ async def resolve_action(self, offering_id: str, action_id: str) -> ResolvedAction:
155
+ from offering_protocol.agent.details import resolve_action
156
+
157
+ return await resolve_action(self, offering_id, action_id)
158
+
159
+ async def get_collection_search_capabilities(self, identifier: str) -> SearchCapabilityCatalog:
160
+ from offering_protocol.agent.capabilities import get_collection_search_capabilities
161
+
162
+ return await get_collection_search_capabilities(self, identifier)
163
+
164
+ async def get_offering_search_capabilities(
165
+ self, collection_id: str | None = None
166
+ ) -> SearchCapabilityCatalog:
167
+ from offering_protocol.agent.capabilities import get_offering_search_capabilities
168
+
169
+ return await get_offering_search_capabilities(self, collection_id)
170
+
171
+ async def list_collections(
172
+ self, representation: Representation = Representation.TERSE, limit: int = 0
173
+ ) -> Page[Collection]:
174
+ body = await self._get_page(Operation.LIST_COLLECTIONS, None, representation, limit)
175
+ page = parse_collection_page(body)
176
+ for item in page.items:
177
+ parse_collection(_encode(item))
178
+ return page
179
+
180
+ async def get_collection(self, identifier: str) -> Collection:
181
+ body = await self._get_page(Operation.GET_COLLECTION, identifier, Representation.FULL, 0)
182
+ return parse_collection(body)
183
+
184
+ async def search_collections(
185
+ self,
186
+ request: CollectionSearchRequest,
187
+ representation: Representation = Representation.TERSE,
188
+ ) -> Page[Collection]:
189
+ body = await self._post_search(
190
+ Operation.SEARCH_COLLECTIONS, request.to_dict(), representation
191
+ )
192
+ page = parse_collection_page(body)
193
+ for item in page.items:
194
+ parse_collection(_encode(item))
195
+ return page
196
+
197
+ async def list_offerings(
198
+ self, representation: Representation = Representation.TERSE, limit: int = 0
199
+ ) -> OfferingPage[Offering]:
200
+ body = await self._get_page(Operation.LIST_OFFERINGS, None, representation, limit)
201
+ return parse_offering_page(body)
202
+
203
+ async def list_collection_offerings(
204
+ self,
205
+ collection_id: str,
206
+ representation: Representation = Representation.TERSE,
207
+ limit: int = 0,
208
+ ) -> OfferingPage[Offering]:
209
+ body = await self._get_page(
210
+ Operation.LIST_COLLECTION_OFFERINGS, collection_id, representation, limit
211
+ )
212
+ return parse_offering_page(body)
213
+
214
+ async def get_offering(self, identifier: str) -> Offering:
215
+ body = await self._get_page(Operation.GET_OFFERING, identifier, Representation.FULL, 0)
216
+ return parse_offering(body)
217
+
218
+ async def search_offerings(
219
+ self,
220
+ request: OfferingSearchRequest,
221
+ representation: Representation = Representation.TERSE,
222
+ ) -> OfferingPage[Offering]:
223
+ body = await self._post_search(
224
+ Operation.SEARCH_OFFERINGS, request.to_dict(), representation
225
+ )
226
+ return parse_offering_page(body)
227
+
228
+ async def continue_collections(self, next_reference: str) -> Page[Collection]:
229
+ target = resolve_continuation(next_reference, self.service_origin)
230
+ response = await self._request_cached(
231
+ "GET",
232
+ target,
233
+ b"",
234
+ _MAXIMUM_RESOURCE_BYTES,
235
+ self._cache_fallbacks.collection,
236
+ parse_collection_page,
237
+ )
238
+ return parse_collection_page(response.body)
239
+
240
+ async def continue_offerings(self, next_reference: str) -> OfferingPage[Offering]:
241
+ target = resolve_continuation(next_reference, self.service_origin)
242
+ response = await self._request_cached(
243
+ "GET",
244
+ target,
245
+ b"",
246
+ _MAXIMUM_RESOURCE_BYTES,
247
+ self._cache_fallbacks.offering,
248
+ parse_offering_page,
249
+ )
250
+ return parse_offering_page(response.body)
251
+
252
+ async def list_all_collections(
253
+ self,
254
+ representation: Representation = Representation.TERSE,
255
+ limit: int = 0,
256
+ options: TraversalOptions | None = None,
257
+ ) -> list[Collection]:
258
+ maximum_items, maximum_pages = _traversal_bounds(options or TraversalOptions())
259
+ page = await self.list_collections(representation, limit)
260
+ result: list[Collection] = []
261
+ for page_number in range(maximum_pages):
262
+ result.extend(page.items[: maximum_items - len(result)])
263
+ if len(result) == maximum_items or not page.next:
264
+ break
265
+ if page_number + 1 < maximum_pages:
266
+ page = await self.continue_collections(page.next)
267
+ return result
268
+
269
+ async def list_all_offerings(
270
+ self,
271
+ representation: Representation = Representation.TERSE,
272
+ limit: int = 0,
273
+ options: TraversalOptions | None = None,
274
+ ) -> list[Offering]:
275
+ resolved_options = options or TraversalOptions()
276
+ _traversal_bounds(resolved_options)
277
+ page = await self.list_offerings(representation, limit)
278
+ return await self._collect_offerings(page, resolved_options)
279
+
280
+ async def search_all_offerings(
281
+ self,
282
+ request: OfferingSearchRequest,
283
+ representation: Representation = Representation.TERSE,
284
+ options: TraversalOptions | None = None,
285
+ ) -> list[Offering]:
286
+ resolved_options = options or TraversalOptions()
287
+ _traversal_bounds(resolved_options)
288
+ page = await self.search_offerings(request, representation)
289
+ return await self._collect_offerings(page, resolved_options)
290
+
291
+ async def _collect_offerings(
292
+ self, page: OfferingPage[Offering], options: TraversalOptions
293
+ ) -> list[Offering]:
294
+ result: list[Offering] = []
295
+ maximum_items, maximum_pages = _traversal_bounds(options)
296
+ for page_number in range(maximum_pages):
297
+ result.extend(page.items[: maximum_items - len(result)])
298
+ if len(result) == maximum_items or not page.next:
299
+ break
300
+ if page_number + 1 < maximum_pages:
301
+ page = await self.continue_offerings(page.next)
302
+ return result
303
+
304
+ async def _get_page(
305
+ self,
306
+ operation: Operation,
307
+ identifier: str | None,
308
+ representation: Representation,
309
+ limit: int,
310
+ ) -> bytes:
311
+ inspection = await self._require_operation(operation)
312
+ target = build_operation_url(
313
+ inspection.document.http.endpoint_base,
314
+ operation,
315
+ self.service_origin,
316
+ identifier,
317
+ )
318
+ query = {"representation": representation.value}
319
+ if limit:
320
+ query["limit"] = str(limit)
321
+ target = _append_query(target, query)
322
+ fallback = (
323
+ self._cache_fallbacks.collection
324
+ if operation
325
+ in {
326
+ Operation.GET_COLLECTION,
327
+ Operation.LIST_COLLECTIONS,
328
+ Operation.SEARCH_COLLECTIONS,
329
+ }
330
+ else self._cache_fallbacks.offering
331
+ )
332
+ parser = _operation_parser(operation)
333
+ response = await self._request_cached(
334
+ "GET", target, b"", _MAXIMUM_RESOURCE_BYTES, fallback, parser
335
+ )
336
+ return response.body
337
+
338
+ async def _post_search(
339
+ self, operation: Operation, value: Mapping[str, object], representation: Representation
340
+ ) -> bytes:
341
+ inspection = await self._require_operation(operation)
342
+ target = build_operation_url(
343
+ inspection.document.http.endpoint_base, operation, self.service_origin, None
344
+ )
345
+ target = _append_query(target, {"representation": representation.value})
346
+ fallback = (
347
+ self._cache_fallbacks.collection
348
+ if operation is Operation.SEARCH_COLLECTIONS
349
+ else self._cache_fallbacks.offering
350
+ )
351
+ response = await self._request_cached(
352
+ "POST",
353
+ target,
354
+ json.dumps(value, separators=(",", ":")).encode(),
355
+ _MAXIMUM_RESOURCE_BYTES,
356
+ fallback,
357
+ _operation_parser(operation),
358
+ )
359
+ return response.body
360
+
361
+ async def _require_operation(self, operation: Operation) -> Inspection:
362
+ inspection = await self.inspect()
363
+ if not any(item.name is operation for item in inspection.document.operations):
364
+ raise UnsupportedOperationError(operation)
365
+ return inspection
366
+
367
+ async def _request_cached(
368
+ self,
369
+ method: str,
370
+ target: str,
371
+ body: bytes,
372
+ maximum_bytes: int,
373
+ fallback: timedelta,
374
+ parser: object,
375
+ ) -> _FetchedResponse:
376
+ key = self._cache_key(method, target, body)
377
+ cached = self._cache.get(key)
378
+ now = utc_now()
379
+ if cached is not None and now < cached.expires:
380
+ return _FetchedResponse(cached.body, cached.final_url, Freshness.FRESH)
381
+ headers: dict[str, str] = {}
382
+ request_target = target
383
+ if cached is not None:
384
+ if derive_service_origin(cached.final_url) == derive_service_origin(target):
385
+ request_target = cached.final_url
386
+ if cached.etag:
387
+ headers["if-none-match"] = cached.etag
388
+ if cached.last_modified:
389
+ headers["if-modified-since"] = cached.last_modified
390
+ response, final_url = await self._request_raw(method, request_target, body, headers)
391
+ if response.status == 304:
392
+ if cached is None:
393
+ raise AgentError("ODP response returned 304 without a cached representation")
394
+ if _no_store(response.headers):
395
+ self._cache.delete(key)
396
+ return _FetchedResponse(cached.body, cached.final_url, Freshness.REVALIDATED)
397
+ lifetime = cached.expires - cached.stored
398
+ expires = (
399
+ _expiration(response.headers, fallback, now)
400
+ if _has_freshness(response.headers)
401
+ else now + max(lifetime, timedelta())
402
+ )
403
+ record = replace(cached, expires=expires, final_url=final_url, stored=now)
404
+ self._cache.set(key, record)
405
+ return _FetchedResponse(record.body, record.final_url, Freshness.REVALIDATED)
406
+ response = _consume(response, maximum_bytes)
407
+ _invoke_parser(parser, response.body)
408
+ if _cacheable(method, response.headers, fallback):
409
+ self._cache.set(
410
+ key,
411
+ CacheRecord(
412
+ body=response.body,
413
+ etag=response.headers.get("etag"),
414
+ expires=_expiration(response.headers, fallback, now),
415
+ final_url=final_url,
416
+ last_modified=response.headers.get("last-modified"),
417
+ status=response.status,
418
+ stored=now,
419
+ ),
420
+ )
421
+ else:
422
+ self._cache.delete(key)
423
+ return _FetchedResponse(response.body, final_url, Freshness.FETCHED)
424
+
425
+ async def _request_raw(
426
+ self, method: str, target: str, body: bytes, conditional: dict[str, str]
427
+ ) -> tuple[HttpResponse, str]:
428
+ redirect_origin = derive_service_origin(target)
429
+ for redirects in range(_MAXIMUM_REDIRECTS + 1):
430
+ headers = {"accept": MEDIA_TYPE, **conditional}
431
+ if self._accept_language:
432
+ headers["accept-language"] = self._accept_language
433
+ if body:
434
+ headers["content-type"] = MEDIA_TYPE
435
+ try:
436
+ response = await self._transport.send(HttpRequest(method, target, headers, body))
437
+ except TransportError as error:
438
+ raise AgentError(f"ODP Service request failed: {error}") from error
439
+ if response.status not in {301, 302, 303, 307, 308}:
440
+ return response, target
441
+ if redirects == _MAXIMUM_REDIRECTS:
442
+ raise AgentError("ODP response exceeded five redirects")
443
+ location = response.headers.get("location")
444
+ if location is None:
445
+ raise AgentError("ODP redirect omitted Location")
446
+ next_target = urljoin(target, location)
447
+ if derive_service_origin(next_target) != redirect_origin:
448
+ raise AgentError("ODP redirect changed Service origin")
449
+ if response.status == 303 or (response.status in {301, 302} and method == "POST"):
450
+ method, body = "GET", b""
451
+ target = next_target
452
+ raise AgentError("ODP response exceeded its redirect limit") # pragma: no cover
453
+
454
+ async def _linked_odp(self, target: str, fallback: timedelta, parser: object) -> bytes:
455
+ response = await self._request_cached(
456
+ "GET", target, b"", _MAXIMUM_RESOURCE_BYTES, fallback, parser
457
+ )
458
+ return response.body
459
+
460
+ async def _supporting_json(
461
+ self,
462
+ target: str,
463
+ resource_class: str,
464
+ accept: str,
465
+ media_types: set[str],
466
+ maximum_bytes: int,
467
+ ) -> dict[str, object]:
468
+ current = target
469
+ if not _is_https_url(current):
470
+ raise AgentError("ODP supporting document URL must use HTTPS")
471
+ key = f"anonymous:{resource_class}\nGET\n{target}\n{accept}"
472
+ cached = self._cache.get(key)
473
+ now = utc_now()
474
+ if cached is not None and now < cached.expires:
475
+ return _decode_json_object(cached.body)
476
+ conditional: dict[str, str] = {}
477
+ if cached is not None:
478
+ if cached.etag:
479
+ conditional["if-none-match"] = cached.etag
480
+ if cached.last_modified:
481
+ conditional["if-modified-since"] = cached.last_modified
482
+ for redirects in range(_MAXIMUM_REDIRECTS + 1):
483
+ try:
484
+ response = await self._supporting_transport.send(
485
+ HttpRequest("GET", current, {"accept": accept, **conditional})
486
+ )
487
+ except TransportError as error:
488
+ raise AgentError(f"ODP supporting document request failed: {error}") from error
489
+ if response.status in {301, 302, 303, 307, 308}:
490
+ if redirects == _MAXIMUM_REDIRECTS:
491
+ raise AgentError("ODP supporting document exceeded five redirects")
492
+ location = response.headers.get("location")
493
+ if location is None:
494
+ raise AgentError("ODP supporting document redirect omitted Location")
495
+ current = urljoin(current, location)
496
+ if not _is_https_url(current):
497
+ raise AgentError("ODP supporting document redirect must use HTTPS")
498
+ continue
499
+ if response.status == 304:
500
+ if cached is None:
501
+ raise AgentError(
502
+ "ODP supporting document returned 304 without a cached representation"
503
+ )
504
+ if _no_store(response.headers):
505
+ self._cache.delete(key)
506
+ else:
507
+ lifetime = cached.expires - cached.stored
508
+ expires = (
509
+ _expiration(response.headers, timedelta(), now)
510
+ if _has_freshness(response.headers)
511
+ else now + max(lifetime, timedelta())
512
+ )
513
+ self._cache.set(
514
+ key,
515
+ replace(cached, expires=expires, final_url=current, stored=now),
516
+ )
517
+ return _decode_json_object(cached.body)
518
+ if not 200 <= response.status < 300:
519
+ raise ServiceRequestError(
520
+ response.status,
521
+ f"ODP supporting document returned HTTP {response.status}",
522
+ response.headers,
523
+ )
524
+ if len(response.body) > maximum_bytes:
525
+ raise AgentError("ODP supporting document exceeds its byte limit")
526
+ content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
527
+ if content_type not in media_types:
528
+ raise AgentError("ODP supporting document has an unsupported media type")
529
+ value = _decode_json_object(response.body)
530
+ if _cacheable("GET", response.headers, timedelta()):
531
+ self._cache.set(
532
+ key,
533
+ CacheRecord(
534
+ body=response.body,
535
+ etag=response.headers.get("etag"),
536
+ expires=_expiration(response.headers, timedelta(), now),
537
+ final_url=current,
538
+ last_modified=response.headers.get("last-modified"),
539
+ status=response.status,
540
+ stored=now,
541
+ ),
542
+ )
543
+ else:
544
+ self._cache.delete(key)
545
+ return value
546
+ raise AgentError("ODP supporting document exceeded its redirect limit") # pragma: no cover
547
+
548
+ def _cache_key(self, method: str, target: str, body: bytes) -> str:
549
+ digest = hashlib.sha256(body).hexdigest()
550
+ return "\n".join(
551
+ (self._cache_partition, method, target, self._accept_language or "", digest)
552
+ )
553
+
554
+
555
+ def _invoke_parser(parser: object, body: bytes) -> None:
556
+ if not callable(parser):
557
+ raise TypeError("ODP parser is not callable")
558
+ try:
559
+ parser(body)
560
+ except ValueError as error:
561
+ raise AgentError(str(error)) from error
562
+
563
+
564
+ def _operation_parser(operation: Operation) -> object:
565
+ if operation is Operation.GET_COLLECTION:
566
+ return parse_collection
567
+ if operation is Operation.GET_OFFERING:
568
+ return parse_offering
569
+ if operation in {Operation.LIST_COLLECTIONS, Operation.SEARCH_COLLECTIONS}:
570
+ return parse_collection_page
571
+ return parse_offering_page
572
+
573
+
574
+ def _encode(value: object) -> bytes:
575
+ if not hasattr(value, "model_dump_json"):
576
+ raise TypeError("ODP model is not serializable")
577
+ encoded = value.model_dump_json(by_alias=True, exclude_unset=True)
578
+ return cast(str, encoded).encode()
579
+
580
+
581
+ def _append_query(target: str, values: dict[str, str]) -> str:
582
+ parts = urlsplit(target)
583
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
584
+ query.update(values)
585
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
586
+
587
+
588
+ def _is_https_url(value: str) -> bool:
589
+ parsed = urlsplit(value)
590
+ return parsed.scheme == "https" and parsed.hostname is not None
591
+
592
+
593
+ def _decode_json_object(data: bytes) -> dict[str, object]:
594
+ try:
595
+ value = json.loads(data)
596
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
597
+ raise AgentError(f"ODP supporting document is invalid JSON: {error}") from error
598
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
599
+ raise AgentError("ODP supporting document must be a JSON object")
600
+ return cast(dict[str, object], value)
601
+
602
+
603
+ def _consume(response: HttpResponse, maximum_bytes: int) -> HttpResponse:
604
+ if len(response.body) > maximum_bytes:
605
+ raise AgentError("ODP response exceeds its byte limit")
606
+ if not 200 <= response.status < 300:
607
+ try:
608
+ problem = parse_problem_response(response.body, response.status)
609
+ message = problem.detail or problem.title
610
+ except ValueError:
611
+ message = response.body.decode(errors="replace")
612
+ raise ServiceRequestError(response.status, message, response.headers)
613
+ content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
614
+ if content_type != MEDIA_TYPE:
615
+ raise AgentError(f"ODP response must use {MEDIA_TYPE}")
616
+ return response
617
+
618
+
619
+ def _traversal_bounds(options: TraversalOptions) -> tuple[int, int]:
620
+ if not 1 <= options.max_items <= 10_000 or not 1 <= options.max_pages <= 16:
621
+ raise AgentError("traversal exceeds 10000 items or 16 pages")
622
+ return options.max_items, options.max_pages
623
+
624
+
625
+ def _cache_directives(headers: dict[str, str]) -> dict[str, str]:
626
+ result: dict[str, str] = {}
627
+ for value in headers.get("cache-control", "").split(","):
628
+ value = value.strip()
629
+ if value:
630
+ name, _, setting = value.partition("=")
631
+ result[name.lower()] = setting.strip('"')
632
+ return result
633
+
634
+
635
+ def _no_store(headers: dict[str, str]) -> bool:
636
+ return "no-store" in _cache_directives(headers)
637
+
638
+
639
+ def _has_freshness(headers: dict[str, str]) -> bool:
640
+ directives = _cache_directives(headers)
641
+ return bool({"max-age", "no-cache", "no-store"} & directives.keys()) or "expires" in headers
642
+
643
+
644
+ def _cacheable(method: str, headers: dict[str, str], fallback: timedelta) -> bool:
645
+ vary = {value.strip().lower() for value in headers.get("vary", "").split(",") if value.strip()}
646
+ if not vary <= {"accept", "accept-language", "content-type"} or _no_store(headers):
647
+ return False
648
+ directives = _cache_directives(headers)
649
+ explicit = "max-age" in directives or "expires" in headers
650
+ return (method == "GET" and (fallback > timedelta() or "no-cache" in directives)) or explicit
651
+
652
+
653
+ def _expiration(headers: dict[str, str], fallback: timedelta, now: datetime) -> datetime:
654
+ directives = _cache_directives(headers)
655
+ if "no-cache" in directives:
656
+ return now
657
+ try:
658
+ duration = max(0, int(directives["max-age"]) - int(headers.get("age", "0")))
659
+ return now + timedelta(seconds=duration)
660
+ except (KeyError, ValueError):
661
+ pass
662
+ if "expires" in headers:
663
+ try:
664
+ return parsedate_to_datetime(headers["expires"])
665
+ except (TypeError, ValueError):
666
+ pass
667
+ return now + fallback