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/resources.py ADDED
@@ -0,0 +1,201 @@
1
+ """Resource clients. Each method maps to one connectivity route."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ from carrier.errors import CarrierError, outcome_unknown
7
+ from carrier.http import Http
8
+ from carrier.idempotency import require_id
9
+ from carrier.models import ConnectivityIntent, ConnectivityPlan, parse_list
10
+ from carrier.records import (
11
+ ConnectivityEvent,
12
+ ConnectivityUsageRecord,
13
+ DeletedWebhookEndpoint,
14
+ WebhookEndpoint,
15
+ WebhookEndpointCreated,
16
+ )
17
+
18
+
19
+ class Intents:
20
+ """Issue, read, and confirm connectivity intents."""
21
+
22
+ def __init__(self, http: Http) -> None:
23
+ self._http = http
24
+
25
+ def create(
26
+ self,
27
+ *,
28
+ plan: str | None = None,
29
+ metadata: Mapping[str, str] | None = None,
30
+ idempotency_key: str,
31
+ ) -> ConnectivityIntent:
32
+ """POST /v1/connectivity/intents. The same key and body return the original intent."""
33
+ body: dict[str, Any] = {}
34
+ if plan is not None:
35
+ body["plan"] = plan
36
+ if metadata is not None:
37
+ body["metadata"] = dict(metadata)
38
+ payload = self._http.request(
39
+ "POST",
40
+ "/v1/connectivity/intents",
41
+ body=body,
42
+ idempotency_key=idempotency_key,
43
+ )
44
+ try:
45
+ return ConnectivityIntent.from_dict(_as_object(payload))
46
+ except CarrierError as error:
47
+ raise outcome_unknown(idempotency_key) from error
48
+
49
+ def retrieve(self, intent_id: str) -> ConnectivityIntent:
50
+ """GET /v1/connectivity/intents/:id."""
51
+ path = f"/v1/connectivity/intents/{require_id(intent_id, 'intent')}"
52
+ return ConnectivityIntent.from_dict(_as_object(self._http.request("GET", path)))
53
+
54
+ def list(self, *, limit: int | None = None) -> list[ConnectivityIntent]:
55
+ """GET /v1/connectivity/intents. Newest first. Omit limit for the default page of 20."""
56
+ query: dict[str, str] = {}
57
+ if limit is not None:
58
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1 or limit > 100:
59
+ raise CarrierError(400, "invalid_limit", "limit must be an integer from 1 to 100.")
60
+ query["limit"] = str(limit)
61
+ payload = self._http.request("GET", "/v1/connectivity/intents", query=query or None)
62
+ return parse_list(payload, ConnectivityIntent.from_dict)
63
+
64
+ def confirm(self, intent_id: str, *, plan: str) -> ConnectivityIntent:
65
+ """POST /v1/connectivity/intents/:id/confirm."""
66
+ path = f"/v1/connectivity/intents/{require_id(intent_id, 'intent')}/confirm"
67
+ payload = self._http.request("POST", path, body={"plan": plan})
68
+ return ConnectivityIntent.from_dict(_as_object(payload))
69
+
70
+
71
+ class Products:
72
+ """Operator-defined plans. Creating one does not debit a wallet."""
73
+
74
+ def __init__(self, http: Http) -> None:
75
+ self._http = http
76
+
77
+ def create(
78
+ self,
79
+ *,
80
+ name: str,
81
+ data_mb: int,
82
+ validity_days: int,
83
+ countries: Sequence[str],
84
+ price_cents: int,
85
+ currency: str,
86
+ package_template_id: int,
87
+ account_id: int | None = None,
88
+ idempotency_key: str,
89
+ ) -> ConnectivityPlan:
90
+ """POST /v1/connectivity/products. price_cents is catalogue data."""
91
+ body: dict[str, Any] = {
92
+ "name": name,
93
+ "data_mb": data_mb,
94
+ "validity_days": validity_days,
95
+ "countries": list(countries),
96
+ "price_cents": price_cents,
97
+ "currency": currency,
98
+ "package_template_id": package_template_id,
99
+ }
100
+ if account_id is not None:
101
+ body["account_id"] = account_id
102
+ payload = self._http.request(
103
+ "POST",
104
+ "/v1/connectivity/products",
105
+ body=body,
106
+ idempotency_key=idempotency_key,
107
+ )
108
+ return ConnectivityPlan.from_dict(_as_object(payload))
109
+
110
+ def retrieve(self, product_id: str) -> ConnectivityPlan:
111
+ """GET /v1/connectivity/products/:id."""
112
+ path = f"/v1/connectivity/products/{require_id(product_id, 'product')}"
113
+ return ConnectivityPlan.from_dict(_as_object(self._http.request("GET", path)))
114
+
115
+ def list(self) -> list[ConnectivityPlan]:
116
+ """GET /v1/connectivity/products. Newest first, up to 100."""
117
+ return parse_list(self._http.request("GET", "/v1/connectivity/products"), ConnectivityPlan.from_dict)
118
+
119
+
120
+ class Plans:
121
+ """Test plans and live plans this account can issue."""
122
+
123
+ def __init__(self, http: Http) -> None:
124
+ self._http = http
125
+
126
+ def list(self) -> list[ConnectivityPlan]:
127
+ """GET /v1/connectivity/plans."""
128
+ return parse_list(self._http.request("GET", "/v1/connectivity/plans"), ConnectivityPlan.from_dict)
129
+
130
+
131
+ class WebhookEndpoints:
132
+ """HTTPS endpoints that receive Carrier-Signature deliveries."""
133
+
134
+ def __init__(self, http: Http) -> None:
135
+ self._http = http
136
+
137
+ def create(self, url: str) -> WebhookEndpointCreated:
138
+ """POST /v1/connectivity/webhook_endpoints. The secret is on this response only."""
139
+ payload = self._http.request("POST", "/v1/connectivity/webhook_endpoints", body={"url": url})
140
+ return WebhookEndpointCreated.from_dict(_as_object(payload))
141
+
142
+ def list(self) -> list[WebhookEndpoint]:
143
+ """GET /v1/connectivity/webhook_endpoints. Secrets are omitted."""
144
+ payload = self._http.request("GET", "/v1/connectivity/webhook_endpoints")
145
+ return parse_list(payload, WebhookEndpoint.from_dict)
146
+
147
+ def delete(self, endpoint_id: str) -> DeletedWebhookEndpoint:
148
+ """DELETE /v1/connectivity/webhook_endpoints/:id."""
149
+ path = f"/v1/connectivity/webhook_endpoints/{require_id(endpoint_id, 'webhook endpoint')}"
150
+ return DeletedWebhookEndpoint.from_dict(_as_object(self._http.request("DELETE", path)))
151
+
152
+
153
+ class Events:
154
+ """Connectivity events for this account."""
155
+
156
+ def __init__(self, http: Http) -> None:
157
+ self._http = http
158
+
159
+ def list(self, *, intent: str | None = None) -> list[ConnectivityEvent]:
160
+ """GET /v1/connectivity/events. Newest first, at most 20."""
161
+ query = None if intent is None else {"intent": require_id(intent, "intent")}
162
+ return parse_list(self._http.request("GET", "/v1/connectivity/events", query=query), ConnectivityEvent.from_dict)
163
+
164
+ def retrieve(self, event_id: str) -> ConnectivityEvent:
165
+ """GET /v1/connectivity/events/:id."""
166
+ path = f"/v1/connectivity/events/{require_id(event_id, 'event')}"
167
+ return ConnectivityEvent.from_dict(_as_object(self._http.request("GET", path)))
168
+
169
+ def retry(self, event_id: str) -> ConnectivityEvent:
170
+ """POST /v1/connectivity/events/:id/retry."""
171
+ path = f"/v1/connectivity/events/{require_id(event_id, 'event')}/retry"
172
+ return ConnectivityEvent.from_dict(_as_object(self._http.request("POST", path)))
173
+
174
+
175
+ class UsageRecords:
176
+ """Usage lines on a test intent. Live usage is metered by the network."""
177
+
178
+ def __init__(self, http: Http) -> None:
179
+ self._http = http
180
+
181
+ def create(self, *, intent: str, quantity_mb: float, idempotency_key: str) -> ConnectivityUsageRecord:
182
+ """POST /v1/connectivity/usage_records."""
183
+ payload = self._http.request(
184
+ "POST",
185
+ "/v1/connectivity/usage_records",
186
+ body={"intent": intent, "quantity_mb": quantity_mb},
187
+ idempotency_key=idempotency_key,
188
+ )
189
+ return ConnectivityUsageRecord.from_dict(_as_object(payload))
190
+
191
+ def list(self, intent: str) -> list[ConnectivityUsageRecord]:
192
+ """GET /v1/connectivity/usage_records?intent=."""
193
+ query = {"intent": require_id(intent, "intent")}
194
+ payload = self._http.request("GET", "/v1/connectivity/usage_records", query=query)
195
+ return parse_list(payload, ConnectivityUsageRecord.from_dict)
196
+
197
+
198
+ def _as_object(payload: object) -> dict[str, Any]:
199
+ if not isinstance(payload, dict):
200
+ raise CarrierError(0, "invalid_response", "Carrier response is not an object.")
201
+ return payload
carrier/signature.py ADDED
@@ -0,0 +1,57 @@
1
+ """Webhook signature check. Same header the API sends as Carrier-Signature."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Final
7
+
8
+ DEFAULT_TOLERANCE_SECONDS: Final = 300
9
+
10
+
11
+ def verify_webhook_signature(
12
+ body: str,
13
+ signature: str,
14
+ secret: str,
15
+ *,
16
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
17
+ now_seconds: int | None = None,
18
+ ) -> bool:
19
+ """Return whether `signature` signs `body` under `secret`.
20
+
21
+ The header is `t=<unix>,v1=<hex>` over `{timestamp}.{body}`. A rotation
22
+ can put more than one `v1` value in the header. Any match passes.
23
+ `tolerance_seconds` of 0 turns the replay window off.
24
+ """
25
+ timestamps: list[str] = []
26
+ candidates: list[str] = []
27
+ for part in signature.split(","):
28
+ if "=" not in part:
29
+ continue
30
+ key, value = part.split("=", 1)
31
+ key = key.strip()
32
+ value = value.strip()
33
+ if value == "":
34
+ continue
35
+ if key == "t":
36
+ timestamps.append(value)
37
+ elif key == "v1":
38
+ candidates.append(value)
39
+ if not timestamps or not candidates:
40
+ return False
41
+ timestamp = timestamps[0]
42
+ if tolerance_seconds < 0:
43
+ return False
44
+ if tolerance_seconds > 0:
45
+ try:
46
+ signed_at = int(timestamp)
47
+ except ValueError:
48
+ return False
49
+ clock = int(time.time()) if now_seconds is None else now_seconds
50
+ if abs(clock - signed_at) > tolerance_seconds:
51
+ return False
52
+ digest = hmac.new(
53
+ secret.encode("utf-8"),
54
+ f"{timestamp}.{body}".encode("utf-8"),
55
+ hashlib.sha256,
56
+ ).hexdigest()
57
+ return any(hmac.compare_digest(digest, candidate) for candidate in candidates)
carrier/version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version. Kept beside the HTTP client so the user agent stays in sync."""
2
+
3
+ __version__ = "0.1.15"
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: carrier-python
3
+ Version: 0.1.15
4
+ Summary: Python client for the Carrier API, MCP server, and CLI.
5
+ Author: Lifecycle Innovations Limited
6
+ License: MIT
7
+ Project-URL: Homepage, https://carrier.llc/sdks/
8
+ Project-URL: Documentation, https://carrier.llc/docs/sdks/
9
+ Keywords: carrier,esim,connectivity
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+
16
+ # carrier-python
17
+
18
+ Python client for the Carrier API, the hosted MCP server, and the `carrier` CLI. `intents.create` posts to `/v1/connectivity/intents`. `call` uses an OpenAPI operation id. `Mcp` speaks Streamable HTTP. `Cli` runs the binary with an argument list. `rest_tools`, `mcp_tools`, and `cli_tools` return plain dicts for LangGraph or an OpenAI-style tool loop. The price on a product is catalogue data. Neither an intent nor a product debits a wallet.
19
+
20
+ ```bash
21
+ pip install carrier-python
22
+ ```
23
+
24
+ ```python
25
+ import os
26
+ from carrier import Carrier
27
+
28
+ carrier = Carrier(os.environ["CARRIER_API_KEY"])
29
+ intent = carrier.intents.create(
30
+ plan="test_global_1gb",
31
+ metadata={"order": "123"},
32
+ idempotency_key="order_123",
33
+ )
34
+ ```
35
+
36
+ `test_global_1gb` is a practice plan. The call succeeds, `livemode` is false, and the network is not used. The same idempotency key and body return the original intent.
37
+
38
+ A waiting intent takes a plan later:
39
+
40
+ ```python
41
+ carrier.intents.confirm(intent.id, plan="test_global_1gb")
42
+ ```
43
+
44
+ Webhook deliveries use `Carrier-Signature: t=<unix>,v1=<hex>` over `{timestamp}.{body}`. The endpoint secret (`whsec_…`) is returned once, on create.
45
+
46
+ ```python
47
+ from carrier import verify_webhook_signature
48
+
49
+ ok = verify_webhook_signature(raw_body, header, secret)
50
+ ```
51
+
52
+ `CarrierError.status`, `.code`, and `.type` match the API error envelope. A blank API key raises before a request is sent.
53
+
54
+ ## Issuance safety (0.1.11)
55
+
56
+ For `intents.create` and `call("issue_esim_from_inventory", ...)`, supply a stable business key. Issuance keys must match `[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}`. They are never trimmed, coerced, or replaced. Invalid keys fail before transport; nonissuance operations retain their existing key rules.
57
+
58
+ Transport loss, response-read failure, or response-decode failure after an issuance call raises `CarrierError` with `code == "outcome_unknown"`, the unchanged `.idempotency_key`, and `.retryable is False`. The SDK does not retry dispatch or mint a new key. Reconcile the original operation using that key; do not automatically reissue. A complete valid API error keeps its server status, code, message, and type. This is not a provider exactly-once guarantee. Custom transports must not retry issuance internally.
59
+
60
+ Every public operation:
61
+
62
+ ```python
63
+ accounts = carrier.call("list_reseller_accounts")
64
+ issued = carrier.call(
65
+ "create_connectivity_intent",
66
+ body={"plan": "test_global_1gb"},
67
+ idempotency_key="order_123",
68
+ )
69
+ ```
70
+
71
+ `security` on each catalog entry is `bearer`, `public`, `internal`, or `storefront`. A customer API key is `bearer`. The two internal routes do not accept that key.
72
+
73
+ ```python
74
+ from carrier import Mcp, Cli, rest_tools
75
+
76
+ session = Mcp(os.environ["ESIMVAULT_API_TOKEN"]) # ?TOKEN= on https://mcp.carrier.llc/mcp
77
+ tools = session.list_tools()
78
+ session.call_tool("list_subscribers", {"limit": 5})
79
+
80
+ cli = Cli()
81
+ cli.run(["subscribers", "list", "--limit", "5"])
82
+ agent_tools = rest_tools(carrier)
83
+ ```
84
+
85
+ `Mcp(..., auth="bearer")` sends an OAuth access token or a Clerk organization API key. `Cli` never invokes a shell. Pass `strict=False` only when you intentionally run a command that is not in the catalog.
86
+
87
+ Python 3.11 or newer. The package uses the standard library only. `pip install carrier-python` installs it from PyPI.
@@ -0,0 +1,23 @@
1
+ carrier/__init__.py,sha256=W9FU9skCg7U4MxhH6UKO_Sxw0R-x_i9eng4Hgkq90js,1291
2
+ carrier/adapters.py,sha256=Akxd630boQdyh4fUctI6xpxqiWze7wdrhHgWxTkD5AA,4374
3
+ carrier/catalog.py,sha256=F_7tTFu8vXJ4NuxUgwfQErRGNQCC9AVWqWxi7drK158,2871
4
+ carrier/cli.py,sha256=iFhB-j2ckurNuEK_e2khpjspYCmAjlb0RUNJJrddufs,2680
5
+ carrier/client.py,sha256=tJEsBoyA7oailopi84tndEy7X7nr3tbrZZ3p46tG5Yw,2074
6
+ carrier/errors.py,sha256=X61cqPBsgBF3-bq41DoNzLepabvhVDarpqF0Y7fyIDE,1923
7
+ carrier/http.py,sha256=h8YAZkG5Ouh4893Sww7CmlcnnsYjNjZJmwE96-ZsCjA,5161
8
+ carrier/idempotency.py,sha256=IzapZp2SmcIYJU9G_vfzsgu0n_h55O4Fcc3A45kaNwE,1698
9
+ carrier/mcp.py,sha256=EtIJ71rXf9bfeOGz93hCVtODnkGq57eY0XdXyiffu2k,9475
10
+ carrier/models.py,sha256=0_kKeAnewlpzwcn8K-KVCqxMQhs6oh70W9FlhNKmy1s,6638
11
+ carrier/operation_ids.py,sha256=GSucnszcNxtbkpUIcRth1oHZNnEOed2RMp3eGl6ldUY,5835
12
+ carrier/operations.py,sha256=4Zl3LIV7XtpP1Or-_IyUUjNs-mE59Aftnu4rrTz2OLw,8279
13
+ carrier/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ carrier/records.py,sha256=4e4m0AiePdw5axgJiSfHBt2ftf4Szx5sOl5_rkN15Rw,3442
15
+ carrier/resources.py,sha256=mj97lySHMJi12KQVhXlfZ-grAKmf7XkhgBNi_nGegZg,8057
16
+ carrier/signature.py,sha256=MZRp9Vj9heode_plZvXTGhN6P1wos0DEyOvS29l2XtA,1724
17
+ carrier/version.py,sha256=Mw8J1E9E8IbEMhFW_X8i2lF4PHFFzmUP-0N3u4L-1ZQ,108
18
+ carrier/data/cli.json,sha256=omgHkZLsfMwxT8SQg29vJjl43lswtZE0sGUmpR9L_uM,87647
19
+ carrier/data/operations.json,sha256=MXjZcvXUO8W35ir77R_BjidtmWGUex8QmkBfcrRShUk,73707
20
+ carrier_python-0.1.15.dist-info/METADATA,sha256=PJvDHp0kMUeOvf8AR-Kvmtxslo7dqJUjhNg5FyvokR0,3817
21
+ carrier_python-0.1.15.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ carrier_python-0.1.15.dist-info/top_level.txt,sha256=yXdhRVSdu7ru_mUxsikeswMIz0nQubrqn1MQa6EW2fQ,8
23
+ carrier_python-0.1.15.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ carrier