carrier-python 0.1.15__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.
carrier/errors.py ADDED
@@ -0,0 +1,51 @@
1
+ """Errors raised by the Carrier client."""
2
+
3
+
4
+ class CarrierError(Exception):
5
+ """Error returned by the Carrier API, or raised before a request is sent."""
6
+
7
+ def __init__(
8
+ self,
9
+ status: int,
10
+ code: str,
11
+ message: str,
12
+ error_type: str = "invalid_request_error",
13
+ *,
14
+ idempotency_key: str | None = None,
15
+ retryable: bool | None = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.status = status
19
+ self.code = code
20
+ self.type = error_type
21
+ self.idempotency_key = idempotency_key
22
+ self.retryable = retryable
23
+
24
+
25
+ def outcome_unknown(key: str, status: int = 0) -> CarrierError:
26
+ """A dispatched issuance cannot safely be retried or given a new key."""
27
+ return CarrierError(
28
+ status, "outcome_unknown",
29
+ "Issuance outcome is unknown. Reconcile using the original idempotency key; do not automatically retry or create a new key.",
30
+ "api_error", idempotency_key=key, retryable=False,
31
+ )
32
+
33
+
34
+ def error_from_body(status: int, body: object) -> CarrierError:
35
+ """Build a CarrierError from an API error envelope."""
36
+ if isinstance(body, dict):
37
+ error = body.get("error")
38
+ if isinstance(error, str):
39
+ code = body.get("code")
40
+ return CarrierError(status, code if isinstance(code, str) else "api_error", error, "api_error")
41
+ if isinstance(error, dict):
42
+ code = error.get("code")
43
+ message = error.get("message")
44
+ error_type = error.get("type")
45
+ return CarrierError(
46
+ status,
47
+ code if isinstance(code, str) else "api_error",
48
+ message if isinstance(message, str) else "Carrier request failed.",
49
+ error_type if isinstance(error_type, str) else "api_error",
50
+ )
51
+ return CarrierError(status, "api_error", "Carrier request failed.", "api_error")
carrier/http.py ADDED
@@ -0,0 +1,135 @@
1
+ """HTTP transport for the connectivity API."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlencode
9
+
10
+ from carrier.errors import CarrierError, error_from_body, outcome_unknown
11
+ from carrier.idempotency import is_issuance, require_idempotency_key, require_issuance_key
12
+ from carrier.version import __version__
13
+
14
+ DEFAULT_BASE_URL = "https://api.carrier.llc"
15
+ _USER_AGENT = f"carrier-python/{__version__}"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PreparedRequest:
20
+ """One outbound call. Tests capture this instead of opening a socket."""
21
+
22
+ method: str
23
+ url: str
24
+ headers: dict[str, str]
25
+ body: bytes | None
26
+
27
+
28
+ Transport = Callable[[PreparedRequest], tuple[int, bytes]]
29
+
30
+
31
+ class Http:
32
+ """Bearer-authenticated JSON client."""
33
+
34
+ def __init__(self, api_key: str, base_url: str, transport: Transport, timeout: float) -> None:
35
+ self._api_key = api_key
36
+ self._base_url = base_url.rstrip("/")
37
+ self._transport = transport
38
+ self._timeout = timeout
39
+
40
+ def request(
41
+ self,
42
+ method: str,
43
+ path: str,
44
+ *,
45
+ body: object | None = None,
46
+ idempotency_key: str | None = None,
47
+ query: Mapping[str, str] | None = None,
48
+ headers: Mapping[str, str] | None = None,
49
+ ) -> object:
50
+ """Send one request and return the decoded JSON body."""
51
+ url = f"{self._base_url}{path}"
52
+ if query:
53
+ url = f"{url}?{urlencode(query)}"
54
+ outbound = {
55
+ "Authorization": f"Bearer {self._api_key}",
56
+ "Accept": "application/json",
57
+ "User-Agent": _USER_AGENT,
58
+ }
59
+ for name, value in (headers or {}).items():
60
+ if name.lower() in {"authorization", "accept", "user-agent", "content-type", "host"}:
61
+ raise CarrierError(400, "invalid_header", f"{name} is set by the client.")
62
+ outbound[name] = value
63
+ payload: bytes | None = None
64
+ if body is not None:
65
+ try:
66
+ payload = json.dumps(body).encode("utf-8")
67
+ except (TypeError, ValueError) as error:
68
+ raise CarrierError(400, "invalid_body", "Request body could not be encoded.") from error
69
+ outbound["Content-Type"] = "application/json"
70
+ issuance = is_issuance(method, path)
71
+ key = ""
72
+ if issuance:
73
+ supplied_key = idempotency_key if idempotency_key is not None else outbound.get("X-Idempotency-Key", outbound.get("Idempotency-Key"))
74
+ key = require_issuance_key(supplied_key)
75
+ if idempotency_key is not None:
76
+ outbound["Idempotency-Key"] = require_idempotency_key(idempotency_key)
77
+ try:
78
+ status, raw = self._transport(
79
+ PreparedRequest(method=method, url=url, headers=outbound, body=payload),
80
+ )
81
+ except Exception as error:
82
+ if is_issuance(method, path):
83
+ raise outcome_unknown(key) from error
84
+ if isinstance(error, CarrierError):
85
+ raise
86
+ message = str(error) if str(error) else "Network request failed."
87
+ raise CarrierError(0, "network_error", message, "api_error") from error
88
+ try:
89
+ return _decode(status, raw)
90
+ except CarrierError as error:
91
+ if issuance and isinstance(error.__cause__, (UnicodeDecodeError, json.JSONDecodeError)):
92
+ raise outcome_unknown(key, status) from error
93
+ if issuance and error.code.lower() in ("outcome_unknown", "manual_reconciliation_required"):
94
+ error.idempotency_key = key
95
+ error.retryable = False
96
+ raise
97
+
98
+
99
+ def urllib_transport(timeout: float) -> Transport:
100
+ """The default transport. Uses the stdlib so the package has no dependencies."""
101
+
102
+ def send(request: PreparedRequest) -> tuple[int, bytes]:
103
+ outbound = urllib.request.Request(
104
+ request.url,
105
+ data=request.body,
106
+ headers=request.headers,
107
+ method=request.method,
108
+ )
109
+ try:
110
+ with urllib.request.urlopen(outbound, timeout=timeout) as response:
111
+ return int(response.status), response.read()
112
+ except urllib.error.HTTPError as error:
113
+ return int(error.code), error.read()
114
+ except urllib.error.URLError as error:
115
+ reason = error.reason
116
+ message = str(reason) if reason else "Network request failed."
117
+ raise CarrierError(0, "network_error", message, "api_error") from error
118
+ except TimeoutError as error:
119
+ raise CarrierError(0, "network_error", str(error), "api_error") from error
120
+
121
+ return send
122
+
123
+
124
+ def _decode(status: int, raw: bytes) -> object:
125
+ try:
126
+ parsed = json.loads(raw.decode("utf-8"))
127
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
128
+ raise CarrierError(
129
+ status,
130
+ "invalid_response",
131
+ "Carrier returned a body that is not JSON.",
132
+ ) from error
133
+ if 200 <= status <= 299:
134
+ return parsed
135
+ raise error_from_body(status, parsed)
carrier/idempotency.py ADDED
@@ -0,0 +1,46 @@
1
+ """Idempotency-Key checks shared with the API."""
2
+
3
+ import re
4
+
5
+ from carrier.errors import CarrierError
6
+
7
+
8
+ def is_issuance(method: str, path: str) -> bool:
9
+ return method == "POST" and path in {
10
+ "/v1/connectivity/intents", "/v1/subscribers/issue-from-inventory",
11
+ }
12
+
13
+
14
+ def require_issuance_key(key: object) -> str:
15
+ """Preserve the API's stable business key verbatim; never coerce or trim."""
16
+ if not isinstance(key, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}", key) is None:
17
+ raise CarrierError(400, "invalid_idempotency_key", "Issuance requires a stable 1–200 character key: alphanumeric first, then letters, digits, _, ., :, or -.")
18
+ return key
19
+
20
+
21
+ def require_idempotency_key(key: str | None) -> str:
22
+ """Return the key, or raise before a request is sent."""
23
+ if key is None or key.strip() == "":
24
+ raise CarrierError(400, "invalid_idempotency_key", "Idempotency-Key is required.")
25
+ if len(key) > 255:
26
+ raise CarrierError(
27
+ 400,
28
+ "invalid_idempotency_key",
29
+ "Idempotency-Key must be 255 characters or fewer.",
30
+ )
31
+ for char in key:
32
+ code = ord(char)
33
+ if code <= 31 or code == 127:
34
+ raise CarrierError(
35
+ 400,
36
+ "invalid_idempotency_key",
37
+ "Idempotency-Key must not contain control characters.",
38
+ )
39
+ return key
40
+
41
+
42
+ def require_id(value: str, label: str) -> str:
43
+ """Reject a path segment that could escape the resource URL."""
44
+ if value.strip() == "" or any(char in value for char in "/\\?#") or ".." in value:
45
+ raise CarrierError(400, "invalid_id", f"{label} is not a valid id.")
46
+ return value
carrier/mcp.py ADDED
@@ -0,0 +1,237 @@
1
+ """Streamable HTTP client for the hosted Carrier MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
11
+
12
+ from carrier.errors import CarrierError
13
+ from carrier.version import __version__
14
+
15
+ DEFAULT_MCP_URL = "https://mcp.carrier.llc/mcp"
16
+ PROTOCOL_VERSION = "2025-11-25"
17
+ ACCEPTED_PROTOCOL_VERSIONS = frozenset({"2025-11-25", "2025-06-18", "2025-03-26"})
18
+ _USER_AGENT = f"carrier-python/{__version__}"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class McpRequest:
23
+ """One MCP HTTP call. Tests capture this instead of opening a socket."""
24
+
25
+ method: str
26
+ url: str
27
+ headers: dict[str, str]
28
+ body: bytes | None
29
+
30
+
31
+ McpTransport = Callable[[McpRequest], tuple[int, Mapping[str, str], bytes]]
32
+
33
+
34
+ class Mcp:
35
+ """Client for `https://mcp.carrier.llc/mcp`.
36
+
37
+ `auth="token"` sends the eSIMVault reseller token as `?TOKEN=`.
38
+ `auth="bearer"` sends an OAuth access token or a Clerk organization API key.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ token: str,
44
+ *,
45
+ url: str = DEFAULT_MCP_URL,
46
+ auth: str = "token",
47
+ transport: McpTransport | None = None,
48
+ timeout: float = 30,
49
+ ) -> None:
50
+ if token.strip() == "":
51
+ raise CarrierError(400, "invalid_token", "An MCP token is required.")
52
+ if auth not in {"token", "bearer"}:
53
+ raise CarrierError(400, "invalid_auth", "MCP auth must be token or bearer.")
54
+ self._token = token
55
+ self._url = url
56
+ self._auth = auth
57
+ self._transport = transport if transport is not None else urllib_mcp_transport(timeout)
58
+ self._session_id: str | None = None
59
+ self._protocol: str | None = None
60
+ self._connected = False
61
+ self._next_id = 1
62
+
63
+ def connect(self) -> dict[str, object]:
64
+ """Initialize a session and send `notifications/initialized`."""
65
+ result = self._rpc(
66
+ "initialize",
67
+ {
68
+ "protocolVersion": PROTOCOL_VERSION,
69
+ "capabilities": {"tools": {}},
70
+ "clientInfo": {"name": "carrier-python", "version": __version__},
71
+ },
72
+ handshake=True,
73
+ )
74
+ if not isinstance(result, dict):
75
+ raise CarrierError(200, "invalid_response", "MCP initialize did not return an object.")
76
+ version = result.get("protocolVersion")
77
+ if version not in ACCEPTED_PROTOCOL_VERSIONS:
78
+ raise CarrierError(400, "unsupported_protocol", f"MCP server negotiated {version}.")
79
+ self._protocol = str(version)
80
+ self._notify("notifications/initialized")
81
+ self._connected = True
82
+ return result
83
+
84
+ def list_tools(self) -> list[object]:
85
+ """Return the tools advertised for this session."""
86
+ self._ensure_connected()
87
+ result = self._rpc("tools/list", {})
88
+ if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
89
+ raise CarrierError(200, "invalid_response", "MCP tools/list did not return tools.")
90
+ return list(result["tools"])
91
+
92
+ def call_tool(self, name: str, arguments: Mapping[str, object] | None = None) -> dict[str, object]:
93
+ """Call one MCP tool and return the tool result object."""
94
+ self._ensure_connected()
95
+ if name.strip() == "":
96
+ raise CarrierError(400, "invalid_tool", "An MCP tool name is required.")
97
+ result = self._rpc("tools/call", {"name": name, "arguments": dict(arguments or {})})
98
+ if not isinstance(result, dict):
99
+ raise CarrierError(200, "invalid_response", "MCP tools/call did not return an object.")
100
+ return result
101
+
102
+ def request(self, method: str, params: Mapping[str, object] | None = None) -> object:
103
+ """Call any MCP method. The session is opened on the first call."""
104
+ if method != "initialize":
105
+ self._ensure_connected()
106
+ return self._rpc(method, dict(params or {}))
107
+
108
+ def _ensure_connected(self) -> None:
109
+ if not self._connected:
110
+ self.connect()
111
+
112
+ def _notify(self, method: str) -> None:
113
+ self._send({"jsonrpc": "2.0", "method": method}, notify=True)
114
+
115
+ def _rpc(self, method: str, params: Mapping[str, object], *, handshake: bool = False) -> object:
116
+ message = {"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": dict(params)}
117
+ self._next_id += 1
118
+ return self._send(message, handshake=handshake)
119
+
120
+ def _send(self, message: Mapping[str, object], *, notify: bool = False, handshake: bool = False) -> object:
121
+ payload = json.dumps(dict(message)).encode("utf-8")
122
+ try:
123
+ status, headers, raw = self._transport(
124
+ McpRequest(method="POST", url=self._endpoint(), headers=self._headers(), body=payload),
125
+ )
126
+ except CarrierError:
127
+ raise
128
+ except Exception as error:
129
+ text = str(error) if str(error) else "Network request failed."
130
+ raise CarrierError(0, "network_error", text, "api_error") from error
131
+ if handshake:
132
+ self._session_id = _header(headers, "mcp-session-id") or self._session_id
133
+ if status < 200 or status > 299:
134
+ raise CarrierError(status, "mcp_error", _error_text(raw), "api_error")
135
+ if notify and raw.strip() == b"":
136
+ return None
137
+ return _decode_rpc(raw, headers)
138
+
139
+ def _endpoint(self) -> str:
140
+ if self._auth != "token":
141
+ return self._url
142
+ parts = urlsplit(self._url)
143
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
144
+ query["TOKEN"] = self._token
145
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
146
+
147
+ def _headers(self) -> dict[str, str]:
148
+ headers = {
149
+ "Content-Type": "application/json",
150
+ "Accept": "application/json, text/event-stream",
151
+ "User-Agent": _USER_AGENT,
152
+ }
153
+ if self._auth == "bearer":
154
+ headers["Authorization"] = f"Bearer {self._token}"
155
+ if self._session_id:
156
+ headers["Mcp-Session-Id"] = self._session_id
157
+ if self._protocol:
158
+ headers["MCP-Protocol-Version"] = self._protocol
159
+ return headers
160
+
161
+
162
+ def urllib_mcp_transport(timeout: float) -> McpTransport:
163
+ """Default MCP transport. Uses the stdlib so the package has no dependencies."""
164
+
165
+ def send(request: McpRequest) -> tuple[int, Mapping[str, str], bytes]:
166
+ outbound = urllib.request.Request(request.url, data=request.body, headers=request.headers, method=request.method)
167
+ try:
168
+ with urllib.request.urlopen(outbound, timeout=timeout) as response:
169
+ header_map = {key.lower(): value for key, value in response.headers.items()}
170
+ return int(response.status), header_map, response.read()
171
+ except urllib.error.HTTPError as error:
172
+ header_map = {key.lower(): value for key, value in error.headers.items()}
173
+ return int(error.code), header_map, error.read()
174
+ except urllib.error.URLError as error:
175
+ reason = error.reason
176
+ message = str(reason) if reason else "Network request failed."
177
+ raise CarrierError(0, "network_error", message, "api_error") from error
178
+ except TimeoutError as error:
179
+ raise CarrierError(0, "network_error", str(error), "api_error") from error
180
+
181
+ return send
182
+
183
+
184
+ def _decode_rpc(raw: bytes, headers: Mapping[str, str]) -> object:
185
+ if raw.strip() == b"":
186
+ return None
187
+ content_type = _header(headers, "content-type") or ""
188
+ try:
189
+ if "text/event-stream" in content_type:
190
+ return _decode_sse(raw)
191
+ parsed = json.loads(raw.decode("utf-8"))
192
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
193
+ raise CarrierError(200, "invalid_response", "MCP returned a body that is not JSON.") from error
194
+ if not isinstance(parsed, dict):
195
+ raise CarrierError(200, "invalid_response", "MCP returned a body that is not an object.")
196
+ return _rpc_payload(parsed)
197
+
198
+
199
+ def _decode_sse(raw: bytes) -> object:
200
+ for block in raw.decode("utf-8").split("\n\n"):
201
+ for line in block.split("\n"):
202
+ if not line.startswith("data:"):
203
+ continue
204
+ data = line[5:].strip()
205
+ if data == "" or data == "[DONE]":
206
+ continue
207
+ parsed = json.loads(data)
208
+ if isinstance(parsed, dict) and ("result" in parsed or "error" in parsed):
209
+ return _rpc_payload(parsed)
210
+ raise CarrierError(200, "invalid_response", "MCP event stream did not include a result.")
211
+
212
+
213
+ def _rpc_payload(message: Mapping[str, object]) -> object:
214
+ error = message.get("error")
215
+ if isinstance(error, dict):
216
+ text = error.get("message")
217
+ raise CarrierError(
218
+ 200,
219
+ "mcp_error",
220
+ text if isinstance(text, str) and text else "MCP request failed.",
221
+ "api_error",
222
+ )
223
+ return message.get("result")
224
+
225
+
226
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
227
+ for key, value in headers.items():
228
+ if key.lower() == name:
229
+ return value
230
+ return None
231
+
232
+
233
+ def _error_text(raw: bytes) -> str:
234
+ text = raw.decode("utf-8", errors="replace").strip()
235
+ if text == "":
236
+ return "MCP request failed."
237
+ return text[:300]
carrier/models.py ADDED
@@ -0,0 +1,213 @@
1
+ """Public objects returned by the connectivity API."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, TypeVar
5
+
6
+ from carrier.errors import CarrierError
7
+
8
+
9
+ def _invalid(field: str) -> CarrierError:
10
+ return CarrierError(0, "invalid_response", f"Carrier response field {field} is missing or invalid.")
11
+
12
+
13
+ def _mapping(value: object, label: str) -> dict[str, Any]:
14
+ if not isinstance(value, dict):
15
+ raise CarrierError(0, "invalid_response", f"Carrier response {label} is not an object.")
16
+ return value
17
+
18
+
19
+ def _str(data: dict[str, Any], key: str) -> str:
20
+ value = data.get(key)
21
+ if not isinstance(value, str):
22
+ raise _invalid(key)
23
+ return value
24
+
25
+
26
+ def _int(data: dict[str, Any], key: str) -> int:
27
+ value = data.get(key)
28
+ if isinstance(value, bool) or not isinstance(value, int):
29
+ raise _invalid(key)
30
+ return value
31
+
32
+
33
+ def _bool(data: dict[str, Any], key: str) -> bool:
34
+ value = data.get(key)
35
+ if not isinstance(value, bool):
36
+ raise _invalid(key)
37
+ return value
38
+
39
+
40
+ def _number(data: dict[str, Any], key: str) -> float:
41
+ value = data.get(key)
42
+ if isinstance(value, bool) or not isinstance(value, int | float):
43
+ raise _invalid(key)
44
+ return float(value)
45
+
46
+
47
+ def _string_map(value: object) -> dict[str, str]:
48
+ if not isinstance(value, dict):
49
+ raise _invalid("metadata")
50
+ metadata: dict[str, str] = {}
51
+ for key, entry in value.items():
52
+ if not isinstance(key, str) or not isinstance(entry, str):
53
+ raise _invalid("metadata")
54
+ metadata[key] = entry
55
+ return metadata
56
+
57
+
58
+ T = TypeVar("T")
59
+
60
+
61
+ def parse_list(payload: object, parse_item: Callable[[dict[str, Any]], T]) -> list[T]:
62
+ """Unwrap `{object: list, data: [...]}`."""
63
+ data = _mapping(payload, "list")
64
+ items = data.get("data")
65
+ if data.get("object") != "list" or not isinstance(items, list):
66
+ raise CarrierError(0, "invalid_response", "Carrier returned a list that is not an object.")
67
+ return [parse_item(_mapping(item, "item")) for item in items]
68
+
69
+
70
+ class PlanPrice:
71
+ """Catalogue price in minor units. Present on operator products."""
72
+
73
+ def __init__(self, amount: int, currency: str) -> None:
74
+ self.amount = amount
75
+ self.currency = currency
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict[str, Any]) -> "PlanPrice":
79
+ return cls(amount=_int(data, "amount"), currency=_str(data, "currency"))
80
+
81
+
82
+ class ConnectivityPlan:
83
+ """A test plan or an operator-defined product."""
84
+
85
+ def __init__(
86
+ self,
87
+ id: str,
88
+ object: str,
89
+ name: str,
90
+ data_mb: int,
91
+ validity_days: int,
92
+ countries: list[str],
93
+ livemode: bool,
94
+ price: PlanPrice | None,
95
+ ) -> None:
96
+ self.id = id
97
+ self.object = object
98
+ self.name = name
99
+ self.data_mb = data_mb
100
+ self.validity_days = validity_days
101
+ self.countries = countries
102
+ self.livemode = livemode
103
+ self.price = price
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> "ConnectivityPlan":
107
+ raw_countries = data.get("countries")
108
+ if not isinstance(raw_countries, list) or not all(isinstance(item, str) for item in raw_countries):
109
+ raise _invalid("countries")
110
+ raw_price = data.get("price")
111
+ price = None if raw_price is None else PlanPrice.from_dict(_mapping(raw_price, "price"))
112
+ return cls(
113
+ id=_str(data, "id"),
114
+ object=_str(data, "object"),
115
+ name=_str(data, "name"),
116
+ data_mb=_int(data, "data_mb"),
117
+ validity_days=_int(data, "validity_days"),
118
+ countries=list(raw_countries),
119
+ livemode=_bool(data, "livemode"),
120
+ price=price,
121
+ )
122
+
123
+
124
+ class ConnectivityEsim:
125
+ """The profile attached to a succeeded intent."""
126
+
127
+ def __init__(
128
+ self,
129
+ iccid: str,
130
+ activation_code: str | None,
131
+ qr_code: str | None,
132
+ smdp_address: str | None,
133
+ matching_id: str | None,
134
+ ) -> None:
135
+ self.iccid = iccid
136
+ self.activation_code = activation_code
137
+ self.qr_code = qr_code
138
+ self.smdp_address = smdp_address
139
+ self.matching_id = matching_id
140
+
141
+ @classmethod
142
+ def from_dict(cls, data: dict[str, Any]) -> "ConnectivityEsim":
143
+ return cls(
144
+ iccid=_str(data, "iccid"),
145
+ activation_code=_optional_str(data, "activation_code"),
146
+ qr_code=_optional_str(data, "qr_code"),
147
+ smdp_address=_optional_str(data, "smdp_address"),
148
+ matching_id=_optional_str(data, "matching_id"),
149
+ )
150
+
151
+
152
+ class IntentError:
153
+ """Failure stored on an intent. Distinct from CarrierError, which is raised."""
154
+
155
+ def __init__(self, code: str, message: str) -> None:
156
+ self.code = code
157
+ self.message = message
158
+
159
+ @classmethod
160
+ def from_dict(cls, data: dict[str, Any]) -> "IntentError":
161
+ return cls(code=_str(data, "code"), message=_str(data, "message"))
162
+
163
+
164
+ class ConnectivityIntent:
165
+ """One request to issue an eSIM."""
166
+
167
+ def __init__(
168
+ self,
169
+ id: str,
170
+ object: str,
171
+ status: str,
172
+ livemode: bool,
173
+ plan: ConnectivityPlan | None,
174
+ esim: ConnectivityEsim | None,
175
+ metadata: dict[str, str],
176
+ error: IntentError | None,
177
+ created: int,
178
+ ) -> None:
179
+ self.id = id
180
+ self.object = object
181
+ self.status = status
182
+ self.livemode = livemode
183
+ self.plan = plan
184
+ self.esim = esim
185
+ self.metadata = metadata
186
+ self.error = error
187
+ self.created = created
188
+
189
+ @classmethod
190
+ def from_dict(cls, data: dict[str, Any]) -> "ConnectivityIntent":
191
+ raw_plan = data.get("plan")
192
+ raw_esim = data.get("esim")
193
+ raw_error = data.get("error")
194
+ return cls(
195
+ id=_str(data, "id"),
196
+ object=_str(data, "object"),
197
+ status=_str(data, "status"),
198
+ livemode=_bool(data, "livemode"),
199
+ plan=None if raw_plan is None else ConnectivityPlan.from_dict(_mapping(raw_plan, "plan")),
200
+ esim=None if raw_esim is None else ConnectivityEsim.from_dict(_mapping(raw_esim, "esim")),
201
+ metadata=_string_map(data.get("metadata", {})),
202
+ error=None if raw_error is None else IntentError.from_dict(_mapping(raw_error, "error")),
203
+ created=_int(data, "created"),
204
+ )
205
+
206
+
207
+ def _optional_str(data: dict[str, Any], key: str) -> str | None:
208
+ if key not in data or data[key] is None:
209
+ return None
210
+ value = data[key]
211
+ if not isinstance(value, str):
212
+ raise _invalid(key)
213
+ return value