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/__init__.py +52 -0
- carrier/adapters.py +121 -0
- carrier/catalog.py +108 -0
- carrier/cli.py +70 -0
- carrier/client.py +63 -0
- carrier/data/cli.json +3032 -0
- carrier/data/operations.json +3042 -0
- carrier/errors.py +51 -0
- carrier/http.py +135 -0
- carrier/idempotency.py +46 -0
- carrier/mcp.py +237 -0
- carrier/models.py +213 -0
- carrier/operation_ids.py +188 -0
- carrier/operations.py +192 -0
- carrier/py.typed +0 -0
- carrier/records.py +117 -0
- carrier/resources.py +201 -0
- carrier/signature.py +57 -0
- carrier/version.py +3 -0
- carrier_python-0.1.15.dist-info/METADATA +87 -0
- carrier_python-0.1.15.dist-info/RECORD +23 -0
- carrier_python-0.1.15.dist-info/WHEEL +5 -0
- carrier_python-0.1.15.dist-info/top_level.txt +1 -0
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
|