carrier-python 0.1.15__tar.gz
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_python-0.1.15/PKG-INFO +87 -0
- carrier_python-0.1.15/README.md +72 -0
- carrier_python-0.1.15/pyproject.toml +28 -0
- carrier_python-0.1.15/setup.cfg +4 -0
- carrier_python-0.1.15/src/carrier/__init__.py +52 -0
- carrier_python-0.1.15/src/carrier/adapters.py +121 -0
- carrier_python-0.1.15/src/carrier/catalog.py +108 -0
- carrier_python-0.1.15/src/carrier/cli.py +70 -0
- carrier_python-0.1.15/src/carrier/client.py +63 -0
- carrier_python-0.1.15/src/carrier/data/cli.json +3032 -0
- carrier_python-0.1.15/src/carrier/data/operations.json +3042 -0
- carrier_python-0.1.15/src/carrier/errors.py +51 -0
- carrier_python-0.1.15/src/carrier/http.py +135 -0
- carrier_python-0.1.15/src/carrier/idempotency.py +46 -0
- carrier_python-0.1.15/src/carrier/mcp.py +237 -0
- carrier_python-0.1.15/src/carrier/models.py +213 -0
- carrier_python-0.1.15/src/carrier/operation_ids.py +188 -0
- carrier_python-0.1.15/src/carrier/operations.py +192 -0
- carrier_python-0.1.15/src/carrier/py.typed +0 -0
- carrier_python-0.1.15/src/carrier/records.py +117 -0
- carrier_python-0.1.15/src/carrier/resources.py +201 -0
- carrier_python-0.1.15/src/carrier/signature.py +57 -0
- carrier_python-0.1.15/src/carrier/version.py +3 -0
- carrier_python-0.1.15/src/carrier_python.egg-info/PKG-INFO +87 -0
- carrier_python-0.1.15/src/carrier_python.egg-info/SOURCES.txt +29 -0
- carrier_python-0.1.15/src/carrier_python.egg-info/dependency_links.txt +1 -0
- carrier_python-0.1.15/src/carrier_python.egg-info/top_level.txt +1 -0
- carrier_python-0.1.15/tests/test_client.py +177 -0
- carrier_python-0.1.15/tests/test_issuance.py +184 -0
- carrier_python-0.1.15/tests/test_signature.py +78 -0
- carrier_python-0.1.15/tests/test_surface.py +215 -0
|
@@ -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,72 @@
|
|
|
1
|
+
# carrier-python
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install carrier-python
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from carrier import Carrier
|
|
12
|
+
|
|
13
|
+
carrier = Carrier(os.environ["CARRIER_API_KEY"])
|
|
14
|
+
intent = carrier.intents.create(
|
|
15
|
+
plan="test_global_1gb",
|
|
16
|
+
metadata={"order": "123"},
|
|
17
|
+
idempotency_key="order_123",
|
|
18
|
+
)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`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.
|
|
22
|
+
|
|
23
|
+
A waiting intent takes a plan later:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
carrier.intents.confirm(intent.id, plan="test_global_1gb")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Webhook deliveries use `Carrier-Signature: t=<unix>,v1=<hex>` over `{timestamp}.{body}`. The endpoint secret (`whsec_…`) is returned once, on create.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from carrier import verify_webhook_signature
|
|
33
|
+
|
|
34
|
+
ok = verify_webhook_signature(raw_body, header, secret)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`CarrierError.status`, `.code`, and `.type` match the API error envelope. A blank API key raises before a request is sent.
|
|
38
|
+
|
|
39
|
+
## Issuance safety (0.1.11)
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
Every public operation:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
accounts = carrier.call("list_reseller_accounts")
|
|
49
|
+
issued = carrier.call(
|
|
50
|
+
"create_connectivity_intent",
|
|
51
|
+
body={"plan": "test_global_1gb"},
|
|
52
|
+
idempotency_key="order_123",
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`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.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from carrier import Mcp, Cli, rest_tools
|
|
60
|
+
|
|
61
|
+
session = Mcp(os.environ["ESIMVAULT_API_TOKEN"]) # ?TOKEN= on https://mcp.carrier.llc/mcp
|
|
62
|
+
tools = session.list_tools()
|
|
63
|
+
session.call_tool("list_subscribers", {"limit": 5})
|
|
64
|
+
|
|
65
|
+
cli = Cli()
|
|
66
|
+
cli.run(["subscribers", "list", "--limit", "5"])
|
|
67
|
+
agent_tools = rest_tools(carrier)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`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.
|
|
71
|
+
|
|
72
|
+
Python 3.11 or newer. The package uses the standard library only. `pip install carrier-python` installs it from PyPI.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "carrier-python"
|
|
7
|
+
version = "0.1.15"
|
|
8
|
+
description = "Python client for the Carrier API, MCP server, and CLI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Lifecycle Innovations Limited" }]
|
|
13
|
+
keywords = ["carrier", "esim", "connectivity"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://carrier.llc/sdks/"
|
|
22
|
+
Documentation = "https://carrier.llc/docs/sdks/"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
carrier = ["py.typed", "data/*.json"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Python client for the Carrier API, MCP server, and CLI."""
|
|
2
|
+
|
|
3
|
+
from carrier.adapters import cli_tools, mcp_tools, rest_tools
|
|
4
|
+
from carrier.catalog import cli_commands, operations
|
|
5
|
+
from carrier.cli import Cli, CliResult
|
|
6
|
+
from carrier.client import Carrier
|
|
7
|
+
from carrier.errors import CarrierError
|
|
8
|
+
from carrier.mcp import DEFAULT_MCP_URL, Mcp
|
|
9
|
+
from carrier.operation_ids import OperationId
|
|
10
|
+
from carrier.models import (
|
|
11
|
+
ConnectivityEsim,
|
|
12
|
+
ConnectivityIntent,
|
|
13
|
+
ConnectivityPlan,
|
|
14
|
+
IntentError,
|
|
15
|
+
PlanPrice,
|
|
16
|
+
)
|
|
17
|
+
from carrier.records import (
|
|
18
|
+
ConnectivityEvent,
|
|
19
|
+
ConnectivityUsageRecord,
|
|
20
|
+
DeletedWebhookEndpoint,
|
|
21
|
+
WebhookEndpoint,
|
|
22
|
+
WebhookEndpointCreated,
|
|
23
|
+
)
|
|
24
|
+
from carrier.signature import verify_webhook_signature
|
|
25
|
+
from carrier.version import __version__
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DEFAULT_MCP_URL",
|
|
29
|
+
"Carrier",
|
|
30
|
+
"CarrierError",
|
|
31
|
+
"Cli",
|
|
32
|
+
"CliResult",
|
|
33
|
+
"Mcp",
|
|
34
|
+
"OperationId",
|
|
35
|
+
"ConnectivityEsim",
|
|
36
|
+
"ConnectivityEvent",
|
|
37
|
+
"ConnectivityIntent",
|
|
38
|
+
"ConnectivityPlan",
|
|
39
|
+
"ConnectivityUsageRecord",
|
|
40
|
+
"DeletedWebhookEndpoint",
|
|
41
|
+
"IntentError",
|
|
42
|
+
"PlanPrice",
|
|
43
|
+
"WebhookEndpoint",
|
|
44
|
+
"WebhookEndpointCreated",
|
|
45
|
+
"__version__",
|
|
46
|
+
"cli_commands",
|
|
47
|
+
"cli_tools",
|
|
48
|
+
"mcp_tools",
|
|
49
|
+
"operations",
|
|
50
|
+
"rest_tools",
|
|
51
|
+
"verify_webhook_signature",
|
|
52
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Dependency-free tool definitions for LangGraph, OpenAI, and the Vercel AI SDK.
|
|
2
|
+
|
|
3
|
+
Each tool is a plain dict with `name`, `description`, `inputSchema`, and
|
|
4
|
+
`execute`. Pass `inputSchema` to an AI SDK `jsonSchema()` helper, or use the
|
|
5
|
+
dict as a LangGraph / OpenAI function tool. Nothing in this module imports
|
|
6
|
+
those libraries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
from carrier.catalog import Operation, cli_commands, operations
|
|
15
|
+
from carrier.cli import Cli, CliResult
|
|
16
|
+
from carrier.client import Carrier
|
|
17
|
+
from carrier.errors import CarrierError
|
|
18
|
+
from carrier.mcp import Mcp
|
|
19
|
+
from carrier.operations import arguments_to_call, input_schema
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Tool(TypedDict):
|
|
23
|
+
"""One callable tool definition."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
inputSchema: dict[str, object]
|
|
28
|
+
execute: object
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rest_tools(client: Carrier) -> list[Tool]:
|
|
32
|
+
"""One tool per OpenAPI operation. `execute` calls `client.call`."""
|
|
33
|
+
tools: list[Tool] = []
|
|
34
|
+
for spec in operations():
|
|
35
|
+
|
|
36
|
+
def execute(arguments: Mapping[str, object], spec: Operation = spec) -> object:
|
|
37
|
+
prepared = arguments_to_call(spec, arguments)
|
|
38
|
+
return client.call(spec["id"], **_call_kwargs(prepared))
|
|
39
|
+
|
|
40
|
+
tools.append(
|
|
41
|
+
{
|
|
42
|
+
"name": spec["id"],
|
|
43
|
+
"description": f"{spec['summary']} ({spec['method']} {spec['path']}, {spec['security']})",
|
|
44
|
+
"inputSchema": input_schema(spec),
|
|
45
|
+
"execute": execute,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
return tools
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mcp_tools(session: Mcp) -> list[Tool]:
|
|
52
|
+
"""One tool per MCP tool advertised by `session.list_tools()`."""
|
|
53
|
+
tools: list[Tool] = []
|
|
54
|
+
for advertised in session.list_tools():
|
|
55
|
+
if not isinstance(advertised, dict) or not isinstance(advertised.get("name"), str):
|
|
56
|
+
continue
|
|
57
|
+
name = advertised["name"]
|
|
58
|
+
description = advertised.get("description")
|
|
59
|
+
schema = advertised.get("inputSchema")
|
|
60
|
+
input_schema_value = schema if isinstance(schema, dict) else {"type": "object", "properties": {}}
|
|
61
|
+
|
|
62
|
+
def execute(arguments: Mapping[str, object], tool_name: str = name) -> dict[str, object]:
|
|
63
|
+
return session.call_tool(tool_name, arguments)
|
|
64
|
+
|
|
65
|
+
tools.append(
|
|
66
|
+
{
|
|
67
|
+
"name": name,
|
|
68
|
+
"description": description if isinstance(description, str) and description else name,
|
|
69
|
+
"inputSchema": input_schema_value,
|
|
70
|
+
"execute": execute,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return tools
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cli_tools(cli: Cli) -> list[Tool]:
|
|
77
|
+
"""One tool per catalogued CLI command. Extra flags go in `args`."""
|
|
78
|
+
tools: list[Tool] = []
|
|
79
|
+
for command in cli_commands():
|
|
80
|
+
tokens = command["invocation"].split()[1:]
|
|
81
|
+
option_text = ", ".join(option["flags"] for option in command["options"]) or "no flags"
|
|
82
|
+
description = f"{command['summary']} Flags: {option_text}."
|
|
83
|
+
|
|
84
|
+
def execute(arguments: Mapping[str, object], tokens: list[str] = tokens) -> CliResult:
|
|
85
|
+
extra = arguments.get("args", [])
|
|
86
|
+
if extra is None:
|
|
87
|
+
extra = []
|
|
88
|
+
if not isinstance(extra, list) or not all(isinstance(item, str) for item in extra):
|
|
89
|
+
raise CarrierError(400, "invalid_argument", "CLI tool args must be a list of strings.")
|
|
90
|
+
return cli.run([*tokens, *extra])
|
|
91
|
+
|
|
92
|
+
tools.append(
|
|
93
|
+
{
|
|
94
|
+
"name": "cli_" + "_".join(tokens),
|
|
95
|
+
"description": description,
|
|
96
|
+
"inputSchema": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"args": {
|
|
100
|
+
"type": "array",
|
|
101
|
+
"items": {"type": "string"},
|
|
102
|
+
"description": "Flags and values after the command name.",
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"additionalProperties": False,
|
|
106
|
+
},
|
|
107
|
+
"execute": execute,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return tools
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _call_kwargs(prepared: Mapping[str, object]) -> dict[str, object]:
|
|
114
|
+
kwargs: dict[str, object] = {}
|
|
115
|
+
for key in ("path", "query", "headers", "body", "idempotency_key"):
|
|
116
|
+
if key in prepared:
|
|
117
|
+
kwargs[key] = prepared[key]
|
|
118
|
+
return kwargs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["Tool", "cli_tools", "mcp_tools", "rest_tools"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Operation and CLI catalogs generated from the public contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from typing import TypedDict, cast
|
|
9
|
+
|
|
10
|
+
from carrier.errors import CarrierError
|
|
11
|
+
from carrier.operation_ids import OperationId
|
|
12
|
+
|
|
13
|
+
HEADER_FIELDS = {
|
|
14
|
+
"Idempotency-Key": "idempotency_key",
|
|
15
|
+
"X-Idempotency-Key": "idempotency_key",
|
|
16
|
+
"X-Confirm-Token": "confirm_token",
|
|
17
|
+
"stripe-signature": "stripe_signature",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Param(TypedDict):
|
|
22
|
+
"""One path, query, or header parameter."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
required: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Operation(TypedDict):
|
|
29
|
+
"""One OpenAPI operation the generic caller can invoke."""
|
|
30
|
+
|
|
31
|
+
id: OperationId
|
|
32
|
+
method: str
|
|
33
|
+
path: str
|
|
34
|
+
tag: str
|
|
35
|
+
summary: str
|
|
36
|
+
security: str
|
|
37
|
+
body: bool
|
|
38
|
+
bodyRequired: bool
|
|
39
|
+
pathParams: list[Param]
|
|
40
|
+
queryParams: list[Param]
|
|
41
|
+
headerParams: list[Param]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CliOption(TypedDict):
|
|
45
|
+
"""One flag on a carrier CLI command."""
|
|
46
|
+
|
|
47
|
+
flags: str
|
|
48
|
+
description: str
|
|
49
|
+
required: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CliCommand(TypedDict):
|
|
53
|
+
"""One command from the published CLI catalog."""
|
|
54
|
+
|
|
55
|
+
invocation: str
|
|
56
|
+
domain: str
|
|
57
|
+
summary: str
|
|
58
|
+
write: bool
|
|
59
|
+
transport: str
|
|
60
|
+
tool: str | None
|
|
61
|
+
restMethod: str | None
|
|
62
|
+
restPath: str | None
|
|
63
|
+
options: list[CliOption]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache(maxsize=1)
|
|
67
|
+
def operations() -> tuple[Operation, ...]:
|
|
68
|
+
"""Every operation in the public OpenAPI document, in spec order."""
|
|
69
|
+
return tuple(cast(list[Operation], _load("operations.json")))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@lru_cache(maxsize=1)
|
|
73
|
+
def cli_commands() -> tuple[CliCommand, ...]:
|
|
74
|
+
"""Every command in the published CLI catalog."""
|
|
75
|
+
return tuple(cast(list[CliCommand], _load("cli.json")))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def operation(operation_id: str) -> Operation:
|
|
79
|
+
"""Return one operation, or raise when the id is not in the catalog."""
|
|
80
|
+
found = _operation_index().get(operation_id)
|
|
81
|
+
if found is None:
|
|
82
|
+
raise CarrierError(400, "unknown_operation", f"Unknown operation {operation_id}.")
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command_for_args(args: list[str]) -> CliCommand | None:
|
|
87
|
+
"""Match the longest catalog command that is a prefix of args."""
|
|
88
|
+
best: CliCommand | None = None
|
|
89
|
+
best_len = -1
|
|
90
|
+
for command in cli_commands():
|
|
91
|
+
tokens = command["invocation"].split()[1:]
|
|
92
|
+
if args[: len(tokens)] == tokens and len(tokens) > best_len:
|
|
93
|
+
best = command
|
|
94
|
+
best_len = len(tokens)
|
|
95
|
+
return best
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@lru_cache(maxsize=1)
|
|
99
|
+
def _operation_index() -> dict[str, Operation]:
|
|
100
|
+
return {item["id"]: item for item in operations()}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load(name: str) -> list[object]:
|
|
104
|
+
raw = files("carrier").joinpath("data", name).read_text(encoding="utf-8")
|
|
105
|
+
parsed = json.loads(raw)
|
|
106
|
+
if not isinstance(parsed, list):
|
|
107
|
+
raise CarrierError(500, "invalid_catalog", f"{name} is not a list.")
|
|
108
|
+
return cast(list[object], parsed)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Runner for the carrier CLI. Arguments are passed as a list, never through a shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from carrier.catalog import CliCommand, cli_commands, command_for_args
|
|
10
|
+
from carrier.errors import CarrierError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CliResult:
|
|
15
|
+
"""Stdout and stderr from one carrier invocation."""
|
|
16
|
+
|
|
17
|
+
code: int
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Runner = Callable[[str, list[str]], CliResult]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Cli:
|
|
26
|
+
"""Run catalogued `carrier` commands."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, binary: str = "carrier", runner: Runner | None = None) -> None:
|
|
29
|
+
if binary.strip() == "" or "\x00" in binary:
|
|
30
|
+
raise CarrierError(400, "invalid_cli", "The carrier binary path is not valid.")
|
|
31
|
+
self.binary = binary
|
|
32
|
+
self._runner = runner if runner is not None else _subprocess_runner
|
|
33
|
+
|
|
34
|
+
def commands(self) -> tuple[CliCommand, ...]:
|
|
35
|
+
"""Every command in the published catalog."""
|
|
36
|
+
return cli_commands()
|
|
37
|
+
|
|
38
|
+
def describe(self, invocation: str) -> CliCommand:
|
|
39
|
+
"""Return one catalog entry, such as `carrier subscribers list`."""
|
|
40
|
+
for command in cli_commands():
|
|
41
|
+
if command["invocation"] == invocation:
|
|
42
|
+
return command
|
|
43
|
+
raise CarrierError(400, "unknown_command", f"Unknown command {invocation}.")
|
|
44
|
+
|
|
45
|
+
def run(self, args: Sequence[str], *, strict: bool = True) -> CliResult:
|
|
46
|
+
"""Run `carrier` with args. Strict mode rejects commands missing from the catalog."""
|
|
47
|
+
argv = _validate_args(args)
|
|
48
|
+
if strict and command_for_args(argv) is None:
|
|
49
|
+
joined = " ".join(argv) if argv else "(empty)"
|
|
50
|
+
raise CarrierError(400, "unknown_command", f"Unknown command carrier {joined}.")
|
|
51
|
+
return self._runner(self.binary, argv)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_args(args: Sequence[str]) -> list[str]:
|
|
55
|
+
argv: list[str] = []
|
|
56
|
+
for arg in args:
|
|
57
|
+
if not isinstance(arg, str) or "\x00" in arg:
|
|
58
|
+
raise CarrierError(400, "invalid_argument", "CLI arguments must be strings without NUL bytes.")
|
|
59
|
+
argv.append(arg)
|
|
60
|
+
return argv
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _subprocess_runner(binary: str, args: list[str]) -> CliResult:
|
|
64
|
+
try:
|
|
65
|
+
completed = subprocess.run([binary, *args], capture_output=True, text=True, check=False)
|
|
66
|
+
except FileNotFoundError as error:
|
|
67
|
+
raise CarrierError(0, "cli_not_found", f"Could not run {binary}.") from error
|
|
68
|
+
except OSError as error:
|
|
69
|
+
raise CarrierError(0, "cli_not_found", str(error) or f"Could not run {binary}.") from error
|
|
70
|
+
return CliResult(completed.returncode, completed.stdout, completed.stderr)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Typed client for the Carrier connectivity API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from carrier.errors import CarrierError
|
|
6
|
+
from carrier.http import DEFAULT_BASE_URL, Http, Transport, urllib_transport
|
|
7
|
+
from carrier.operation_ids import OperationId
|
|
8
|
+
from carrier.operations import call_operation
|
|
9
|
+
from carrier.resources import Events, Intents, Plans, Products, UsageRecords, WebhookEndpoints
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Carrier:
|
|
13
|
+
"""Client for one Carrier API key.
|
|
14
|
+
|
|
15
|
+
`intents.create` issues one eSIM. `call` reaches every public OpenAPI
|
|
16
|
+
operation id. The price on a product is catalogue data. Neither the intent
|
|
17
|
+
nor the product call debits a wallet.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str,
|
|
23
|
+
*,
|
|
24
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
25
|
+
transport: Transport | None = None,
|
|
26
|
+
timeout: float = 30,
|
|
27
|
+
) -> None:
|
|
28
|
+
if api_key.strip() == "":
|
|
29
|
+
raise CarrierError(400, "invalid_api_key", "A Carrier API key is required.")
|
|
30
|
+
http = Http(
|
|
31
|
+
api_key,
|
|
32
|
+
base_url or DEFAULT_BASE_URL,
|
|
33
|
+
transport if transport is not None else urllib_transport(timeout),
|
|
34
|
+
timeout,
|
|
35
|
+
)
|
|
36
|
+
self._http = http
|
|
37
|
+
self.intents = Intents(http)
|
|
38
|
+
self.products = Products(http)
|
|
39
|
+
self.plans = Plans(http)
|
|
40
|
+
self.webhook_endpoints = WebhookEndpoints(http)
|
|
41
|
+
self.events = Events(http)
|
|
42
|
+
self.usage_records = UsageRecords(http)
|
|
43
|
+
|
|
44
|
+
def call(
|
|
45
|
+
self,
|
|
46
|
+
operation_id: OperationId,
|
|
47
|
+
*,
|
|
48
|
+
path: Mapping[str, object] | None = None,
|
|
49
|
+
query: Mapping[str, object] | None = None,
|
|
50
|
+
headers: Mapping[str, object] | None = None,
|
|
51
|
+
body: object | None = None,
|
|
52
|
+
idempotency_key: str | None = None,
|
|
53
|
+
) -> object:
|
|
54
|
+
"""Call any operation id from the public OpenAPI document."""
|
|
55
|
+
return call_operation(
|
|
56
|
+
self._http,
|
|
57
|
+
operation_id,
|
|
58
|
+
path=path,
|
|
59
|
+
query=query,
|
|
60
|
+
headers=headers,
|
|
61
|
+
body=body,
|
|
62
|
+
idempotency_key=idempotency_key,
|
|
63
|
+
)
|