booking-agent-cli 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 (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,6 @@
1
+ """booking_agent: USP protocol models, profile discovery, REST client, and backends.
2
+
3
+ Ships in the booking-agent-cli wheel and never imports ``booking_agent_cli``.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,450 @@
1
+ """``AuthorizationPolicy`` parsing and mechanism selection (USP Section 10.1.6).
2
+
3
+ USP splits operations into two tiers. *Public* operations -- catalog,
4
+ availability, profile discovery -- MAY remain anonymous. *Privileged*
5
+ operations -- creating, updating, confirming, cancelling or rescheduling a
6
+ booking, holds, waitlist mutations, payment-adjacent completion, registry
7
+ writes, and any response carrying buyer personal data -- MUST be authenticated.
8
+ A resource identifier is not a credential.
9
+
10
+ The *mechanism* is business-declared: the business publishes an
11
+ ``AuthorizationPolicy`` and the platform selects one it supports. Two rules
12
+ make that safe, and both are easy to get backwards:
13
+
14
+ * Unrecognized mechanisms are **ignored, never treated as accepted**. A client
15
+ that shrugged and continued anonymously against a policy it did not
16
+ understand would defeat the whole tier split.
17
+ * Where the policy is missing or nothing is mutually supported, the client
18
+ **fails closed** rather than falling back to anonymous.
19
+
20
+ Placement differs by deployment mode:
21
+
22
+ * **Standalone** -- top-level ``authorization`` in ``/.well-known/usp``.
23
+ * **UCP-Native** -- ``config.authorization`` on the ``dev.usp-protocol.services``
24
+ binding. A binding carrying ``authorization`` as a *direct member* is
25
+ malformed: ``schemas/usp.json`` gives it a never-satisfiable schema so that it
26
+ is rejected rather than silently accepted, which would let a business believe
27
+ it had published a policy no conforming consumer reads. We therefore treat
28
+ that shape as *no policy published* and fail closed -- we do not read it.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import re
34
+ from datetime import datetime, timezone
35
+ from enum import Enum
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ from pydantic import BaseModel, ConfigDict, field_validator
39
+
40
+ from . import settings
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: USP Section 10.1.6 mechanism identifiers this client can actually satisfy.
45
+ #: ``platform_key_pop`` is the only one implemented; the rest are recognized so
46
+ #: that diagnostics can say *why* a business is unreachable.
47
+ MECHANISM_PLATFORM_KEY_POP = "platform_key_pop"
48
+ MECHANISM_BOOKING_SCOPED_CREDENTIAL = "booking_scoped_credential"
49
+ MECHANISM_OAUTH2_BEARER = "oauth2_bearer"
50
+ MECHANISM_API_KEY = "api_key"
51
+ MECHANISM_HTTP_MESSAGE_SIGNATURE = "http_message_signature"
52
+ MECHANISM_MTLS = "mtls"
53
+
54
+ #: Ordered by preference. Only the first is implemented in this version;
55
+ #: ``oauth2_bearer`` support is dormant (see ``oauth_dormant``) and gated on #199.
56
+ SUPPORTED_MECHANISMS: tuple[str, ...] = (MECHANISM_PLATFORM_KEY_POP,)
57
+
58
+
59
+ class AuthorizationError(Exception):
60
+ """Base class for fail-closed authorization outcomes."""
61
+
62
+
63
+ #: USP Section 10.1.1 error codes relating to proofs. Note the ``pop_`` prefix: an
64
+ #: earlier draft of the client checklist called the replay code ``proof_replayed``,
65
+ #: which matches nothing on the wire.
66
+ ERR_POP_PROOF_MISSING = "pop_proof_missing"
67
+ ERR_POP_PROOF_INVALID = "pop_proof_invalid"
68
+ ERR_POP_KEY_MISMATCH = "pop_key_mismatch"
69
+ ERR_POP_PROOF_REPLAYED = "pop_proof_replayed"
70
+ ERR_PROOF_NONCE_REQUIRED = "proof_nonce_required"
71
+
72
+ #: All 401, and all terminal for the caller except the nonce challenge, which is
73
+ #: a single-round-trip negotiation rather than a failure.
74
+ POP_ERROR_CODES = frozenset({
75
+ ERR_POP_PROOF_MISSING,
76
+ ERR_POP_PROOF_INVALID,
77
+ ERR_POP_KEY_MISMATCH,
78
+ ERR_POP_PROOF_REPLAYED,
79
+ ERR_PROOF_NONCE_REQUIRED,
80
+ })
81
+
82
+
83
+ def nonce_from_pop_error_body(payload: Dict[str, Any]) -> Optional[str]:
84
+ """Return a proof nonce from a USP error object (MCP and REST 401 fallback).
85
+
86
+ MCP has no HTTP headers, so the MCP binding carries the nonce in
87
+ ``error.data.nonce`` when ``error.code`` is ``proof_nonce_required``. REST
88
+ prefers the ``DPoP-Nonce`` header; this parser is the fallback when the
89
+ header is absent on a 401.
90
+ """
91
+ if payload.get("code") != ERR_PROOF_NONCE_REQUIRED:
92
+ return None
93
+ data = payload.get("data")
94
+ nonce = data.get("nonce") if isinstance(data, dict) else None
95
+ return nonce if isinstance(nonce, str) and nonce else None
96
+
97
+
98
+ class AuthorizationRefused(AuthorizationError):
99
+ """The client refuses to perform a privileged operation against this business."""
100
+
101
+ def __init__(self, code: str, message: str):
102
+ super().__init__(message)
103
+ self.code = code
104
+ self.message = message
105
+
106
+
107
+ class AuthorizationPolicy(BaseModel):
108
+ """``schemas/profile.json#/$defs/AuthorizationPolicy``.
109
+
110
+ Both members are REQUIRED by the schema. The object is deliberately open
111
+ (``additionalProperties: true``) -- accepted proof algorithms and similar
112
+ extensions are advertised through it -- so unknown members are preserved
113
+ rather than dropped.
114
+ """
115
+
116
+ model_config = ConfigDict(extra="allow")
117
+
118
+ privileged_operations_require_authentication: bool
119
+ accepted_mechanisms: List[str]
120
+ token_endpoint: Optional[str] = None
121
+
122
+ def select_mechanism(self) -> Optional[str]:
123
+ """Return the best mutually supported mechanism, or None.
124
+
125
+ Unrecognized identifiers in ``accepted_mechanisms`` are skipped, never
126
+ assumed acceptable.
127
+ """
128
+ for candidate in SUPPORTED_MECHANISMS:
129
+ if candidate in self.accepted_mechanisms:
130
+ return candidate
131
+ return None
132
+
133
+
134
+ def parse_policy(raw: Any) -> Optional[AuthorizationPolicy]:
135
+ """Parse an ``authorization`` object, or return None when absent/malformed.
136
+
137
+ A malformed policy is reported as *absent* rather than raising, so the
138
+ caller reaches the same fail-closed path as a business that published
139
+ nothing -- the distinction matters for the log line, not for the decision.
140
+ """
141
+ if raw is None:
142
+ return None
143
+ if not isinstance(raw, dict):
144
+ logger.warning("Ignoring non-object authorization policy of type %s", type(raw).__name__)
145
+ return None
146
+ try:
147
+ return AuthorizationPolicy.model_validate(raw)
148
+ except Exception:
149
+ logger.warning("Ignoring malformed authorization policy: %r", raw, exc_info=True)
150
+ return None
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # The issued credential
155
+ # ---------------------------------------------------------------------------
156
+
157
+ class ConfirmationKey(BaseModel):
158
+ """``schemas/usp.json#/$defs/ConfirmationKey`` -- the RFC 7800 ``cnf`` claim.
159
+
160
+ Restricted to the JWK-thumbprint form; the embedded-key and key-reference
161
+ forms are not used by USP. ``extra="forbid"`` is load-bearing: ignoring an
162
+ unrecognized confirmation method would silently downgrade a
163
+ sender-constrained credential to a bearer token, so the Section 10.1.6
164
+ forward-compatibility rule deliberately does NOT extend to this object.
165
+ """
166
+
167
+ model_config = ConfigDict(extra="forbid")
168
+
169
+ jkt: str
170
+
171
+ @field_validator("jkt")
172
+ @classmethod
173
+ def _check_thumbprint(cls, v: str) -> str:
174
+ if not _JKT_RE.fullmatch(v):
175
+ raise ValueError(
176
+ "jkt must be an unpadded base64url SHA-256 thumbprint of exactly 43 characters"
177
+ )
178
+ return v
179
+
180
+
181
+ class BookingScopedCredential(BaseModel):
182
+ """``schemas/profile.json#/$defs/BookingScopedCredential``.
183
+
184
+ Returned on a creation response only, never on reads, and authorizing
185
+ continuation operations on exactly one booking or waitlist entry. Also a
186
+ **closed** object for the same reason as ``ConfirmationKey``.
187
+ """
188
+
189
+ model_config = ConfigDict(extra="forbid")
190
+
191
+ value: str
192
+ expires_at: datetime
193
+ cnf: Optional[ConfirmationKey] = None
194
+ #: USP method names this credential authorizes on its resource. Absent means
195
+ #: the full continuation set for the resource type -- never another resource.
196
+ scope: Optional[List[str]] = None
197
+
198
+ @field_validator("value")
199
+ @classmethod
200
+ def _check_value(cls, v: str) -> str:
201
+ if not v.startswith("bsc_") or len(v) < 26:
202
+ raise ValueError("credential value must start with 'bsc_' and be at least 26 characters")
203
+ return v
204
+
205
+ @property
206
+ def is_sender_constrained(self) -> bool:
207
+ return self.cnf is not None
208
+
209
+ def is_expired(self, *, now: Optional[datetime] = None) -> bool:
210
+ current = now or datetime.now(timezone.utc)
211
+ expires = self.expires_at
212
+ if expires.tzinfo is None:
213
+ expires = expires.replace(tzinfo=timezone.utc)
214
+ return expires <= current
215
+
216
+ def covers(self, usp_method: str) -> bool:
217
+ return self.scope is None or usp_method in self.scope
218
+
219
+
220
+ _JKT_RE = re.compile(r"[A-Za-z0-9_-]{43}")
221
+
222
+
223
+ class CredentialBindingError(AuthorizationError):
224
+ """The business issued a credential that is not bound to our key."""
225
+
226
+
227
+ def issued_credential_payload(data: Dict[str, Any]) -> Any:
228
+ """Locate ``booking_scoped_credential`` on a creation response.
229
+
230
+ Standalone USP returns it at the body root. UCP-Native paid bookings return
231
+ it as a sibling of ``booking`` at the checkout root, or inside the
232
+ ``dev.usp-protocol.services.paid_bookings`` extension. Creation is the only
233
+ place the value appears.
234
+ """
235
+ from .namespace import CAP_PAID_BOOKINGS
236
+
237
+ if not isinstance(data, dict):
238
+ return None
239
+ payload = data.get("booking_scoped_credential")
240
+ if payload is not None:
241
+ return payload
242
+ extension = data.get(CAP_PAID_BOOKINGS)
243
+ if isinstance(extension, dict):
244
+ return extension.get("booking_scoped_credential")
245
+ return None
246
+
247
+
248
+ def parse_issued_credential(
249
+ payload: Any, *, own_jkt: str, required: bool,
250
+ ) -> Optional[BookingScopedCredential]:
251
+ """Parse and verify a ``booking_scoped_credential`` from a creation response.
252
+
253
+ Verifies ``cnf.jkt`` against *own_jkt*: this detects a business that accepted
254
+ the proof and then issued a credential bound to something else, or to
255
+ nothing. A credential returned without ``cnf`` MUST NOT be treated as
256
+ sender-constrained -- when we authenticated with ``platform_key_pop``
257
+ (*required*), that is a protocol violation rather than a soft downgrade,
258
+ because continuing would hand a plain bearer secret to a caller that
259
+ believes it holds a bound one.
260
+ """
261
+ if payload is None:
262
+ return None
263
+ try:
264
+ credential = BookingScopedCredential.model_validate(payload)
265
+ except Exception as exc:
266
+ raise CredentialBindingError(
267
+ f"business returned a malformed booking_scoped_credential: {exc}"
268
+ ) from exc
269
+
270
+ if credential.cnf is None:
271
+ if required:
272
+ raise CredentialBindingError(
273
+ "business issued a booking_scoped_credential with no 'cnf' after a "
274
+ "platform_key_pop request; refusing to treat an unbound credential as "
275
+ "sender-constrained."
276
+ )
277
+ return credential
278
+
279
+ if credential.cnf.jkt != own_jkt:
280
+ raise CredentialBindingError(
281
+ f"business bound the credential to jkt {credential.cnf.jkt!r}, which is not "
282
+ f"this agent's key ({own_jkt!r}); the credential would be unusable."
283
+ )
284
+ return credential
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Deployment posture
289
+ # ---------------------------------------------------------------------------
290
+
291
+ class ScopedCredentialPosture(str, Enum):
292
+ """How strictly to enforce the Section 10.1.6 privileged-operation floor.
293
+
294
+ ``REQUIRED`` is both the conformant posture and the default. USP is
295
+ unambiguous that privileged operations MUST be authenticated and that a
296
+ consumer fails closed rather than falling back to anonymous, so shipping a
297
+ laxer default would be shipping a known deviation.
298
+
299
+ Under ``REQUIRED``, privileged calls to a business that publishes no
300
+ ``authorization`` policy, or declares privileged operations unauthenticated,
301
+ are refused: an unauthenticated booking is exactly what the mechanism exists
302
+ to prevent.
303
+
304
+ ``PREFERRED`` is for a deployment that chooses to transact with such a
305
+ business anyway: the same cases log a warning and proceed instead of refusing.
306
+ """
307
+
308
+ REQUIRED = "required"
309
+ PREFERRED = "preferred"
310
+
311
+
312
+ DEFAULT_POSTURE = ScopedCredentialPosture.REQUIRED
313
+
314
+ #: Precedence: explicit config -> environment -> default.
315
+ POSTURE_ENV_VAR = "BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL"
316
+
317
+
318
+ def resolve_posture(explicit: Optional[str] = None) -> ScopedCredentialPosture:
319
+ raw = explicit or settings.get(POSTURE_ENV_VAR)
320
+ if not raw:
321
+ return DEFAULT_POSTURE
322
+ try:
323
+ return ScopedCredentialPosture(raw.strip().lower())
324
+ except ValueError:
325
+ logger.warning(
326
+ "Unknown %s=%r; falling back to %s",
327
+ POSTURE_ENV_VAR, raw, DEFAULT_POSTURE.value,
328
+ )
329
+ return DEFAULT_POSTURE
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Decision
334
+ # ---------------------------------------------------------------------------
335
+
336
+ class AuthorizationDecision(BaseModel):
337
+ """What the client will do for privileged operations against one business."""
338
+
339
+ model_config = ConfigDict(frozen=True)
340
+
341
+ mechanism: Optional[str] = None
342
+ #: True when privileged requests should carry a proof.
343
+ authenticate: bool = False
344
+ #: Set when proceeding unauthenticated under a non-conformant posture.
345
+ warning: Optional[str] = None
346
+ #: Set when this business cannot be transacted with under the current
347
+ #: posture. Carried rather than raised so the refusal lands on the
348
+ #: privileged call that is actually forbidden -- see ``raise_if_refused``.
349
+ refusal_code: Optional[str] = None
350
+ refusal_message: Optional[str] = None
351
+
352
+ @property
353
+ def uses_pop(self) -> bool:
354
+ return self.mechanism == MECHANISM_PLATFORM_KEY_POP
355
+
356
+ @property
357
+ def refused(self) -> bool:
358
+ return self.refusal_code is not None
359
+
360
+ def raise_if_refused(self) -> None:
361
+ """Enforce the refusal. Call this on privileged operations only.
362
+
363
+ Public operations -- catalog, availability, profile discovery -- MAY
364
+ remain unauthenticated under USP Section 10.1.6, so a business that publishes
365
+ no usable policy is still browsable. Refusing at client construction
366
+ would block those too, which is both non-conformant and needlessly
367
+ unhelpful: the agent could not even show the user what it cannot book.
368
+ """
369
+ if self.refusal_code is not None:
370
+ raise AuthorizationRefused(self.refusal_code, self.refusal_message or "")
371
+
372
+
373
+ def evaluate(
374
+ policy: Optional[AuthorizationPolicy],
375
+ *,
376
+ posture: ScopedCredentialPosture,
377
+ exposes_privileged_capabilities: bool = True,
378
+ business_label: str = "business",
379
+ defer: bool = False,
380
+ ) -> AuthorizationDecision:
381
+ """Decide how to authenticate privileged operations against one business.
382
+
383
+ Raises ``AuthorizationRefused`` on every fail-closed path, unless *defer* is
384
+ set -- in which case the refusal is returned on the decision and enforced
385
+ later by ``AuthorizationDecision.raise_if_refused``. Callers that build a
386
+ client used for public operations too must defer, or browsing a business
387
+ would fail for want of an authorization policy it does not need.
388
+ """
389
+ if policy is None:
390
+ if not exposes_privileged_capabilities:
391
+ return AuthorizationDecision(mechanism=None, authenticate=False)
392
+ return _refuse_or_warn(
393
+ posture,
394
+ defer=defer,
395
+ code="authorization_policy_absent",
396
+ message=(
397
+ f"{business_label} exposes privileged capabilities but publishes no "
398
+ f"authorization policy, so there is no mechanism to negotiate."
399
+ ),
400
+ )
401
+
402
+ if not policy.privileged_operations_require_authentication:
403
+ # Section 10.1.6: publishing false declares the deployment OUT OF CONFORMANCE
404
+ # for privileged operations. The platform MUST refuse to transact real
405
+ # bookings or transmit buyer personal data -- refuse, not warn and continue.
406
+ return _refuse_or_warn(
407
+ posture,
408
+ defer=defer,
409
+ code="privileged_operations_unauthenticated",
410
+ message=(
411
+ f"{business_label} publishes "
412
+ f"privileged_operations_require_authentication=false, declaring itself "
413
+ f"out of conformance for privileged operations. Refusing to transact "
414
+ f"real bookings or send buyer personal data."
415
+ ),
416
+ )
417
+
418
+ mechanism = policy.select_mechanism()
419
+ if mechanism is None:
420
+ return _refuse_or_warn(
421
+ posture,
422
+ defer=defer,
423
+ code="no_supported_mechanism",
424
+ message=(
425
+ f"{business_label} accepts {policy.accepted_mechanisms!r}, none of which "
426
+ f"this client supports (supported: {list(SUPPORTED_MECHANISMS)!r})."
427
+ ),
428
+ )
429
+
430
+ return AuthorizationDecision(mechanism=mechanism, authenticate=True)
431
+
432
+
433
+ def _refuse_or_warn(
434
+ posture: ScopedCredentialPosture, *, code: str, message: str, defer: bool = False,
435
+ ) -> AuthorizationDecision:
436
+ if posture is ScopedCredentialPosture.REQUIRED:
437
+ if defer:
438
+ return AuthorizationDecision(
439
+ mechanism=None,
440
+ authenticate=False,
441
+ refusal_code=code,
442
+ refusal_message=message,
443
+ )
444
+ raise AuthorizationRefused(code, message)
445
+ logger.warning(
446
+ "%s Proceeding unauthenticated because %s=%s; this is not a conformant "
447
+ "configuration and privileged calls may be rejected.",
448
+ message, POSTURE_ENV_VAR, posture.value,
449
+ )
450
+ return AuthorizationDecision(mechanism=None, authenticate=False, warning=message)
@@ -0,0 +1,217 @@
1
+ """USP Backend abstraction layer.
2
+
3
+ Single interface (USPBackend) for all USP operations — registry search,
4
+ profile resolution, catalog, availability, and booking. The product
5
+ implementation is booking_agent.backend.live.LiveUSPBackend; tests inject
6
+ their own.
7
+ """
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+
13
+ def _parse_iso(s: str) -> datetime:
14
+ """Parse an ISO8601 datetime string, handling the Z suffix for Python 3.10."""
15
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
16
+
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from ..authorization import BookingScopedCredential
20
+ from ..discovery import USPBusinessProfile
21
+ from ..models.platform import ServiceSearchIntent
22
+ from ..models.wire import (
23
+ Booking,
24
+ Hold,
25
+ TimeSlot,
26
+ USPPaymentResult,
27
+ USPService,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class USPBackend(ABC):
34
+ """Strategy interface for all USP operations."""
35
+
36
+ #: Set by the resource-creating operations (``hold_slot``, ``create_booking``)
37
+ #: to the ``booking_scoped_credential`` the business issued, already verified
38
+ #: against this agent's key. Read it immediately after the call: creation
39
+ #: responses are the only place a credential ever appears, and the next
40
+ #: creation overwrites it. ``None`` when the business issued none.
41
+ last_issued_credential: Optional[BookingScopedCredential] = None
42
+
43
+ # -- Registry-level (service discovery) ----------------------------------
44
+
45
+ @abstractmethod
46
+ async def search_registry(self, intent: ServiceSearchIntent) -> List[USPService]: ...
47
+
48
+ # -- Discovery (profile resolution) --------------------------------------
49
+
50
+ @abstractmethod
51
+ async def resolve_profile(self, usp_profile_url: str) -> USPBusinessProfile: ...
52
+
53
+ @abstractmethod
54
+ async def resolve_profile_from_agent_card(
55
+ self, agent_card_url: str
56
+ ) -> USPBusinessProfile: ...
57
+
58
+ # -- Business-level (catalog) --------------------------------------------
59
+
60
+ @abstractmethod
61
+ async def list_services(
62
+ self,
63
+ profile: USPBusinessProfile,
64
+ filters: Optional[Dict[str, Any]] = None,
65
+ ) -> List[USPService]: ...
66
+
67
+ @abstractmethod
68
+ async def get_service(
69
+ self, profile: USPBusinessProfile, service_id: str
70
+ ) -> USPService: ...
71
+
72
+ # -- Business-level (availability) ---------------------------------------
73
+
74
+ @abstractmethod
75
+ async def query_availability(
76
+ self,
77
+ profile: USPBusinessProfile,
78
+ service_id: str,
79
+ start_date: str,
80
+ end_date: str,
81
+ timezone: Optional[str] = None,
82
+ ) -> List[TimeSlot]:
83
+ """Slots for the window. ``timezone`` is sent as ``date_bounds_timezone``.
84
+
85
+ Live backends return an ``AvailabilityResult`` carrying the response's
86
+ ``business_timezone`` and ``applied_date_bounds_timezone``.
87
+ """
88
+
89
+ async def find_next_available(
90
+ self,
91
+ profile: USPBusinessProfile,
92
+ service_id: str,
93
+ after_date: str,
94
+ max_days: int = 30,
95
+ timezone_str: Optional[str] = None,
96
+ ) -> Optional[str]:
97
+ try:
98
+ base = _parse_iso(after_date).date()
99
+ except (ValueError, TypeError):
100
+ base = datetime.now(timezone.utc).date()
101
+ start_day = base + timedelta(days=1)
102
+ end_day = base + timedelta(days=max_days)
103
+ try:
104
+ slots = await self.query_availability(
105
+ profile, service_id,
106
+ f"{start_day.isoformat()}T00:00:00Z",
107
+ f"{end_day.isoformat()}T23:59:59Z",
108
+ timezone_str,
109
+ )
110
+ except Exception:
111
+ logger.debug("find_next_available: query failed for %s", service_id)
112
+ return None
113
+ if not slots:
114
+ return None
115
+ earliest = min(slots, key=lambda s: s.start)
116
+ return _parse_iso(earliest.start).date().isoformat()
117
+
118
+ @abstractmethod
119
+ async def hold_slot(
120
+ self,
121
+ profile: USPBusinessProfile,
122
+ slot_id: str,
123
+ service_id: str,
124
+ spots: int = 1,
125
+ ) -> Hold: ...
126
+
127
+ @abstractmethod
128
+ async def release_hold(
129
+ self,
130
+ profile: USPBusinessProfile,
131
+ hold_id: str,
132
+ *,
133
+ credential: Optional[BookingScopedCredential] = None,
134
+ ) -> dict: ...
135
+
136
+ # -- Business-level (booking) --------------------------------------------
137
+
138
+ @abstractmethod
139
+ async def create_booking(
140
+ self,
141
+ profile: USPBusinessProfile,
142
+ service_id: str,
143
+ slot_id: str,
144
+ buyer: Dict[str, Any],
145
+ party_size: int = 1,
146
+ resource_id: Optional[str] = None,
147
+ notes: Optional[str] = None,
148
+ delivery_address: Optional[Dict[str, Any]] = None,
149
+ idempotency_key: Optional[str] = None,
150
+ ) -> Booking: ...
151
+
152
+ @abstractmethod
153
+ async def get_booking(
154
+ self,
155
+ profile: USPBusinessProfile,
156
+ booking_id: str,
157
+ *,
158
+ credential: Optional[BookingScopedCredential] = None,
159
+ credential_endpoint: Optional[str] = None,
160
+ ) -> Booking: ...
161
+
162
+ @abstractmethod
163
+ async def cancel_booking(
164
+ self,
165
+ profile: USPBusinessProfile,
166
+ booking_id: str,
167
+ reason: Optional[str] = None,
168
+ *,
169
+ credential: Optional[BookingScopedCredential] = None,
170
+ credential_endpoint: Optional[str] = None,
171
+ if_match: Optional[str] = None,
172
+ ) -> Booking: ...
173
+
174
+ @abstractmethod
175
+ async def reschedule_booking(
176
+ self,
177
+ profile: USPBusinessProfile,
178
+ booking_id: str,
179
+ new_slot_id: str,
180
+ *,
181
+ credential: Optional[BookingScopedCredential] = None,
182
+ credential_endpoint: Optional[str] = None,
183
+ if_match: Optional[str] = None,
184
+ ) -> Booking: ...
185
+
186
+ @abstractmethod
187
+ async def confirm_payment(
188
+ self,
189
+ profile: USPBusinessProfile,
190
+ booking_id: str,
191
+ payment_result: USPPaymentResult,
192
+ *,
193
+ credential: Optional[BookingScopedCredential] = None,
194
+ if_match: Optional[str] = None,
195
+ ) -> Booking: ...
196
+
197
+
198
+ def get_usp_backend() -> USPBackend:
199
+ from .live import LiveUSPBackend
200
+ return LiveUSPBackend()
201
+
202
+
203
+ # ISO 4217 minor unit exponent. Default 2.
204
+ CURRENCY_MINOR_UNITS = {
205
+ "BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0,
206
+ "KRW": 0, "PYG": 0, "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0,
207
+ "XAF": 0, "XOF": 0, "XPF": 0,
208
+ "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3,
209
+ "CLF": 4, "UYW": 4,
210
+ }
211
+
212
+ CURRENCY_SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "KRW": "₩"}
213
+
214
+
215
+ def minor_to_decimal(amount: int, currency: str) -> float:
216
+ exponent = CURRENCY_MINOR_UNITS.get(currency.upper(), 2)
217
+ return amount / (10 ** exponent)