lcp-sdk 1.0.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.
lcp_sdk/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Standalone LCP SDK for Python integrations."""
2
+
3
+ from .client import LCPClient, LCPHTTPError
4
+ from .envelope import build_envelope, message_metadata, utc_timestamp
5
+ from .signing import SignatureError, canonical_signing_bytes, sign_hmac, verify_hmac
6
+ from .validation import SchemaValidator, ValidationError
7
+ from .webhook import verify_http_request
8
+
9
+ __all__ = [
10
+ "LCPClient",
11
+ "LCPHTTPError",
12
+ "SchemaValidator",
13
+ "SignatureError",
14
+ "ValidationError",
15
+ "build_envelope",
16
+ "canonical_signing_bytes",
17
+ "message_metadata",
18
+ "sign_hmac",
19
+ "utc_timestamp",
20
+ "verify_hmac",
21
+ "verify_http_request",
22
+ ]
23
+
24
+ __version__ = "1.0.0"
lcp_sdk/client.py ADDED
@@ -0,0 +1,163 @@
1
+ """HTTP client for LCP-compatible endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ from .envelope import message_metadata, utc_timestamp
13
+ from .signing import sign_hmac
14
+ from .validation import SchemaValidator
15
+
16
+
17
+ class LCPHTTPError(RuntimeError):
18
+ """Raised when an LCP endpoint returns a non-success response."""
19
+
20
+ def __init__(self, status_code: int, body: Any):
21
+ self.status_code = status_code
22
+ self.body = body
23
+ super().__init__(f"LCP HTTP {status_code}: {body}")
24
+
25
+
26
+ class LCPClient:
27
+ """Small, dependency-light client for the LCP HTTP binding."""
28
+
29
+ def __init__(
30
+ self,
31
+ endpoint: str,
32
+ *,
33
+ sender_id: str | None = None,
34
+ api_key: str | None = None,
35
+ hmac_secret: str | None = None,
36
+ timeout: float = 30.0,
37
+ max_retries: int = 2,
38
+ validator: SchemaValidator | None = None,
39
+ ) -> None:
40
+ self.endpoint = endpoint.rstrip("/")
41
+ self.sender_id = sender_id
42
+ self.api_key = api_key
43
+ self.hmac_secret = hmac_secret
44
+ self.timeout = timeout
45
+ self.max_retries = max(0, max_retries)
46
+ self.validator = validator
47
+
48
+ def _headers(
49
+ self,
50
+ body: bytes,
51
+ *,
52
+ idempotency_key: str | None,
53
+ test: bool = False,
54
+ ) -> dict[str, str]:
55
+ headers = {
56
+ "Content-Type": "application/json",
57
+ "Accept": "application/json",
58
+ }
59
+ if self.sender_id:
60
+ headers["X-LCP-Sender-Id"] = self.sender_id
61
+ if self.api_key:
62
+ headers["Authorization"] = f"Bearer {self.api_key}"
63
+ if self.hmac_secret:
64
+ timestamp = utc_timestamp()
65
+ headers["X-LCP-Timestamp"] = timestamp
66
+ if idempotency_key:
67
+ headers["X-LCP-Idempotency-Key"] = idempotency_key
68
+ headers["X-LCP-Signature"] = sign_hmac(
69
+ self.hmac_secret, timestamp, idempotency_key, body
70
+ )
71
+ elif idempotency_key:
72
+ headers["X-LCP-Idempotency-Key"] = idempotency_key
73
+ if test:
74
+ headers["X-LCP-Test"] = "true"
75
+ return headers
76
+
77
+ def request(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ *,
82
+ payload: dict[str, Any] | None = None,
83
+ params: dict[str, Any] | None = None,
84
+ idempotency_key: str | None = None,
85
+ test: bool = False,
86
+ ) -> dict[str, Any]:
87
+ body = b""
88
+ if payload is not None:
89
+ body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
90
+ "utf-8"
91
+ )
92
+ if idempotency_key is None:
93
+ try:
94
+ idempotency_key = message_metadata(payload)["idempotency_key"]
95
+ except (KeyError, ValueError):
96
+ pass
97
+ headers = self._headers(body, idempotency_key=idempotency_key, test=test)
98
+ url = f"{self.endpoint}/{path.lstrip('/')}"
99
+ retryable = method.upper() in {"GET", "HEAD", "PUT", "DELETE", "POST"}
100
+ last_error: Exception | None = None
101
+
102
+ for attempt in range(self.max_retries + 1):
103
+ try:
104
+ with httpx.Client(timeout=self.timeout) as client:
105
+ response = client.request(
106
+ method,
107
+ url,
108
+ content=body or None,
109
+ headers=headers,
110
+ params=params,
111
+ )
112
+ except httpx.RequestError as exc:
113
+ last_error = exc
114
+ if not retryable or attempt >= self.max_retries:
115
+ raise
116
+ time.sleep(2**attempt)
117
+ continue
118
+
119
+ try:
120
+ data = response.json()
121
+ except ValueError:
122
+ data = {"raw": response.text}
123
+ if response.is_success:
124
+ return data
125
+ if response.status_code in {429, 500, 502, 503, 504} and attempt < self.max_retries:
126
+ retry_after = response.headers.get("Retry-After")
127
+ delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
128
+ time.sleep(delay)
129
+ continue
130
+ raise LCPHTTPError(response.status_code, data)
131
+
132
+ if last_error:
133
+ raise last_error
134
+ raise RuntimeError("LCP request failed without a response")
135
+
136
+ def submit_lead(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
137
+ self._validate(envelope)
138
+ return self.request("POST", "/v1/lcp/leads", payload=envelope, test=test)
139
+
140
+ def submit_call(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
141
+ self._validate(envelope)
142
+ return self.request("POST", "/v1/lcp/calls", payload=envelope, test=test)
143
+
144
+ def submit_bid(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
145
+ self._validate(envelope)
146
+ return self.request("POST", "/v1/lcp/bids", payload=envelope, test=test)
147
+
148
+ def query_lead_status(self, lead_id: str) -> dict[str, Any]:
149
+ return self.request("GET", f"/v1/lcp/leads/{quote(lead_id, safe='')}")
150
+
151
+ def get_schema(self, name: str) -> dict[str, Any]:
152
+ return self.request("GET", f"/v1/lcp/schemas/{quote(name, safe='/')}")
153
+
154
+ def get_capabilities(self) -> dict[str, Any]:
155
+ return self.request("GET", "/v1/lcp/capabilities")
156
+
157
+ def list_offers(self, vertical: str | None = None) -> dict[str, Any]:
158
+ params = {"vertical": vertical} if vertical else None
159
+ return self.request("GET", "/v1/lcp/offers", params=params)
160
+
161
+ def _validate(self, envelope: dict[str, Any]) -> None:
162
+ if self.validator:
163
+ self.validator.require_valid_envelope(envelope)
lcp_sdk/envelope.py ADDED
@@ -0,0 +1,54 @@
1
+ """Helpers for constructing LCP envelopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+
10
+ def utc_timestamp() -> str:
11
+ """Return the current UTC time in the canonical LCP format."""
12
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
13
+
14
+
15
+ def build_envelope(
16
+ message_type: str,
17
+ sender_id: str,
18
+ receiver_id: str,
19
+ payload: dict[str, Any],
20
+ *,
21
+ version: str = "1.0.0",
22
+ message_id: str | None = None,
23
+ correlation_id: str | None = None,
24
+ idempotency_key: str | None = None,
25
+ timestamp: str | None = None,
26
+ test: bool = False,
27
+ ) -> dict[str, Any]:
28
+ """Build a complete LCP envelope around a message payload."""
29
+ message_id = message_id or str(uuid4())
30
+ idempotency_key = idempotency_key or f"{sender_id}-{message_type}-{uuid4().hex}"
31
+ return {
32
+ "lcp": {
33
+ "version": version,
34
+ "message": {
35
+ "id": message_id,
36
+ "type": message_type,
37
+ "timestamp": timestamp or utc_timestamp(),
38
+ "sender_id": sender_id,
39
+ "receiver_id": receiver_id,
40
+ "correlation_id": correlation_id,
41
+ "idempotency_key": idempotency_key,
42
+ "test": test,
43
+ },
44
+ "payload": payload,
45
+ }
46
+ }
47
+
48
+
49
+ def message_metadata(envelope: dict[str, Any]) -> dict[str, Any]:
50
+ """Return the message header or raise for a malformed envelope."""
51
+ try:
52
+ return envelope["lcp"]["message"]
53
+ except (KeyError, TypeError) as exc:
54
+ raise ValueError("Envelope must contain lcp.message") from exc